surf-cli 2.13.1 → 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 +65 -34
- package/agents/gpt-pro.md +19 -0
- package/dist/content/index.js +116 -0
- package/dist/content/index.js.map +1 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/manifest.json +2 -11
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +261 -61
- package/dist/service-worker/index.js.map +1 -1
- package/native/browser-scheduler.cjs +348 -0
- package/native/browser-session-store.cjs +271 -0
- package/native/chatgpt-client-selection.cjs +20 -10
- package/native/chatgpt-client-ui.cjs +35 -7
- package/native/cli.cjs +333 -62
- 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/mcp-server.cjs +1 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/oracle-jobs.cjs +25 -3
- 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 +275 -2
- package/skills/surf/SKILL.md +52 -23
- package/dist/content/accessibility-tree.js +0 -11
- package/dist/content/accessibility-tree.js.map +0 -1
- package/dist/content/visual-indicator.js +0 -111
- package/dist/content/visual-indicator.js.map +0 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const { surfError } = require("./surf-error.cjs");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_QUEUED = 64;
|
|
4
|
+
const DEFAULT_MAX_PER_LANE = 16;
|
|
5
|
+
const DEFAULT_QUEUE_TIMEOUT_MS = 60000;
|
|
6
|
+
const WRITE_SCOPES = new Set(["browser-write", "provider"]);
|
|
7
|
+
|
|
8
|
+
function normalizeResourceKeys(resourceKeys) {
|
|
9
|
+
if (!Array.isArray(resourceKeys)) return [];
|
|
10
|
+
return [...new Set(resourceKeys.filter((key) => typeof key === "string" && key).map(String))].sort();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class BrowserScheduler {
|
|
14
|
+
constructor({
|
|
15
|
+
maxQueued = DEFAULT_MAX_QUEUED,
|
|
16
|
+
maxPerLane = DEFAULT_MAX_PER_LANE,
|
|
17
|
+
queueTimeoutMs = DEFAULT_QUEUE_TIMEOUT_MS,
|
|
18
|
+
audit = () => {},
|
|
19
|
+
} = {}) {
|
|
20
|
+
this.maxQueued = maxQueued;
|
|
21
|
+
this.maxPerLane = maxPerLane;
|
|
22
|
+
this.queueTimeoutMs = queueTimeoutMs;
|
|
23
|
+
this.audit = audit;
|
|
24
|
+
this.queue = [];
|
|
25
|
+
this.activeTabs = new Map();
|
|
26
|
+
this.activeReaders = new Set();
|
|
27
|
+
this.activeWriter = null;
|
|
28
|
+
this.activeResources = new Map();
|
|
29
|
+
this.sequence = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
acquire({ scope, laneKey, resourceKeys = [], wait = true, signal, request, session } = {}) {
|
|
33
|
+
const normalizedResources = normalizeResourceKeys(resourceKeys);
|
|
34
|
+
if (scope === "host" && normalizedResources.length === 0) {
|
|
35
|
+
return Promise.resolve(this.#token({
|
|
36
|
+
scope,
|
|
37
|
+
laneKey,
|
|
38
|
+
resourceKeys: normalizedResources,
|
|
39
|
+
request,
|
|
40
|
+
session,
|
|
41
|
+
queuedAt: Date.now(),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
if (scope === "tab" && !laneKey) {
|
|
45
|
+
return Promise.reject(surfError("target_required", "tab-scoped command requires a resolved tab"));
|
|
46
|
+
}
|
|
47
|
+
if (!scope || !["host", "tab", "browser-read", "browser-write", "provider"].includes(scope)) {
|
|
48
|
+
return Promise.reject(surfError("scheduler_scope_invalid", `invalid browser scheduler scope: ${scope}`));
|
|
49
|
+
}
|
|
50
|
+
if (signal?.aborted) return Promise.reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
|
|
51
|
+
|
|
52
|
+
const entry = {
|
|
53
|
+
id: ++this.sequence,
|
|
54
|
+
scope,
|
|
55
|
+
laneKey,
|
|
56
|
+
resourceKeys: normalizedResources,
|
|
57
|
+
wait,
|
|
58
|
+
signal,
|
|
59
|
+
request,
|
|
60
|
+
session,
|
|
61
|
+
queuedAt: Date.now(),
|
|
62
|
+
resolve: null,
|
|
63
|
+
reject: null,
|
|
64
|
+
timer: null,
|
|
65
|
+
abortCleanup: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (this.#canGrantImmediately(entry)) {
|
|
69
|
+
return Promise.resolve(this.#grant(entry));
|
|
70
|
+
}
|
|
71
|
+
if (!wait) {
|
|
72
|
+
const blockedResources = this.#blockedResourceKeys(entry);
|
|
73
|
+
if (blockedResources.length > 0) {
|
|
74
|
+
return Promise.reject(surfError("resource_busy", `shared resource is busy: ${blockedResources.join(", ")}`, {
|
|
75
|
+
laneKey,
|
|
76
|
+
session,
|
|
77
|
+
resourceKeys: blockedResources,
|
|
78
|
+
retryable: true,
|
|
79
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
80
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
const browserBlocked = Boolean(this.activeWriter) || this.#hasQueuedWriter() || WRITE_SCOPES.has(scope);
|
|
84
|
+
const code = browserBlocked ? "browser_busy" : "tab_busy";
|
|
85
|
+
const message = browserBlocked
|
|
86
|
+
? "a browser-wide writer is active or waiting"
|
|
87
|
+
: `tab lane is busy: ${laneKey}`;
|
|
88
|
+
return Promise.reject(surfError(code, message, {
|
|
89
|
+
laneKey,
|
|
90
|
+
session,
|
|
91
|
+
retryable: true,
|
|
92
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
93
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
if (this.queue.length >= this.maxQueued) {
|
|
97
|
+
return Promise.reject(surfError("queue_full", "browser scheduler queue is full", { retryable: true }));
|
|
98
|
+
}
|
|
99
|
+
if (scope === "tab") {
|
|
100
|
+
const laneDepth = this.queue.filter((queued) => queued.scope === "tab" && queued.laneKey === laneKey).length;
|
|
101
|
+
if (laneDepth >= this.maxPerLane) {
|
|
102
|
+
return Promise.reject(surfError("queue_full", `tab lane queue is full: ${laneKey}`, {
|
|
103
|
+
laneKey,
|
|
104
|
+
retryable: true,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
entry.resolve = resolve;
|
|
111
|
+
entry.reject = reject;
|
|
112
|
+
entry.timer = setTimeout(() => {
|
|
113
|
+
this.#removeQueued(entry);
|
|
114
|
+
reject(surfError("queue_timeout", "timed out waiting for browser admission", {
|
|
115
|
+
laneKey,
|
|
116
|
+
session,
|
|
117
|
+
resourceKeys: normalizedResources,
|
|
118
|
+
retryable: true,
|
|
119
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
120
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
121
|
+
}));
|
|
122
|
+
this.audit({
|
|
123
|
+
event: "scheduler",
|
|
124
|
+
outcome: "queue-timeout",
|
|
125
|
+
request,
|
|
126
|
+
scope,
|
|
127
|
+
laneKey,
|
|
128
|
+
resourceKeys: normalizedResources,
|
|
129
|
+
});
|
|
130
|
+
this.#drain();
|
|
131
|
+
}, this.queueTimeoutMs);
|
|
132
|
+
if (signal) {
|
|
133
|
+
const onAbort = () => {
|
|
134
|
+
if (!this.#removeQueued(entry)) return;
|
|
135
|
+
reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
|
|
136
|
+
this.audit({
|
|
137
|
+
event: "scheduler",
|
|
138
|
+
outcome: "queue-cancel",
|
|
139
|
+
request,
|
|
140
|
+
scope,
|
|
141
|
+
laneKey,
|
|
142
|
+
resourceKeys: normalizedResources,
|
|
143
|
+
});
|
|
144
|
+
this.#drain();
|
|
145
|
+
};
|
|
146
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
147
|
+
entry.abortCleanup = () => signal.removeEventListener("abort", onAbort);
|
|
148
|
+
}
|
|
149
|
+
this.queue.push(entry);
|
|
150
|
+
this.audit({
|
|
151
|
+
event: "scheduler",
|
|
152
|
+
outcome: "queued",
|
|
153
|
+
request,
|
|
154
|
+
scope,
|
|
155
|
+
laneKey,
|
|
156
|
+
resourceKeys: normalizedResources,
|
|
157
|
+
queueDepth: this.queue.length,
|
|
158
|
+
});
|
|
159
|
+
this.#drain();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
stats({ laneKey, resourceKeys = [] } = {}) {
|
|
164
|
+
const queuedByLane = {};
|
|
165
|
+
for (const entry of this.queue) {
|
|
166
|
+
if (entry.scope !== "tab") continue;
|
|
167
|
+
queuedByLane[entry.laneKey] = (queuedByLane[entry.laneKey] || 0) + 1;
|
|
168
|
+
}
|
|
169
|
+
const writer = this.activeWriter
|
|
170
|
+
? {
|
|
171
|
+
scope: this.activeWriter.scope,
|
|
172
|
+
session: this.activeWriter.session || null,
|
|
173
|
+
acquiredAt: this.activeWriter.acquiredAt,
|
|
174
|
+
}
|
|
175
|
+
: null;
|
|
176
|
+
const queuedWriters = this.queue.filter((entry) => WRITE_SCOPES.has(entry.scope));
|
|
177
|
+
const normalizedResources = normalizeResourceKeys(resourceKeys);
|
|
178
|
+
const activeResources = [...this.activeResources.entries()].map(([key, token]) => ({
|
|
179
|
+
key,
|
|
180
|
+
scope: token.scope,
|
|
181
|
+
laneKey: token.laneKey || null,
|
|
182
|
+
session: token.session || null,
|
|
183
|
+
acquiredAt: token.acquiredAt,
|
|
184
|
+
}));
|
|
185
|
+
return {
|
|
186
|
+
activeTabLanes: [...this.activeTabs.entries()].map(([key, token]) => ({
|
|
187
|
+
laneKey: key,
|
|
188
|
+
session: token.session || null,
|
|
189
|
+
acquiredAt: token.acquiredAt,
|
|
190
|
+
})),
|
|
191
|
+
activeReaders: this.activeReaders.size,
|
|
192
|
+
writerActive: Boolean(writer),
|
|
193
|
+
writer,
|
|
194
|
+
activeResources,
|
|
195
|
+
blockedResourceKeys: normalizedResources.filter((key) => this.activeResources.has(key)),
|
|
196
|
+
queued: this.queue.length,
|
|
197
|
+
queuedWriters: queuedWriters.length,
|
|
198
|
+
queuedWriterSessions: queuedWriters.map((entry) => entry.session || null),
|
|
199
|
+
queuedByLane,
|
|
200
|
+
lane: laneKey ? {
|
|
201
|
+
laneKey,
|
|
202
|
+
active: this.activeTabs.has(laneKey),
|
|
203
|
+
queued: queuedByLane[laneKey] || 0,
|
|
204
|
+
blockedBy: this.activeWriter || queuedWriters.length > 0
|
|
205
|
+
? "browser-writer"
|
|
206
|
+
: this.activeTabs.has(laneKey)
|
|
207
|
+
? "own-tab"
|
|
208
|
+
: null,
|
|
209
|
+
} : undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#blockedResourceKeys(entry) {
|
|
214
|
+
return entry.resourceKeys.filter((key) => this.activeResources.has(key));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#hasQueuedWriter() {
|
|
218
|
+
return this.queue.some((entry) => WRITE_SCOPES.has(entry.scope));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#canGrantImmediately(entry) {
|
|
222
|
+
if (this.queue.length > 0) return false;
|
|
223
|
+
if (this.#blockedResourceKeys(entry).length > 0) return false;
|
|
224
|
+
if (entry.scope === "host") return true;
|
|
225
|
+
if (WRITE_SCOPES.has(entry.scope)) {
|
|
226
|
+
return !this.activeWriter && this.activeReaders.size === 0 && this.activeTabs.size === 0;
|
|
227
|
+
}
|
|
228
|
+
if (this.activeWriter || this.#hasQueuedWriter()) return false;
|
|
229
|
+
if (entry.scope === "browser-read") return true;
|
|
230
|
+
return !this.activeTabs.has(entry.laneKey);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#grant(entry) {
|
|
234
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
235
|
+
entry.abortCleanup?.();
|
|
236
|
+
const token = this.#token(entry);
|
|
237
|
+
if (WRITE_SCOPES.has(entry.scope)) this.activeWriter = token;
|
|
238
|
+
else if (entry.scope === "browser-read") this.activeReaders.add(token);
|
|
239
|
+
else if (entry.scope === "tab") this.activeTabs.set(entry.laneKey, token);
|
|
240
|
+
for (const key of entry.resourceKeys) this.activeResources.set(key, token);
|
|
241
|
+
this.audit({
|
|
242
|
+
event: "scheduler",
|
|
243
|
+
outcome: "acquired",
|
|
244
|
+
request: entry.request,
|
|
245
|
+
scope: entry.scope,
|
|
246
|
+
laneKey: entry.laneKey,
|
|
247
|
+
resourceKeys: entry.resourceKeys,
|
|
248
|
+
queueMs: Date.now() - entry.queuedAt,
|
|
249
|
+
});
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#token(entry) {
|
|
254
|
+
let released = false;
|
|
255
|
+
const token = {
|
|
256
|
+
scope: entry.scope,
|
|
257
|
+
laneKey: entry.laneKey,
|
|
258
|
+
resourceKeys: entry.resourceKeys,
|
|
259
|
+
session: entry.session,
|
|
260
|
+
queuedAt: entry.queuedAt,
|
|
261
|
+
acquiredAt: Date.now(),
|
|
262
|
+
release: () => {
|
|
263
|
+
if (released) return;
|
|
264
|
+
released = true;
|
|
265
|
+
if (WRITE_SCOPES.has(entry.scope)) {
|
|
266
|
+
if (this.activeWriter === token) this.activeWriter = null;
|
|
267
|
+
} else if (entry.scope === "browser-read") this.activeReaders.delete(token);
|
|
268
|
+
else if (entry.scope === "tab" && this.activeTabs.get(entry.laneKey) === token) {
|
|
269
|
+
this.activeTabs.delete(entry.laneKey);
|
|
270
|
+
}
|
|
271
|
+
for (const key of entry.resourceKeys) {
|
|
272
|
+
if (this.activeResources.get(key) === token) this.activeResources.delete(key);
|
|
273
|
+
}
|
|
274
|
+
this.audit({
|
|
275
|
+
event: "scheduler",
|
|
276
|
+
outcome: "released",
|
|
277
|
+
request: entry.request,
|
|
278
|
+
scope: entry.scope,
|
|
279
|
+
laneKey: entry.laneKey,
|
|
280
|
+
resourceKeys: entry.resourceKeys,
|
|
281
|
+
session: entry.session,
|
|
282
|
+
});
|
|
283
|
+
this.#drain();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
return token;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#removeQueued(entry) {
|
|
290
|
+
const index = this.queue.indexOf(entry);
|
|
291
|
+
if (index === -1) return false;
|
|
292
|
+
this.queue.splice(index, 1);
|
|
293
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
294
|
+
entry.abortCleanup?.();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#drain() {
|
|
299
|
+
for (let index = 0; index < this.queue.length;) {
|
|
300
|
+
const entry = this.queue[index];
|
|
301
|
+
if (entry.scope !== "host" || this.#blockedResourceKeys(entry).length > 0) {
|
|
302
|
+
index += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
this.queue.splice(index, 1);
|
|
306
|
+
entry.resolve(this.#grant(entry));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this.activeWriter) return;
|
|
310
|
+
const writerIndex = this.queue.findIndex((entry) => WRITE_SCOPES.has(entry.scope));
|
|
311
|
+
if (writerIndex !== -1) {
|
|
312
|
+
const writer = this.queue[writerIndex];
|
|
313
|
+
if (
|
|
314
|
+
this.activeReaders.size > 0 ||
|
|
315
|
+
this.activeTabs.size > 0 ||
|
|
316
|
+
this.#blockedResourceKeys(writer).length > 0
|
|
317
|
+
) return;
|
|
318
|
+
this.queue.splice(writerIndex, 1);
|
|
319
|
+
writer.resolve(this.#grant(writer));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (let index = 0; index < this.queue.length;) {
|
|
324
|
+
const entry = this.queue[index];
|
|
325
|
+
if (this.#blockedResourceKeys(entry).length > 0) {
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
let grant = false;
|
|
330
|
+
if (entry.scope === "browser-read") grant = true;
|
|
331
|
+
else if (entry.scope === "tab") grant = !this.activeTabs.has(entry.laneKey);
|
|
332
|
+
if (!grant) {
|
|
333
|
+
index += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
this.queue.splice(index, 1);
|
|
337
|
+
entry.resolve(this.#grant(entry));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
BrowserScheduler,
|
|
344
|
+
DEFAULT_MAX_PER_LANE,
|
|
345
|
+
DEFAULT_MAX_QUEUED,
|
|
346
|
+
DEFAULT_QUEUE_TIMEOUT_MS,
|
|
347
|
+
normalizeResourceKeys,
|
|
348
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy"];
|
|
1
|
+
const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy", "pro"];
|
|
2
|
+
const CHATGPT_MODEL_ALIASES = new Map([
|
|
3
|
+
["instant", "instant"],
|
|
4
|
+
["gpt53", "instant"],
|
|
5
|
+
["thinking", "thinking"],
|
|
6
|
+
["gpt54thinking", "thinking"],
|
|
7
|
+
["pro", "gpt56sol"],
|
|
8
|
+
["gpt54pro", "pro"],
|
|
9
|
+
["55", "gpt55"],
|
|
10
|
+
["gpt55", "gpt55"],
|
|
11
|
+
["chatgpt55", "gpt55"],
|
|
12
|
+
["56sol", "gpt56sol"],
|
|
13
|
+
["gpt56sol", "gpt56sol"],
|
|
14
|
+
["chatgpt56sol", "gpt56sol"],
|
|
15
|
+
]);
|
|
2
16
|
|
|
3
17
|
function normalizeChatGPTModelChoice(desiredModel) {
|
|
4
18
|
const normalized = String(desiredModel || "")
|
|
5
19
|
.toLowerCase()
|
|
6
20
|
.replace(/[^a-z0-9]/g, "");
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
|
|
10
|
-
if (["pro", "gpt54pro"].includes(normalized)) return "pro";
|
|
11
|
-
|
|
12
|
-
return normalized;
|
|
22
|
+
return CHATGPT_MODEL_ALIASES.get(normalized) || normalized;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
function normalizeChatGPTEffortChoice(desiredEffort) {
|
|
@@ -29,7 +39,9 @@ function normalizedWords(value) {
|
|
|
29
39
|
function modelCandidateMatches(item, targetModel) {
|
|
30
40
|
const values = [item?.label, item?.testId?.replace(/^model-switcher-/, "")].filter(Boolean);
|
|
31
41
|
return values.some((value) => {
|
|
32
|
-
|
|
42
|
+
const normalizedValue = normalizeChatGPTModelChoice(value);
|
|
43
|
+
if (normalizedValue === targetModel) return true;
|
|
44
|
+
if (targetModel.startsWith("gpt") && normalizedValue.includes(targetModel)) return true;
|
|
33
45
|
const variants = ["instant", "thinking", "pro"].filter((variant) =>
|
|
34
46
|
normalizedWords(value).includes(variant),
|
|
35
47
|
);
|
|
@@ -58,9 +70,7 @@ function resolveChatGPTModelMenuOption(items, desiredModel) {
|
|
|
58
70
|
return uniqueMatch(
|
|
59
71
|
items,
|
|
60
72
|
(item) =>
|
|
61
|
-
|
|
62
|
-
typeof item?.testId === "string" &&
|
|
63
|
-
item.testId.startsWith("model-switcher-") &&
|
|
73
|
+
["button", "menuitem", "menuitemradio", "radio"].includes(item?.role) &&
|
|
64
74
|
modelCandidateMatches(item, targetModel),
|
|
65
75
|
);
|
|
66
76
|
}
|