surf-cli 2.14.0 → 2.15.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 +62 -33
- package/agents/gpt-pro.md +19 -0
- package/native/browser-scheduler.cjs +348 -0
- package/native/browser-session-store.cjs +271 -0
- package/native/cli.cjs +331 -60
- package/native/do-executor.cjs +5 -0
- package/native/host-helpers.cjs +19 -3
- package/native/host-sessions.cjs +8 -1
- package/native/host.cjs +766 -19
- package/native/playbook-cli.cjs +16 -3
- package/native/surf-error.cjs +47 -0
- package/native/tool-scope.cjs +107 -0
- package/native/workflow-definition.cjs +7 -0
- package/package.json +8 -2
- package/pi-extension/surf.ts +18 -4
- package/skills/surf/SKILL.md +48 -21
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const {
|
|
3
|
+
atomicWriteJson,
|
|
4
|
+
getPrivateStateRoot,
|
|
5
|
+
privateStatePath,
|
|
6
|
+
readPrivateJson,
|
|
7
|
+
} = require("./private-state.cjs");
|
|
8
|
+
const { surfError } = require("./surf-error.cjs");
|
|
9
|
+
|
|
10
|
+
const STORE_VERSION = 1;
|
|
11
|
+
const SESSION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
12
|
+
|
|
13
|
+
function normalizeName(name) {
|
|
14
|
+
return String(name || "").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateSessionName(name) {
|
|
18
|
+
if (typeof name !== "string" || !SESSION_NAME_PATTERN.test(name)) {
|
|
19
|
+
throw surfError(
|
|
20
|
+
"session_name_invalid",
|
|
21
|
+
"session name must be 1-64 characters using letters, numbers, dot, underscore, or hyphen",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emptyState() {
|
|
28
|
+
return { version: STORE_VERSION, browsers: {} };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class BrowserSessionStore {
|
|
32
|
+
constructor({ filePath = privateStatePath("browser-sessions.json"), root = getPrivateStateRoot(), now = () => new Date().toISOString() } = {}) {
|
|
33
|
+
this.filePath = filePath;
|
|
34
|
+
this.root = root;
|
|
35
|
+
this.now = now;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
load() {
|
|
39
|
+
let state;
|
|
40
|
+
try {
|
|
41
|
+
state = readPrivateJson(this.filePath, emptyState(), { root: this.root });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw surfError("state_read_failed", `failed to read browser sessions: ${error.message}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
if (!state || state.version !== STORE_VERSION || !state.browsers || typeof state.browsers !== "object") {
|
|
46
|
+
throw surfError("state_read_failed", `browser session state is invalid: ${this.filePath}`);
|
|
47
|
+
}
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
save(state) {
|
|
52
|
+
try {
|
|
53
|
+
atomicWriteJson(this.filePath, state, { root: this.root });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw surfError("state_write_failed", `failed to save browser sessions: ${error.message}`, {
|
|
56
|
+
cause: error,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
bucket(state, browserInstanceId, create = false) {
|
|
62
|
+
if (!browserInstanceId) throw surfError("extension_identity_missing", "browser instance identity is unavailable");
|
|
63
|
+
let bucket = state.browsers[browserInstanceId];
|
|
64
|
+
if (!bucket && create) {
|
|
65
|
+
bucket = { sessions: {}, namedTabs: {} };
|
|
66
|
+
state.browsers[browserInstanceId] = bucket;
|
|
67
|
+
}
|
|
68
|
+
return bucket || { sessions: {}, namedTabs: {} };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list(identity) {
|
|
72
|
+
const state = this.load();
|
|
73
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
74
|
+
return Object.values(bucket.sessions || {})
|
|
75
|
+
.map((entry) => ({ ...entry }))
|
|
76
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get(identity, name) {
|
|
80
|
+
validateSessionName(name);
|
|
81
|
+
const state = this.load();
|
|
82
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
83
|
+
const record = bucket.sessions?.[normalizeName(name)];
|
|
84
|
+
return record ? { ...record } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
findByTab(identity, tabId, exceptName) {
|
|
88
|
+
const exceptKey = exceptName ? normalizeName(exceptName) : null;
|
|
89
|
+
return this.list(identity).find((record) => (
|
|
90
|
+
record.tabId === tabId &&
|
|
91
|
+
record.browserEpoch === identity.browserEpoch &&
|
|
92
|
+
normalizeName(record.name) !== exceptKey &&
|
|
93
|
+
!record.invalidReason
|
|
94
|
+
)) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
create(identity, name, values) {
|
|
98
|
+
validateSessionName(name);
|
|
99
|
+
const key = normalizeName(name);
|
|
100
|
+
const state = this.load();
|
|
101
|
+
const bucket = this.bucket(state, identity.browserInstanceId, true);
|
|
102
|
+
if (bucket.sessions[key]) {
|
|
103
|
+
throw surfError("session_exists", `session already exists: ${name}`, {
|
|
104
|
+
session: name,
|
|
105
|
+
recoveryCommand: `surf session.ensure ${name}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const timestamp = this.now();
|
|
109
|
+
const record = {
|
|
110
|
+
...values,
|
|
111
|
+
bindingId: crypto.randomUUID(),
|
|
112
|
+
name,
|
|
113
|
+
browserInstanceId: identity.browserInstanceId,
|
|
114
|
+
browserEpoch: identity.browserEpoch,
|
|
115
|
+
createdAt: timestamp,
|
|
116
|
+
updatedAt: timestamp,
|
|
117
|
+
lastValidatedAt: values.lastValidatedAt || timestamp,
|
|
118
|
+
};
|
|
119
|
+
bucket.sessions[key] = record;
|
|
120
|
+
this.save(state);
|
|
121
|
+
return { ...record };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
replace(identity, name, values) {
|
|
125
|
+
validateSessionName(name);
|
|
126
|
+
const key = normalizeName(name);
|
|
127
|
+
const state = this.load();
|
|
128
|
+
const bucket = this.bucket(state, identity.browserInstanceId, true);
|
|
129
|
+
const existing = bucket.sessions[key];
|
|
130
|
+
const timestamp = this.now();
|
|
131
|
+
const record = {
|
|
132
|
+
...existing,
|
|
133
|
+
...values,
|
|
134
|
+
bindingId: existing?.bindingId || crypto.randomUUID(),
|
|
135
|
+
name: existing?.name || name,
|
|
136
|
+
browserInstanceId: identity.browserInstanceId,
|
|
137
|
+
browserEpoch: identity.browserEpoch,
|
|
138
|
+
createdAt: existing?.createdAt || timestamp,
|
|
139
|
+
updatedAt: timestamp,
|
|
140
|
+
lastValidatedAt: values.lastValidatedAt || timestamp,
|
|
141
|
+
};
|
|
142
|
+
delete record.invalidReason;
|
|
143
|
+
delete record.invalidatedAt;
|
|
144
|
+
bucket.sessions[key] = record;
|
|
145
|
+
this.save(state);
|
|
146
|
+
return { ...record };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
update(identity, name, patch) {
|
|
150
|
+
const existing = this.get(identity, name);
|
|
151
|
+
if (!existing) throw surfError("session_unknown", `unknown session: ${name}`, { session: name });
|
|
152
|
+
return this.replace(identity, name, { ...existing, ...patch, updatedAt: this.now() });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
remove(identity, name) {
|
|
156
|
+
validateSessionName(name);
|
|
157
|
+
const key = normalizeName(name);
|
|
158
|
+
const state = this.load();
|
|
159
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
160
|
+
const existing = bucket.sessions?.[key];
|
|
161
|
+
if (!existing) return null;
|
|
162
|
+
delete bucket.sessions[key];
|
|
163
|
+
this.save(state);
|
|
164
|
+
return { ...existing };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
invalidateByTab(identity, tabId, reason = "tab_gone") {
|
|
168
|
+
const state = this.load();
|
|
169
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
170
|
+
let changed = false;
|
|
171
|
+
for (const record of Object.values(bucket.sessions || {})) {
|
|
172
|
+
if (record.tabId !== tabId) continue;
|
|
173
|
+
record.invalidReason = reason;
|
|
174
|
+
record.invalidatedAt = this.now();
|
|
175
|
+
record.updatedAt = record.invalidatedAt;
|
|
176
|
+
changed = true;
|
|
177
|
+
}
|
|
178
|
+
for (const [key, entry] of Object.entries(bucket.namedTabs || {})) {
|
|
179
|
+
if (entry.tabId === tabId) {
|
|
180
|
+
delete bucket.namedTabs[key];
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (changed) this.save(state);
|
|
185
|
+
return changed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
invalidateByWindow(identity, windowId, reason = "window_gone") {
|
|
189
|
+
const state = this.load();
|
|
190
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
191
|
+
let changed = false;
|
|
192
|
+
for (const record of Object.values(bucket.sessions || {})) {
|
|
193
|
+
if (record.windowId !== windowId) continue;
|
|
194
|
+
record.invalidReason = reason;
|
|
195
|
+
record.invalidatedAt = this.now();
|
|
196
|
+
record.updatedAt = record.invalidatedAt;
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
for (const [key, entry] of Object.entries(bucket.namedTabs || {})) {
|
|
200
|
+
if (entry.windowId === windowId) {
|
|
201
|
+
delete bucket.namedTabs[key];
|
|
202
|
+
changed = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (changed) this.save(state);
|
|
206
|
+
return changed;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
updateTabMetadata(identity, tabId, patch) {
|
|
210
|
+
const state = this.load();
|
|
211
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
212
|
+
let changed = false;
|
|
213
|
+
for (const record of Object.values(bucket.sessions || {})) {
|
|
214
|
+
if (record.tabId !== tabId) continue;
|
|
215
|
+
Object.assign(record, patch, { updatedAt: this.now() });
|
|
216
|
+
changed = true;
|
|
217
|
+
}
|
|
218
|
+
if (changed) this.save(state);
|
|
219
|
+
return changed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setNamedTab(identity, name, values) {
|
|
223
|
+
validateSessionName(name);
|
|
224
|
+
const state = this.load();
|
|
225
|
+
const bucket = this.bucket(state, identity.browserInstanceId, true);
|
|
226
|
+
bucket.namedTabs[normalizeName(name)] = {
|
|
227
|
+
name,
|
|
228
|
+
browserEpoch: identity.browserEpoch,
|
|
229
|
+
updatedAt: this.now(),
|
|
230
|
+
...values,
|
|
231
|
+
};
|
|
232
|
+
this.save(state);
|
|
233
|
+
return { ...bucket.namedTabs[normalizeName(name)] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getNamedTab(identity, name) {
|
|
237
|
+
validateSessionName(name);
|
|
238
|
+
const state = this.load();
|
|
239
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
240
|
+
const entry = bucket.namedTabs?.[normalizeName(name)];
|
|
241
|
+
return entry ? { ...entry } : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
listNamedTabs(identity) {
|
|
245
|
+
const state = this.load();
|
|
246
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
247
|
+
return Object.values(bucket.namedTabs || {})
|
|
248
|
+
.map((entry) => ({ ...entry }))
|
|
249
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
removeNamedTab(identity, name) {
|
|
253
|
+
validateSessionName(name);
|
|
254
|
+
const state = this.load();
|
|
255
|
+
const bucket = this.bucket(state, identity.browserInstanceId, false);
|
|
256
|
+
const key = normalizeName(name);
|
|
257
|
+
const entry = bucket.namedTabs?.[key];
|
|
258
|
+
if (!entry) return null;
|
|
259
|
+
delete bucket.namedTabs[key];
|
|
260
|
+
this.save(state);
|
|
261
|
+
return { ...entry };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
BrowserSessionStore,
|
|
267
|
+
SESSION_NAME_PATTERN,
|
|
268
|
+
STORE_VERSION,
|
|
269
|
+
normalizeName,
|
|
270
|
+
validateSessionName,
|
|
271
|
+
};
|