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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { execFileSync } = require("child_process");
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SOCKET_MODE = 0o600;
|
|
5
|
+
const ALLOWED_SOCKET_MODES = new Set([0o600, 0o660]);
|
|
6
|
+
const MAX_GID = 0xfffffffe;
|
|
7
|
+
const SOCKET_GROUP_PATTERN = /^(?:\d+|[A-Za-z_][A-Za-z0-9_.-]*)$/;
|
|
8
|
+
|
|
9
|
+
function parseSocketMode(value) {
|
|
10
|
+
if (value === undefined) return DEFAULT_SOCKET_MODE;
|
|
11
|
+
if (typeof value === "number" && ALLOWED_SOCKET_MODES.has(value)) return value;
|
|
12
|
+
if (value === 600) return DEFAULT_SOCKET_MODE;
|
|
13
|
+
if (value === 660) return 0o660;
|
|
14
|
+
const text = String(value).trim();
|
|
15
|
+
if (!/^0?(?:600|660)$/.test(text)) {
|
|
16
|
+
throw new Error("SURF_SOCKET_MODE must be 600 or 660");
|
|
17
|
+
}
|
|
18
|
+
return Number.parseInt(text, 8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateSocketGroup(value) {
|
|
22
|
+
if (value === undefined) return undefined;
|
|
23
|
+
if (value === null || typeof value === "boolean") {
|
|
24
|
+
throw new Error("SURF_SOCKET_GROUP must be a numeric gid or a simple group name");
|
|
25
|
+
}
|
|
26
|
+
const group = String(value).trim();
|
|
27
|
+
if (!SOCKET_GROUP_PATTERN.test(group)) {
|
|
28
|
+
throw new Error("SURF_SOCKET_GROUP must be a numeric gid or a simple group name");
|
|
29
|
+
}
|
|
30
|
+
if (/^\d+$/.test(group)) {
|
|
31
|
+
const gid = Number(group);
|
|
32
|
+
if (!Number.isSafeInteger(gid) || gid < 0 || gid > MAX_GID) {
|
|
33
|
+
throw new Error("SURF_SOCKET_GROUP gid is out of range");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return group;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSocketConfig(socketMode, socketGroup) {
|
|
40
|
+
const mode = socketMode === undefined ? undefined : parseSocketMode(socketMode);
|
|
41
|
+
const group = socketGroup === undefined ? undefined : validateSocketGroup(socketGroup);
|
|
42
|
+
if (mode === 0o660 && !group) {
|
|
43
|
+
throw new Error("SURF_SOCKET_MODE=660 requires SURF_SOCKET_GROUP");
|
|
44
|
+
}
|
|
45
|
+
return { mode, group };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveSocketGroup(value) {
|
|
49
|
+
const group = validateSocketGroup(value);
|
|
50
|
+
if (group === undefined) return undefined;
|
|
51
|
+
if (/^\d+$/.test(group)) return Number(group);
|
|
52
|
+
|
|
53
|
+
const command = process.platform === "darwin" ? "dscl" : "getent";
|
|
54
|
+
const args = process.platform === "darwin"
|
|
55
|
+
? [".", "-read", `/Groups/${group}`, "PrimaryGroupID"]
|
|
56
|
+
: ["group", group];
|
|
57
|
+
let output;
|
|
58
|
+
try {
|
|
59
|
+
output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(`could not resolve socket group ${group}: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
const match = process.platform === "darwin"
|
|
64
|
+
? output.match(/\bPrimaryGroupID:\s*(\d+)\b/)
|
|
65
|
+
: output.trim().split(/\r?\n/, 1)[0]?.split(":")[2]?.match(/^\d+$/);
|
|
66
|
+
const gid = Number(match?.[1] || match?.[0]);
|
|
67
|
+
if (!Number.isSafeInteger(gid) || gid < 0 || gid > MAX_GID) {
|
|
68
|
+
throw new Error(`could not resolve socket group ${group}`);
|
|
69
|
+
}
|
|
70
|
+
return gid;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveSocketPermissions(socketMode, socketGroup) {
|
|
74
|
+
const config = normalizeSocketConfig(socketMode, socketGroup);
|
|
75
|
+
const mode = config.mode ?? DEFAULT_SOCKET_MODE;
|
|
76
|
+
const gid = resolveSocketGroup(config.group);
|
|
77
|
+
return { mode, group: config.group, gid };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertSocketPath(socketPath) {
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = fs.lstatSync(socketPath);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error(`could not inspect local socket: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
if (stat.isSymbolicLink()) throw new Error("refusing symbolic-link local socket");
|
|
88
|
+
if (!stat.isSocket()) throw new Error("local socket path is not a Unix socket");
|
|
89
|
+
return stat;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applySocketPermissions(socketPath, permissions) {
|
|
93
|
+
const before = assertSocketPath(socketPath);
|
|
94
|
+
if (permissions.gid !== undefined) {
|
|
95
|
+
fs.chownSync(socketPath, before.uid, permissions.gid);
|
|
96
|
+
}
|
|
97
|
+
fs.chmodSync(socketPath, permissions.mode);
|
|
98
|
+
const after = assertSocketPath(socketPath);
|
|
99
|
+
if ((after.mode & 0o7777) !== permissions.mode) {
|
|
100
|
+
throw new Error(`local socket mode is not ${permissions.mode.toString(8)}`);
|
|
101
|
+
}
|
|
102
|
+
if (permissions.gid !== undefined && after.gid !== permissions.gid) {
|
|
103
|
+
throw new Error(`local socket group is not ${permissions.group}`);
|
|
104
|
+
}
|
|
105
|
+
return after;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
applySocketPermissions,
|
|
110
|
+
normalizeSocketConfig,
|
|
111
|
+
parseSocketMode,
|
|
112
|
+
resolveSocketPermissions,
|
|
113
|
+
validateSocketGroup,
|
|
114
|
+
};
|
package/native/tool-scope.cjs
CHANGED
|
@@ -21,7 +21,7 @@ const BROWSER_READ_TOOLS = new Set([
|
|
|
21
21
|
]);
|
|
22
22
|
|
|
23
23
|
const BROWSER_WRITE_TOOLS = new Set([
|
|
24
|
-
"session.new", "session.ensure", "session.close", "session.rebind", "session.reopen",
|
|
24
|
+
"session.new", "session.ensure", "session.cleanup", "session.close", "session.rebind", "session.reopen",
|
|
25
25
|
"tab.new", "new_tab", "tabs_create",
|
|
26
26
|
"tab.move", "tab.switch", "switch_tab",
|
|
27
27
|
"tab.group", "tab.ungroup",
|
|
@@ -36,7 +36,7 @@ const BROWSER_WRITE_TARGETED_TOOLS = new Set([
|
|
|
36
36
|
]);
|
|
37
37
|
|
|
38
38
|
const TAB_TOOLS = new Set([
|
|
39
|
-
"ai", "computer", "batch", "record", "animate-audit", "perf-audit",
|
|
39
|
+
"ai", "computer", "batch", "record", "video.start", "animate-audit", "perf-audit",
|
|
40
40
|
"navigate", "go", "back", "forward", "reload", "tab.reload",
|
|
41
41
|
"screenshot", "snap", "resize",
|
|
42
42
|
"page.read", "read_page", "page.text", "get_page_text", "page.html", "page.save", "page.state",
|
|
@@ -87,10 +87,14 @@ function classifyTool(tool, args = {}) {
|
|
|
87
87
|
return { scope: "browser-write", targetUse: "default-tab" };
|
|
88
88
|
}
|
|
89
89
|
if (BROWSER_WRITE_TOOLS.has(tool)) return { scope: "browser-write", targetUse: "browser" };
|
|
90
|
+
if (tool === "video.stop" || tool === "video.status" || tool === "video.restart") {
|
|
91
|
+
return { scope: "host", targetUse: "host", resourceKeys: ["video-recorder"] };
|
|
92
|
+
}
|
|
90
93
|
if (TAB_TOOLS.has(tool)) {
|
|
91
94
|
const resourceKeys = [];
|
|
92
95
|
if (tool === "network.export" && typeof args.output === "string") resourceKeys.push(`file:${path.resolve(args.output)}`);
|
|
93
96
|
if (tool.startsWith("playbook.record.")) resourceKeys.push("playbook-recorder");
|
|
97
|
+
if (tool === "video.start") resourceKeys.push("video-recorder");
|
|
94
98
|
return { scope: "tab", targetUse: "default-tab", resourceKeys };
|
|
95
99
|
}
|
|
96
100
|
return { scope: "browser-write", targetUse: "browser", conservative: true };
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VIDEO_FPS = 30;
|
|
6
|
+
const MAX_VIDEO_FPS = 60;
|
|
7
|
+
const MIN_VIDEO_FPS = 1;
|
|
8
|
+
const VIDEO_FRAME_QUEUE_LIMIT = 2;
|
|
9
|
+
const VIDEO_BACKFILL_LIMIT_MS = 5000;
|
|
10
|
+
const DEFAULT_STARTUP_GRACE_MS = 100;
|
|
11
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5000;
|
|
12
|
+
|
|
13
|
+
class VideoRecorderError extends Error {
|
|
14
|
+
constructor(code, message, details = undefined) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "VideoRecorderError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (details !== undefined) this.details = details;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseVideoFps(value, fallback = DEFAULT_VIDEO_FPS) {
|
|
23
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
24
|
+
if (typeof value === "boolean") {
|
|
25
|
+
throw new VideoRecorderError("video_fps_invalid", "fps must be a number");
|
|
26
|
+
}
|
|
27
|
+
const fps = Number(value);
|
|
28
|
+
if (!Number.isFinite(fps) || fps < MIN_VIDEO_FPS || fps > MAX_VIDEO_FPS) {
|
|
29
|
+
throw new VideoRecorderError("video_fps_invalid", `fps must be between ${MIN_VIDEO_FPS} and ${MAX_VIDEO_FPS}`);
|
|
30
|
+
}
|
|
31
|
+
return fps;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateVideoOutputPath(output, { createParent = true } = {}) {
|
|
35
|
+
if (typeof output !== "string" || !output.trim()) {
|
|
36
|
+
throw new VideoRecorderError("video_output_invalid", "video output path is required");
|
|
37
|
+
}
|
|
38
|
+
if (output.includes("\0")) {
|
|
39
|
+
throw new VideoRecorderError("video_output_invalid", "video output path contains an invalid null byte");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const resolved = path.resolve(output);
|
|
43
|
+
let existing;
|
|
44
|
+
try {
|
|
45
|
+
existing = fs.statSync(resolved);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code !== "ENOENT") {
|
|
48
|
+
throw new VideoRecorderError("video_output_invalid", `Cannot access video output path: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (existing?.isDirectory()) {
|
|
52
|
+
throw new VideoRecorderError("video_output_invalid", `Video output path is a directory: ${resolved}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parent = path.dirname(resolved);
|
|
56
|
+
if (createParent) {
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new VideoRecorderError("video_output_unwritable", `Cannot create video output directory ${parent}: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
let writableParent = parent;
|
|
65
|
+
while (!fs.existsSync(writableParent)) {
|
|
66
|
+
const next = path.dirname(writableParent);
|
|
67
|
+
if (next === writableParent) break;
|
|
68
|
+
writableParent = next;
|
|
69
|
+
}
|
|
70
|
+
fs.accessSync(writableParent, fs.constants.W_OK);
|
|
71
|
+
if (existing) fs.accessSync(resolved, fs.constants.W_OK);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new VideoRecorderError("video_output_unwritable", `Video output path is not writable: ${resolved}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildFfmpegArgs(output, fps) {
|
|
80
|
+
return [
|
|
81
|
+
"-hide_banner",
|
|
82
|
+
"-loglevel",
|
|
83
|
+
"error",
|
|
84
|
+
"-y",
|
|
85
|
+
"-f",
|
|
86
|
+
"image2pipe",
|
|
87
|
+
"-vcodec",
|
|
88
|
+
"mjpeg",
|
|
89
|
+
"-framerate",
|
|
90
|
+
String(fps),
|
|
91
|
+
"-i",
|
|
92
|
+
"pipe:0",
|
|
93
|
+
"-an",
|
|
94
|
+
"-vf",
|
|
95
|
+
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
96
|
+
"-c:v",
|
|
97
|
+
"libvpx-vp9",
|
|
98
|
+
"-pix_fmt",
|
|
99
|
+
"yuv420p",
|
|
100
|
+
"-r",
|
|
101
|
+
String(fps),
|
|
102
|
+
"-deadline",
|
|
103
|
+
"realtime",
|
|
104
|
+
"-row-mt",
|
|
105
|
+
"1",
|
|
106
|
+
"-f",
|
|
107
|
+
"webm",
|
|
108
|
+
output,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeChildError(error) {
|
|
113
|
+
if (error instanceof VideoRecorderError) return error;
|
|
114
|
+
const message = error?.message || String(error || "ffmpeg failed");
|
|
115
|
+
if (error?.code === "ENOENT") {
|
|
116
|
+
return new VideoRecorderError("ffmpeg_missing", "ffmpeg was not found on PATH. Install ffmpeg and retry.");
|
|
117
|
+
}
|
|
118
|
+
return new VideoRecorderError("ffmpeg_failed", message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class VideoRecorder {
|
|
122
|
+
constructor({
|
|
123
|
+
output,
|
|
124
|
+
fps = DEFAULT_VIDEO_FPS,
|
|
125
|
+
tabId,
|
|
126
|
+
recorderId,
|
|
127
|
+
spawnImpl = spawn,
|
|
128
|
+
now = () => Date.now(),
|
|
129
|
+
setIntervalImpl = setInterval,
|
|
130
|
+
clearIntervalImpl = clearInterval,
|
|
131
|
+
startupGraceMs = DEFAULT_STARTUP_GRACE_MS,
|
|
132
|
+
stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
|
|
133
|
+
onFailure = () => {},
|
|
134
|
+
} = {}) {
|
|
135
|
+
this.output = validateVideoOutputPath(output, { createParent: false });
|
|
136
|
+
this.fps = parseVideoFps(fps);
|
|
137
|
+
this.tabId = tabId;
|
|
138
|
+
this.recorderId = recorderId;
|
|
139
|
+
this.spawnImpl = spawnImpl;
|
|
140
|
+
this.now = now;
|
|
141
|
+
this.setIntervalImpl = setIntervalImpl;
|
|
142
|
+
this.clearIntervalImpl = clearIntervalImpl;
|
|
143
|
+
this.startupGraceMs = startupGraceMs;
|
|
144
|
+
this.stopTimeoutMs = stopTimeoutMs;
|
|
145
|
+
this.onFailure = onFailure;
|
|
146
|
+
|
|
147
|
+
this.state = "idle";
|
|
148
|
+
this.child = null;
|
|
149
|
+
this.ticker = null;
|
|
150
|
+
this.startedAt = null;
|
|
151
|
+
this.stoppedAt = null;
|
|
152
|
+
this.frames = 0;
|
|
153
|
+
this.capturedFrames = 0;
|
|
154
|
+
this.lastFrame = null;
|
|
155
|
+
this.frameQueue = [];
|
|
156
|
+
this.failure = null;
|
|
157
|
+
this.closeCode = null;
|
|
158
|
+
this.closeSignal = null;
|
|
159
|
+
this.stopPromise = null;
|
|
160
|
+
this._failureNotified = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get intervalMs() {
|
|
164
|
+
return Math.max(1, Math.round(1000 / this.fps));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
status() {
|
|
168
|
+
return {
|
|
169
|
+
status: this.state === "active" || this.state === "starting" || this.state === "stopping" ? "active" : this.state,
|
|
170
|
+
recorderId: this.recorderId,
|
|
171
|
+
path: this.output,
|
|
172
|
+
fps: this.fps,
|
|
173
|
+
tabId: this.tabId,
|
|
174
|
+
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : undefined,
|
|
175
|
+
stoppedAt: this.stoppedAt ? new Date(this.stoppedAt).toISOString() : undefined,
|
|
176
|
+
durationMs: this.startedAt ? Math.max(0, (this.stoppedAt || this.now()) - this.startedAt) : 0,
|
|
177
|
+
frames: this.frames,
|
|
178
|
+
capturedFrames: this.capturedFrames,
|
|
179
|
+
...(this.failure ? { error: this.failure.message, errorCode: this.failure.code } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async start() {
|
|
184
|
+
if (this.state !== "idle") {
|
|
185
|
+
throw new VideoRecorderError("video_recorder_state", `Cannot start video recorder in state ${this.state}`);
|
|
186
|
+
}
|
|
187
|
+
// Create the parent only after all basic path validation has completed.
|
|
188
|
+
validateVideoOutputPath(this.output, { createParent: true });
|
|
189
|
+
|
|
190
|
+
this.state = "starting";
|
|
191
|
+
this.startedAt = this.now();
|
|
192
|
+
let child;
|
|
193
|
+
try {
|
|
194
|
+
child = this.spawnImpl("ffmpeg", buildFfmpegArgs(this.output, this.fps), {
|
|
195
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
196
|
+
windowsHide: true,
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
this._fail(normalizeChildError(error));
|
|
200
|
+
throw this.failure;
|
|
201
|
+
}
|
|
202
|
+
this.child = child;
|
|
203
|
+
this._attachChildListeners(child);
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await this._waitForStartup();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const normalized = normalizeChildError(error);
|
|
209
|
+
this._fail(normalized);
|
|
210
|
+
await this._killChild();
|
|
211
|
+
throw normalized;
|
|
212
|
+
}
|
|
213
|
+
if (this.state === "failed") throw this.failure;
|
|
214
|
+
|
|
215
|
+
this.state = "active";
|
|
216
|
+
this.ticker = this.setIntervalImpl(() => this._tick(), this.intervalMs);
|
|
217
|
+
// A frame can arrive while the start command is settling; encode it
|
|
218
|
+
// without waiting for the first interval, then keep a wall-clock cadence.
|
|
219
|
+
this._tick();
|
|
220
|
+
return this.status();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
addFrame(data, receivedAt = this.now()) {
|
|
224
|
+
if (this.state !== "starting" && this.state !== "active") return false;
|
|
225
|
+
if (typeof data !== "string" || !data) return false;
|
|
226
|
+
let frame;
|
|
227
|
+
try {
|
|
228
|
+
frame = Buffer.from(data, "base64");
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
if (!frame.length) return false;
|
|
233
|
+
|
|
234
|
+
this.capturedFrames += 1;
|
|
235
|
+
this.lastFrame = { data: frame, receivedAt };
|
|
236
|
+
this.frameQueue.push(this.lastFrame);
|
|
237
|
+
while (this.frameQueue.length > VIDEO_FRAME_QUEUE_LIMIT) this.frameQueue.shift();
|
|
238
|
+
// The wall-clock ticker is the sole write path once active, so a busy
|
|
239
|
+
// screencast cannot make ffmpeg run faster than the requested FPS.
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async stop() {
|
|
244
|
+
if (this.stopPromise) return this.stopPromise;
|
|
245
|
+
if (this.state === "idle") {
|
|
246
|
+
throw new VideoRecorderError("video_not_active", "No active video recording");
|
|
247
|
+
}
|
|
248
|
+
if (this.state === "failed") throw this.failure;
|
|
249
|
+
|
|
250
|
+
this.stopPromise = this._stopInternal();
|
|
251
|
+
return this.stopPromise;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async dispose() {
|
|
255
|
+
if (this.stopPromise) return this.stopPromise.catch(() => this.status());
|
|
256
|
+
if (this.state === "idle" || this.state === "stopped") return this.status();
|
|
257
|
+
this._clearTicker();
|
|
258
|
+
await this._killChild();
|
|
259
|
+
this.state = "stopped";
|
|
260
|
+
this.stoppedAt = this.now();
|
|
261
|
+
return this.status();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_attachChildListeners(child) {
|
|
265
|
+
child.once?.("error", (error) => {
|
|
266
|
+
if (this.state === "stopping" || this.state === "stopped") return;
|
|
267
|
+
this._fail(normalizeChildError(error));
|
|
268
|
+
});
|
|
269
|
+
child.once?.("exit", (code, signal) => {
|
|
270
|
+
this.closeCode = code;
|
|
271
|
+
this.closeSignal = signal;
|
|
272
|
+
if (this.state === "starting") {
|
|
273
|
+
this._fail(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
|
|
274
|
+
} else if (this.state === "active") {
|
|
275
|
+
this._fail(new VideoRecorderError("ffmpeg_exited", `ffmpeg exited while recording${code === null ? "" : ` (code ${code})`}`));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
child.once?.("close", (code, signal) => {
|
|
279
|
+
this.closeCode = code;
|
|
280
|
+
this.closeSignal = signal;
|
|
281
|
+
if (this.state === "starting") {
|
|
282
|
+
this._fail(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
|
|
283
|
+
} else if (this.state === "active") {
|
|
284
|
+
this._fail(new VideoRecorderError("ffmpeg_exited", `ffmpeg exited while recording${code === null ? "" : ` (code ${code})`}`));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
child.stdin?.once?.("error", (error) => {
|
|
288
|
+
if (this.state === "stopping" || this.state === "stopped") return;
|
|
289
|
+
this._fail(normalizeChildError(error));
|
|
290
|
+
});
|
|
291
|
+
child.stderr?.on?.("data", (chunk) => {
|
|
292
|
+
const text = String(chunk || "").trim();
|
|
293
|
+
if (!text) return;
|
|
294
|
+
this.stderr = `${this.stderr || ""}${text}\n`.slice(-8192);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_waitForStartup() {
|
|
299
|
+
const child = this.child;
|
|
300
|
+
if (!child) return Promise.reject(new VideoRecorderError("ffmpeg_failed", "ffmpeg process was not created"));
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
let settled = false;
|
|
303
|
+
let timer;
|
|
304
|
+
const finish = (error) => {
|
|
305
|
+
if (settled) return;
|
|
306
|
+
settled = true;
|
|
307
|
+
if (timer) clearTimeout(timer);
|
|
308
|
+
error ? reject(error) : resolve();
|
|
309
|
+
};
|
|
310
|
+
const onSpawn = () => {
|
|
311
|
+
timer = setTimeout(() => finish(), this.startupGraceMs);
|
|
312
|
+
};
|
|
313
|
+
child.once?.("spawn", onSpawn);
|
|
314
|
+
child.once?.("error", (error) => finish(normalizeChildError(error)));
|
|
315
|
+
child.once?.("exit", (code) => {
|
|
316
|
+
finish(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
|
|
317
|
+
});
|
|
318
|
+
child.once?.("close", (code) => {
|
|
319
|
+
finish(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
|
|
320
|
+
});
|
|
321
|
+
// Real ChildProcess instances emit spawn, but a small process wrapper may
|
|
322
|
+
// not. Do not leave a recorder hanging forever in that case.
|
|
323
|
+
timer = setTimeout(() => finish(), this.startupGraceMs);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_tick() {
|
|
328
|
+
if (this.state !== "active") return;
|
|
329
|
+
const stdin = this.child?.stdin;
|
|
330
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded || stdin.writableNeedDrain) return;
|
|
331
|
+
|
|
332
|
+
const now = this.now();
|
|
333
|
+
let frame = this.frameQueue.shift() || this.lastFrame;
|
|
334
|
+
if (!frame) return;
|
|
335
|
+
|
|
336
|
+
// Never replay a queued frame after an arbitrarily long event-loop stall.
|
|
337
|
+
// The live ticker continues from the latest frame, but stale backfill is
|
|
338
|
+
// intentionally limited to a few seconds.
|
|
339
|
+
if (now - frame.receivedAt > VIDEO_BACKFILL_LIMIT_MS) {
|
|
340
|
+
frame = this.lastFrame;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
stdin.write(frame.data);
|
|
344
|
+
this.frames += 1;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this._fail(normalizeChildError(error));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async _stopInternal() {
|
|
351
|
+
this._clearTicker();
|
|
352
|
+
// Flush only a pending frame before closing stdin. Replaying the latest
|
|
353
|
+
// already-written frame here would add an unnecessary duplicate at stop.
|
|
354
|
+
if (this.frameQueue.length > 0) this._tick();
|
|
355
|
+
this.state = "stopping";
|
|
356
|
+
this.stoppedAt = this.now();
|
|
357
|
+
const child = this.child;
|
|
358
|
+
if (!child) {
|
|
359
|
+
this.state = "stopped";
|
|
360
|
+
return this.status();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const closeResult = await new Promise((resolve) => {
|
|
364
|
+
let settled = false;
|
|
365
|
+
let timer;
|
|
366
|
+
const finish = (code, signal, timedOut = false) => {
|
|
367
|
+
if (settled) return;
|
|
368
|
+
settled = true;
|
|
369
|
+
if (timer) clearTimeout(timer);
|
|
370
|
+
resolve({ code, signal, timedOut });
|
|
371
|
+
};
|
|
372
|
+
child.once?.("close", finish);
|
|
373
|
+
child.once?.("exit", finish);
|
|
374
|
+
try {
|
|
375
|
+
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end();
|
|
376
|
+
else if (child.stdin?.destroy) child.stdin.destroy();
|
|
377
|
+
} catch (error) {
|
|
378
|
+
this._fail(normalizeChildError(error));
|
|
379
|
+
finish(null, null);
|
|
380
|
+
}
|
|
381
|
+
timer = setTimeout(() => {
|
|
382
|
+
try { child.kill?.("SIGTERM"); } catch {}
|
|
383
|
+
finish(this.closeCode, this.closeSignal, true);
|
|
384
|
+
}, this.stopTimeoutMs);
|
|
385
|
+
});
|
|
386
|
+
if (closeResult.timedOut) {
|
|
387
|
+
this.failure = new VideoRecorderError("ffmpeg_stop_timeout", `ffmpeg did not exit within ${this.stopTimeoutMs}ms while finalizing the recording`);
|
|
388
|
+
this.state = "failed";
|
|
389
|
+
throw this.failure;
|
|
390
|
+
}
|
|
391
|
+
this.closeCode = closeResult.code;
|
|
392
|
+
this.closeSignal = closeResult.signal;
|
|
393
|
+
if (this.failure) {
|
|
394
|
+
this.state = "failed";
|
|
395
|
+
throw this.failure;
|
|
396
|
+
}
|
|
397
|
+
if (closeResult.code !== null && closeResult.code !== undefined && closeResult.code !== 0) {
|
|
398
|
+
const detail = this.stderr ? `: ${this.stderr.trim()}` : "";
|
|
399
|
+
this.failure = new VideoRecorderError("ffmpeg_failed", `ffmpeg failed while finalizing the recording (code ${closeResult.code})${detail}`);
|
|
400
|
+
this.state = "failed";
|
|
401
|
+
throw this.failure;
|
|
402
|
+
}
|
|
403
|
+
this.state = "stopped";
|
|
404
|
+
return this.status();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_clearTicker() {
|
|
408
|
+
if (this.ticker !== null) {
|
|
409
|
+
this.clearIntervalImpl(this.ticker);
|
|
410
|
+
this.ticker = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_fail(error) {
|
|
415
|
+
if (this.failure) return;
|
|
416
|
+
this.failure = normalizeChildError(error);
|
|
417
|
+
this.state = "failed";
|
|
418
|
+
this._clearTicker();
|
|
419
|
+
if (!this._failureNotified) {
|
|
420
|
+
this._failureNotified = true;
|
|
421
|
+
try { this.onFailure(this.failure, this); } catch {}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async _killChild() {
|
|
426
|
+
const child = this.child;
|
|
427
|
+
if (!child || child.killed) return;
|
|
428
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = {
|
|
433
|
+
DEFAULT_STARTUP_GRACE_MS,
|
|
434
|
+
DEFAULT_STOP_TIMEOUT_MS,
|
|
435
|
+
DEFAULT_VIDEO_FPS,
|
|
436
|
+
MAX_VIDEO_FPS,
|
|
437
|
+
MIN_VIDEO_FPS,
|
|
438
|
+
VIDEO_BACKFILL_LIMIT_MS,
|
|
439
|
+
VIDEO_FRAME_QUEUE_LIMIT,
|
|
440
|
+
VideoRecorder,
|
|
441
|
+
VideoRecorderError,
|
|
442
|
+
parseVideoFps,
|
|
443
|
+
validateVideoOutputPath,
|
|
444
|
+
};
|
|
@@ -40,6 +40,7 @@ const COMMANDS = {
|
|
|
40
40
|
"session.ensure": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
|
|
41
41
|
"session.list": { effect: "read", recordable: false },
|
|
42
42
|
"session.info": { primaryArg: "name", effect: "read", recordable: false, argKinds: { name: "name" } },
|
|
43
|
+
"session.cleanup": { effect: "page-write", recordable: false, argKinds: { "idle-after": "duration" } },
|
|
43
44
|
"session.close": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name" } },
|
|
44
45
|
"session.rebind": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name", tabId: "tab-id" } },
|
|
45
46
|
"session.reopen": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
|
|
@@ -64,6 +65,10 @@ const COMMANDS = {
|
|
|
64
65
|
"page.text": { effect: "read" },
|
|
65
66
|
"page.state": { effect: "read" },
|
|
66
67
|
screenshot: { effect: "read" },
|
|
68
|
+
"video.start": { primaryArg: "output", effect: "read", recordable: false },
|
|
69
|
+
"video.stop": { effect: "read", recordable: false },
|
|
70
|
+
"video.status": { effect: "read", recordable: false },
|
|
71
|
+
"video.restart": { primaryArg: "output", effect: "read", recordable: false },
|
|
67
72
|
"window.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
|
|
68
73
|
"window.focus": { primaryArg: "id", effect: "navigation", argKinds: { id: "window-id" } },
|
|
69
74
|
"window.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "window-id" } },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
"@types/node": "^26.1.2",
|
|
71
71
|
"@vitest/coverage-v8": "^4.1.9",
|
|
72
72
|
"@vitest/ui": "^4.1.9",
|
|
73
|
-
"pi-subagents": "^0.
|
|
74
|
-
"puppeteer": "25.
|
|
73
|
+
"pi-subagents": "^0.58.0",
|
|
74
|
+
"puppeteer": "25.9.0",
|
|
75
75
|
"typebox": "^1.3.11",
|
|
76
76
|
"typescript": "^7.0.2",
|
|
77
77
|
"vite": "^8.1.4",
|