surf-cli 2.13.1 → 2.14.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 +5 -3
- 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/chatgpt-client-selection.cjs +20 -10
- package/native/chatgpt-client-ui.cjs +35 -7
- package/native/cli.cjs +2 -2
- package/native/mcp-server.cjs +1 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/oracle-jobs.cjs +25 -3
- package/package.json +1 -1
- package/pi-extension/surf.ts +261 -2
- package/skills/surf/SKILL.md +5 -3
- 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
|
@@ -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
|
}
|
|
@@ -18,9 +18,10 @@ const SELECTORS = {
|
|
|
18
18
|
loginCta: 'a[href*="/auth/login"], button',
|
|
19
19
|
sendButton:
|
|
20
20
|
'button[data-testid="send-button"], button[data-testid*="composer-send"], form button[type="submit"]',
|
|
21
|
-
modelButton:
|
|
21
|
+
modelButton:
|
|
22
|
+
'[data-testid="model-switcher-dropdown-button"], [data-testid="composer-footer-actions"] button[aria-haspopup="menu"], button.__composer-pill[aria-haspopup="menu"], .__composer-pill-composite button[aria-haspopup="menu"]',
|
|
22
23
|
modelMenu: '[role="menu"][data-radix-menu-content]',
|
|
23
|
-
modelMenuItem: '[role="
|
|
24
|
+
modelMenuItem: 'button, [role="menuitem"], [role="menuitemradio"]',
|
|
24
25
|
menuItemPrimaryLabel: '.min-w-0 > span',
|
|
25
26
|
effortButton:
|
|
26
27
|
'[data-testid="composer-footer-actions"] button[aria-haspopup="menu"], button.__composer-pill[aria-haspopup="menu"], .__composer-pill-composite button[aria-haspopup="menu"]',
|
|
@@ -191,8 +192,8 @@ async function readPicker(cdp, kind, click = false) {
|
|
|
191
192
|
${buildClickDispatcher()}
|
|
192
193
|
const kind = ${JSON.stringify(kind)};
|
|
193
194
|
const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
|
|
194
|
-
if (kind === 'model') return true;
|
|
195
195
|
const value = ((node.getAttribute?.('aria-label') || '') + ' ' + (node.textContent || '')).toLowerCase();
|
|
196
|
+
if (kind === 'model') return value.includes('gpt') || value.includes('pro') || value.includes('thinking') || value.includes('instant');
|
|
196
197
|
return value.includes('thinking') || value.includes('pro');
|
|
197
198
|
});
|
|
198
199
|
const items = nodes.map((node) => {
|
|
@@ -224,13 +225,31 @@ async function readMenu(cdp, kind, allowSubmenu) {
|
|
|
224
225
|
const choices = ${JSON.stringify(CHATGPT_EFFORT_CHOICES)};
|
|
225
226
|
const containers = Array.from(document.querySelectorAll(${JSON.stringify(menuSelector)}));
|
|
226
227
|
const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
227
|
-
let menu = isModel ? containers
|
|
228
|
+
let menu = isModel ? containers.find((container) => {
|
|
229
|
+
const labels = Array.from(container.querySelectorAll(${JSON.stringify(itemSelector)}))
|
|
230
|
+
.filter((item) => item.getAttribute?.('aria-haspopup') !== 'menu')
|
|
231
|
+
.map((item) => normalize((item.getAttribute?.('aria-label') || '') + ' ' + (item.textContent || '')));
|
|
232
|
+
return labels.some((label) => label.includes('gpt') || /^o[0-9]/.test(label));
|
|
233
|
+
}) : containers.find((container) => {
|
|
228
234
|
const label = normalize(container.querySelector?.(${JSON.stringify(SELECTORS.effortMenuLabel)})?.textContent);
|
|
229
235
|
const levels = new Set(Array.from(container.querySelectorAll(${JSON.stringify(itemSelector)}))
|
|
230
236
|
.flatMap((item) => normalize(item.textContent).split(/\\s+/))
|
|
231
237
|
.filter((word) => choices.includes(word)));
|
|
232
238
|
return label.includes('thinking time') || levels.size >= 2;
|
|
233
239
|
});
|
|
240
|
+
if (!menu && isModel && ${allowSubmenu}) {
|
|
241
|
+
for (const container of containers) {
|
|
242
|
+
const trigger = Array.from(container.querySelectorAll(${JSON.stringify(SELECTORS.effortSubmenuTrigger)}))
|
|
243
|
+
.find((item) => {
|
|
244
|
+
const label = normalize((item.getAttribute?.('aria-label') || '') + ' ' + (item.textContent || ''));
|
|
245
|
+
return label.includes('model') || label.includes('advanced');
|
|
246
|
+
});
|
|
247
|
+
if (trigger) {
|
|
248
|
+
dispatchClickSequence(trigger);
|
|
249
|
+
return { found: false, submenuOpened: true, items: [] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
234
253
|
if (!menu && !isModel && ${allowSubmenu}) {
|
|
235
254
|
for (const container of containers) {
|
|
236
255
|
const trigger = Array.from(container.querySelectorAll(${JSON.stringify(SELECTORS.effortSubmenuTrigger)}))
|
|
@@ -299,7 +318,7 @@ async function clickMenuItem(cdp, kind, match) {
|
|
|
299
318
|
.replace(/\\s+/g, ' ').trim().slice(0, 80);
|
|
300
319
|
return label === expectedLabel;
|
|
301
320
|
});
|
|
302
|
-
return matches.length
|
|
321
|
+
return matches.length >= 1 ? dispatchClickSequence(matches[0]) : false;
|
|
303
322
|
})()`,
|
|
304
323
|
);
|
|
305
324
|
}
|
|
@@ -317,8 +336,17 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000, signal) {
|
|
|
317
336
|
await delay(200, signal);
|
|
318
337
|
const state = await readPicker(cdp, "model");
|
|
319
338
|
const verified = verifyChatGPTModelSelection(state?.items, desiredModel);
|
|
320
|
-
if (
|
|
321
|
-
|
|
339
|
+
if (verified) return verified.displayLabel || verified.label;
|
|
340
|
+
|
|
341
|
+
const reopened = await readPicker(cdp, "model", true);
|
|
342
|
+
if (reopened?.items?.length !== 1) throw verificationError("model", desiredModel, menu.items);
|
|
343
|
+
const readbackMenu = await waitForMenu(cdp, "model", timeoutMs, signal);
|
|
344
|
+
const readbackVerified = verifyChatGPTModelSelection(
|
|
345
|
+
readbackMenu.items.filter((item) => item.selected),
|
|
346
|
+
desiredModel,
|
|
347
|
+
);
|
|
348
|
+
if (!readbackVerified) throw verificationError("model", desiredModel, readbackMenu.items);
|
|
349
|
+
return readbackVerified.label;
|
|
322
350
|
}
|
|
323
351
|
|
|
324
352
|
async function selectEffort(cdp, desiredEffort, timeoutMs = 8000, signal) {
|
package/native/cli.cjs
CHANGED
|
@@ -270,7 +270,7 @@ const TOOLS = {
|
|
|
270
270
|
args: ["query"],
|
|
271
271
|
opts: {
|
|
272
272
|
"with-page": "Include current page context",
|
|
273
|
-
model: "Model: gpt-
|
|
273
|
+
model: "Model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label",
|
|
274
274
|
file: "Attach file",
|
|
275
275
|
timeout: "Timeout in seconds (default: 2700 = 45min)"
|
|
276
276
|
},
|
|
@@ -278,7 +278,7 @@ const TOOLS = {
|
|
|
278
278
|
{ cmd: 'chatgpt "explain this code"', desc: "Basic query" },
|
|
279
279
|
{ cmd: 'chatgpt "summarize" --with-page', desc: "With page context" },
|
|
280
280
|
{ cmd: 'chatgpt "review" --file code.ts', desc: "With file" },
|
|
281
|
-
{ cmd: 'chatgpt "analyze" --model gpt-
|
|
281
|
+
{ cmd: 'chatgpt "analyze" --model gpt-5.5', desc: "Specify model" },
|
|
282
282
|
]
|
|
283
283
|
},
|
|
284
284
|
"gemini": {
|
package/native/mcp-server.cjs
CHANGED
|
@@ -269,7 +269,7 @@ const TOOL_SCHEMAS = {
|
|
|
269
269
|
desc: "Ask ChatGPT through the browser session",
|
|
270
270
|
schema: {
|
|
271
271
|
query: z.string().describe("Question or prompt"),
|
|
272
|
-
model: z.string().optional().describe("ChatGPT model"),
|
|
272
|
+
model: z.string().optional().describe("ChatGPT model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label"),
|
|
273
273
|
"with-page": z.boolean().optional().describe("Include current page context"),
|
|
274
274
|
file: z.string().optional().describe("One attachment path"),
|
|
275
275
|
timeout: z.number().optional().describe("Timeout in seconds")
|
package/native/oracle-cli.cjs
CHANGED
|
@@ -30,8 +30,8 @@ Commands:
|
|
|
30
30
|
|
|
31
31
|
Ask/follow options:
|
|
32
32
|
--files <glob> Add context files (repeatable)
|
|
33
|
-
--model <model> Select
|
|
34
|
-
--effort <effort> Select
|
|
33
|
+
--model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
|
|
34
|
+
--effort <effort> Select effort: light, standard, extended, heavy, pro
|
|
35
35
|
--detach Return after dispatch
|
|
36
36
|
--allow-sensitive Allow deny-listed context files
|
|
37
37
|
|
package/native/oracle-jobs.cjs
CHANGED
|
@@ -35,6 +35,24 @@ function codedError(code, message, details = {}) {
|
|
|
35
35
|
return error;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function promptDigest(prompt) {
|
|
39
|
+
return `sha256:${crypto.createHash("sha256").update(prompt).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hydrateJobMetadata(job, root = getPrivateStateRoot()) {
|
|
43
|
+
const prompt = job.promptDigest ? null : readPrivateFile(path.join(jobDirectory(job.id, root), "request.md"), {
|
|
44
|
+
root,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
});
|
|
47
|
+
const legacyModel = job.modelRequested === undefined && job.modelVerified === undefined;
|
|
48
|
+
return {
|
|
49
|
+
...job,
|
|
50
|
+
modelRequested: job.modelRequested ?? (legacyModel && job.state === "created" ? job.model ?? null : null),
|
|
51
|
+
modelVerified: job.modelVerified ?? (legacyModel && job.state !== "created" ? job.model ?? null : null),
|
|
52
|
+
promptDigest: job.promptDigest ?? promptDigest(prompt),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
function readJobs(root = getPrivateStateRoot()) {
|
|
39
57
|
const base = oracleRoot(root);
|
|
40
58
|
if (!fs.existsSync(base)) return [];
|
|
@@ -44,7 +62,8 @@ function readJobs(root = getPrivateStateRoot()) {
|
|
|
44
62
|
.filter((id) => JOB_ID_PATTERN.test(id))
|
|
45
63
|
.sort((a, b) => b.localeCompare(a))
|
|
46
64
|
.map((id) => readPrivateJson(path.join(base, id, "job.json"), null, { root }))
|
|
47
|
-
.filter(Boolean)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map((job) => hydrateJobMetadata(job, root));
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null }) {
|
|
@@ -84,8 +103,11 @@ function createJob({ prompt, contextManifest = {}, model = null, effortRequested
|
|
|
84
103
|
id,
|
|
85
104
|
state: "created",
|
|
86
105
|
model,
|
|
106
|
+
modelRequested: model,
|
|
107
|
+
modelVerified: null,
|
|
87
108
|
effortRequested,
|
|
88
109
|
effortVerified: null,
|
|
110
|
+
promptDigest: promptDigest(prompt),
|
|
89
111
|
createdAt: now.toISOString(),
|
|
90
112
|
dispatchedAt: null,
|
|
91
113
|
awaitingAt: null,
|
|
@@ -110,7 +132,7 @@ function getJob(id) {
|
|
|
110
132
|
const root = getPrivateStateRoot();
|
|
111
133
|
const job = readPrivateJson(path.join(jobDirectory(id, root), "job.json"), null, { root });
|
|
112
134
|
if (!job) throw codedError("not_found", `oracle job not found: ${id}`);
|
|
113
|
-
return job;
|
|
135
|
+
return hydrateJobMetadata(job, root);
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
function getResponse(id) {
|
|
@@ -141,7 +163,7 @@ function markDispatched(id, { tabId, promptEcho, modelVerified, effortVerified }
|
|
|
141
163
|
dispatchedAt: new Date().toISOString(),
|
|
142
164
|
tabId,
|
|
143
165
|
...(promptEcho ? { promptEcho } : {}),
|
|
144
|
-
...(modelVerified ? { model: modelVerified } : {}),
|
|
166
|
+
...(modelVerified ? { model: modelVerified, modelVerified } : {}),
|
|
145
167
|
...(effortVerified ? { effortVerified } : {}),
|
|
146
168
|
});
|
|
147
169
|
}
|
package/package.json
CHANGED
package/pi-extension/surf.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
|
|
4
5
|
const require = createRequire(import.meta.url);
|
|
@@ -26,6 +27,9 @@ const ORACLE_FINISHED_CHANNEL = "surf-oracle:finished";
|
|
|
26
27
|
const BACKGROUND_WORK_PROTOCOL_VERSION = 1;
|
|
27
28
|
const BACKGROUND_WORK_REGISTRY_KEY = "pi-subagents.background-work.v1";
|
|
28
29
|
const BACKGROUND_WORK_MODULE_SPECIFIER = "pi-subagents/background-work";
|
|
30
|
+
const EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION = 1;
|
|
31
|
+
const EXTERNAL_JOB_PROVIDER_REGISTRY_KEY = "pi-subagents.external-job-providers.v1";
|
|
32
|
+
const EXTERNAL_JOB_PROVIDER_MODULE_SPECIFIER = "pi-subagents/external-job-provider";
|
|
29
33
|
|
|
30
34
|
type Pi = {
|
|
31
35
|
registerTool(tool: Record<string, unknown>): void;
|
|
@@ -37,6 +41,20 @@ type SurfEndpoint = { kind?: string };
|
|
|
37
41
|
|
|
38
42
|
type ToolResult = { content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }>; details?: unknown; isError?: boolean };
|
|
39
43
|
|
|
44
|
+
type OracleJob = {
|
|
45
|
+
id: string;
|
|
46
|
+
state: string;
|
|
47
|
+
conversationUrl?: string | null;
|
|
48
|
+
model?: string | null;
|
|
49
|
+
modelRequested?: string | null;
|
|
50
|
+
modelVerified?: string | null;
|
|
51
|
+
effortRequested?: string | null;
|
|
52
|
+
effortVerified?: string | null;
|
|
53
|
+
promptDigest?: string | null;
|
|
54
|
+
response?: string;
|
|
55
|
+
error?: { code?: string; message?: string } | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
40
58
|
type BackgroundWorkProvider = {
|
|
41
59
|
name: string;
|
|
42
60
|
wakeChannels: string[];
|
|
@@ -54,6 +72,46 @@ type BackgroundWorkRegistry = {
|
|
|
54
72
|
providers: Map<string, BackgroundWorkProvider>;
|
|
55
73
|
};
|
|
56
74
|
|
|
75
|
+
type OracleExternalJob = {
|
|
76
|
+
provider: "surf-oracle";
|
|
77
|
+
id: string;
|
|
78
|
+
state: string;
|
|
79
|
+
conversationUrl: string | null;
|
|
80
|
+
requestedModel: string | null;
|
|
81
|
+
verifiedModel: string | null;
|
|
82
|
+
requestedEffort: string | null;
|
|
83
|
+
verifiedEffort: string | null;
|
|
84
|
+
promptDigest: string | null;
|
|
85
|
+
resultText?: string;
|
|
86
|
+
resultArtifact?: { kind: "inline-text"; bytes: number };
|
|
87
|
+
failure?: { code: string; message: string };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type OracleExternalJobProvider = {
|
|
91
|
+
name: "surf-oracle";
|
|
92
|
+
kind: "external-job";
|
|
93
|
+
wakeChannels: string[];
|
|
94
|
+
start(input: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
95
|
+
status(id: string): Promise<OracleExternalJob>;
|
|
96
|
+
result(id: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
97
|
+
reattach(id: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
98
|
+
follow(id: string, message: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type RegisterExternalJobProvider = (provider: OracleExternalJobProvider) => () => void;
|
|
102
|
+
|
|
103
|
+
type ExternalJobProviderModule = {
|
|
104
|
+
registerExternalJobProvider?: unknown;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type ExternalJobProviderRegistry = {
|
|
108
|
+
version: typeof EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION;
|
|
109
|
+
providers: Map<string, OracleExternalJobProvider>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type RememberOracleJob = (jobId: string) => boolean;
|
|
113
|
+
type EmitOracleJob = (job: Pick<OracleExternalJob, "id" | "state">) => boolean;
|
|
114
|
+
|
|
57
115
|
function textResult(value: unknown, isError = false): ToolResult {
|
|
58
116
|
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
59
117
|
const bounded = text.length > MAX_OUTPUT_CHARS
|
|
@@ -64,7 +122,12 @@ function textResult(value: unknown, isError = false): ToolResult {
|
|
|
64
122
|
|
|
65
123
|
export function resultFromHost(response: Record<string, unknown>): ToolResult {
|
|
66
124
|
const error = response.error as { content?: Array<{ text?: string }> } | undefined;
|
|
67
|
-
if (error)
|
|
125
|
+
if (error) {
|
|
126
|
+
return {
|
|
127
|
+
...textResult(error.content?.map((item) => item.text ?? "").join("\n") || "Surf request failed", true),
|
|
128
|
+
details: error,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
68
131
|
const result = response.result as { content?: ToolResult["content"] } | undefined;
|
|
69
132
|
if (!result?.content) return textResult(result ?? "OK");
|
|
70
133
|
const text = result.content.find((item) => item.type === "text")?.text;
|
|
@@ -138,6 +201,33 @@ export function registerGlobalBackgroundProvider(provider: BackgroundWorkProvide
|
|
|
138
201
|
};
|
|
139
202
|
}
|
|
140
203
|
|
|
204
|
+
export function registerGlobalExternalJobProvider(provider: OracleExternalJobProvider): () => void {
|
|
205
|
+
const key = Symbol.for(EXTERNAL_JOB_PROVIDER_REGISTRY_KEY);
|
|
206
|
+
const globalObject = globalThis as Record<PropertyKey, unknown>;
|
|
207
|
+
const existing = globalObject[key];
|
|
208
|
+
let registry: ExternalJobProviderRegistry;
|
|
209
|
+
|
|
210
|
+
if (existing === undefined) {
|
|
211
|
+
registry = { version: EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION, providers: new Map() };
|
|
212
|
+
globalObject[key] = registry;
|
|
213
|
+
} else if (
|
|
214
|
+
existing &&
|
|
215
|
+
typeof existing === "object" &&
|
|
216
|
+
!Array.isArray(existing) &&
|
|
217
|
+
(existing as Partial<ExternalJobProviderRegistry>).version === EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION &&
|
|
218
|
+
(existing as Partial<ExternalJobProviderRegistry>).providers instanceof Map
|
|
219
|
+
) {
|
|
220
|
+
registry = existing as ExternalJobProviderRegistry;
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(`Unsupported external-job provider registry at Symbol.for("${EXTERNAL_JOB_PROVIDER_REGISTRY_KEY}").`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
registry.providers.set(provider.name, provider);
|
|
226
|
+
return () => {
|
|
227
|
+
if (registry.providers.get(provider.name) === provider) registry.providers.delete(provider.name);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
141
231
|
export async function resolveBackgroundWorkRegister(
|
|
142
232
|
loadModule: () => Promise<BackgroundWorkModule> = () => import(BACKGROUND_WORK_MODULE_SPECIFIER) as Promise<BackgroundWorkModule>,
|
|
143
233
|
): Promise<RegisterBackgroundWorkProvider> {
|
|
@@ -152,6 +242,137 @@ export async function resolveBackgroundWorkRegister(
|
|
|
152
242
|
return registerGlobalBackgroundProvider;
|
|
153
243
|
}
|
|
154
244
|
|
|
245
|
+
export async function resolveExternalJobProviderRegister(
|
|
246
|
+
loadModule: () => Promise<ExternalJobProviderModule> = () => import(EXTERNAL_JOB_PROVIDER_MODULE_SPECIFIER) as Promise<ExternalJobProviderModule>,
|
|
247
|
+
): Promise<RegisterExternalJobProvider> {
|
|
248
|
+
try {
|
|
249
|
+
const module = await loadModule();
|
|
250
|
+
if (typeof module.registerExternalJobProvider === "function") {
|
|
251
|
+
return module.registerExternalJobProvider as RegisterExternalJobProvider;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// The provider bridge is optional until pi-subagents ships this consumer API.
|
|
255
|
+
}
|
|
256
|
+
return registerGlobalExternalJobProvider;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function digestPrompt(prompt: string) {
|
|
260
|
+
return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function asOracleJob(value: unknown): OracleJob {
|
|
264
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Surf oracle response did not include job metadata");
|
|
265
|
+
const job = value as Partial<OracleJob>;
|
|
266
|
+
if (typeof job.id !== "string" || typeof job.state !== "string") throw new Error("Surf oracle response did not include job id and state");
|
|
267
|
+
return job as OracleJob;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function oracleExternalJob(job: OracleJob): OracleExternalJob {
|
|
271
|
+
const resultText = typeof job.response === "string" ? job.response : undefined;
|
|
272
|
+
const failure = job.error
|
|
273
|
+
? { code: job.error.code || "failed", message: job.error.message || "Surf oracle job failed" }
|
|
274
|
+
: undefined;
|
|
275
|
+
const requestedModel = Object.hasOwn(job, "modelRequested") ? job.modelRequested ?? null : job.model ?? null;
|
|
276
|
+
return {
|
|
277
|
+
provider: "surf-oracle",
|
|
278
|
+
id: job.id,
|
|
279
|
+
state: job.state,
|
|
280
|
+
conversationUrl: job.conversationUrl ?? null,
|
|
281
|
+
requestedModel,
|
|
282
|
+
verifiedModel: job.modelVerified ?? null,
|
|
283
|
+
requestedEffort: job.effortRequested ?? null,
|
|
284
|
+
verifiedEffort: job.effortVerified ?? null,
|
|
285
|
+
promptDigest: job.promptDigest ?? null,
|
|
286
|
+
...(resultText === undefined ? {} : { resultText, resultArtifact: { kind: "inline-text", bytes: Buffer.byteLength(resultText, "utf8") } }),
|
|
287
|
+
...(failure ? { failure } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function requestOracleJob(request: typeof requestSurf, tool: string, args: Record<string, unknown>) {
|
|
292
|
+
const result = await request(tool, args);
|
|
293
|
+
if (result.isError) {
|
|
294
|
+
const details = result.details as { code?: unknown; jobId?: unknown; message?: unknown } | undefined;
|
|
295
|
+
const message = typeof details?.message === "string"
|
|
296
|
+
? details.message
|
|
297
|
+
: result.content.map((item) => item.text ?? "").join("\n") || "Surf oracle request failed";
|
|
298
|
+
const error = new Error(message);
|
|
299
|
+
if (typeof details?.code === "string") Object.assign(error, { code: details.code });
|
|
300
|
+
if (typeof details?.jobId === "string") Object.assign(error, { jobId: details.jobId });
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
return oracleExternalJob(asOracleJob(result.details));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function emitFailedOracleJob(error: unknown, emitTerminal: EmitOracleJob) {
|
|
307
|
+
if (!error || typeof error !== "object" || !("jobId" in error) || typeof error.jobId !== "string") return;
|
|
308
|
+
emitTerminal({ id: error.jobId, state: "failed" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function createOracleExternalJobProvider(
|
|
312
|
+
sessionId: string,
|
|
313
|
+
jobIds: Set<string>,
|
|
314
|
+
request: typeof requestSurf = requestSurf,
|
|
315
|
+
rememberJob: RememberOracleJob = (jobId) => {
|
|
316
|
+
jobIds.add(jobId);
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
emitTerminal: EmitOracleJob = () => false,
|
|
320
|
+
): OracleExternalJobProvider {
|
|
321
|
+
return {
|
|
322
|
+
name: "surf-oracle",
|
|
323
|
+
kind: "external-job",
|
|
324
|
+
wakeChannels: [ORACLE_FINISHED_CHANNEL],
|
|
325
|
+
async start(input) {
|
|
326
|
+
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
327
|
+
if (!prompt.trim()) throw new Error("prompt required");
|
|
328
|
+
const job = await requestOracleJob(request, "oracle.ask", {
|
|
329
|
+
prompt,
|
|
330
|
+
...(typeof input.model === "string" ? { model: input.model } : {}),
|
|
331
|
+
...(typeof input.effort === "string" ? { effort: input.effort } : {}),
|
|
332
|
+
});
|
|
333
|
+
rememberJob(job.id);
|
|
334
|
+
return { ...job, promptDigest: job.promptDigest ?? digestPrompt(prompt) };
|
|
335
|
+
},
|
|
336
|
+
status(id) {
|
|
337
|
+
return requestOracleJob(request, "oracle.status", { id });
|
|
338
|
+
},
|
|
339
|
+
result(id, input = {}) {
|
|
340
|
+
return requestOracleJob(request, "oracle.result", { id, ...(typeof input.timeout === "number" ? { timeout: input.timeout } : {}) })
|
|
341
|
+
.then((job) => {
|
|
342
|
+
emitTerminal({ id: job.id, state: job.state });
|
|
343
|
+
return job;
|
|
344
|
+
})
|
|
345
|
+
.catch((error) => {
|
|
346
|
+
emitFailedOracleJob(error, emitTerminal);
|
|
347
|
+
throw error;
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
reattach(id, input = {}) {
|
|
351
|
+
return requestOracleJob(request, "oracle.result", { id, ...(typeof input.timeout === "number" ? { timeout: input.timeout } : {}) })
|
|
352
|
+
.then((job) => {
|
|
353
|
+
rememberJob(job.id);
|
|
354
|
+
emitTerminal({ id: job.id, state: job.state });
|
|
355
|
+
return job;
|
|
356
|
+
})
|
|
357
|
+
.catch((error) => {
|
|
358
|
+
emitFailedOracleJob(error, emitTerminal);
|
|
359
|
+
throw error;
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
async follow(id, message, input = {}) {
|
|
363
|
+
if (!message.trim()) throw new Error("message required");
|
|
364
|
+
const job = await requestOracleJob(request, "oracle.ask", {
|
|
365
|
+
follow: id,
|
|
366
|
+
prompt: message,
|
|
367
|
+
...(typeof input.model === "string" ? { model: input.model } : {}),
|
|
368
|
+
...(typeof input.effort === "string" ? { effort: input.effort } : {}),
|
|
369
|
+
});
|
|
370
|
+
rememberJob(job.id);
|
|
371
|
+
return { ...job, promptDigest: job.promptDigest ?? digestPrompt(message) };
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
155
376
|
export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Set<string>, listJobs: () => Array<{ id: string; state: string }>, register: RegisterBackgroundWorkProvider) {
|
|
156
377
|
return register({
|
|
157
378
|
name: "surf-oracle",
|
|
@@ -162,6 +383,17 @@ export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Se
|
|
|
162
383
|
});
|
|
163
384
|
}
|
|
164
385
|
|
|
386
|
+
export function registerOptionalExternalJobProvider(
|
|
387
|
+
sessionId: string,
|
|
388
|
+
jobIds: Set<string>,
|
|
389
|
+
register: RegisterExternalJobProvider,
|
|
390
|
+
request: typeof requestSurf = requestSurf,
|
|
391
|
+
rememberJob?: RememberOracleJob,
|
|
392
|
+
emitTerminal?: EmitOracleJob,
|
|
393
|
+
) {
|
|
394
|
+
return register(createOracleExternalJobProvider(sessionId, jobIds, request, rememberJob, emitTerminal));
|
|
395
|
+
}
|
|
396
|
+
|
|
165
397
|
export function rememberOracleJobForSession(jobIds: Set<string>, jobId: unknown, requestGeneration: number, currentGeneration: number, sessionActive: boolean): boolean {
|
|
166
398
|
if (typeof jobId !== "string" || !sessionActive || requestGeneration !== currentGeneration) return false;
|
|
167
399
|
jobIds.add(jobId);
|
|
@@ -219,7 +451,12 @@ export default function surfExtension(pi: Pi) {
|
|
|
219
451
|
async execute(_id: string, args: Record<string, unknown>) {
|
|
220
452
|
try {
|
|
221
453
|
const result = await requestSurf("oracle.result", args);
|
|
222
|
-
|
|
454
|
+
const errorDetails = result.details as { jobId?: unknown } | undefined;
|
|
455
|
+
if (result.isError) {
|
|
456
|
+
if (typeof errorDetails?.jobId === "string") emitOracleFinished(pi, { id: errorDetails.jobId, state: "failed" });
|
|
457
|
+
} else {
|
|
458
|
+
emitOracleFinished(pi, result.details);
|
|
459
|
+
}
|
|
223
460
|
return result;
|
|
224
461
|
} catch (error) {
|
|
225
462
|
return textResult(error instanceof Error ? error.message : String(error), true);
|
|
@@ -249,12 +486,15 @@ export default function surfExtension(pi: Pi) {
|
|
|
249
486
|
});
|
|
250
487
|
|
|
251
488
|
let dispose: (() => void) | undefined;
|
|
489
|
+
let disposeExternal: (() => void) | undefined;
|
|
252
490
|
pi.on("session_start", (_event, ctx) => {
|
|
253
491
|
sessionGeneration++;
|
|
254
492
|
const generation = sessionGeneration;
|
|
255
493
|
sessionActive = false;
|
|
256
494
|
dispose?.();
|
|
495
|
+
disposeExternal?.();
|
|
257
496
|
dispose = undefined;
|
|
497
|
+
disposeExternal = undefined;
|
|
258
498
|
oracleJobIds.clear();
|
|
259
499
|
|
|
260
500
|
const session = ctx as { sessionManager?: { getSessionId?: () => string }; sessionId?: string };
|
|
@@ -262,7 +502,10 @@ export default function surfExtension(pi: Pi) {
|
|
|
262
502
|
if (!sessionId) return;
|
|
263
503
|
try {
|
|
264
504
|
const jobs = require("../native/oracle-jobs.cjs") as { listJobs(): Array<{ id: string; state: string }> };
|
|
505
|
+
const rememberForGeneration = (jobId: string) => rememberOracleJobForSession(oracleJobIds, jobId, generation, sessionGeneration, sessionActive);
|
|
506
|
+
const emitFinished = (job: Pick<OracleExternalJob, "id" | "state">) => emitOracleFinished(pi, job);
|
|
265
507
|
dispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, registerGlobalBackgroundProvider);
|
|
508
|
+
disposeExternal = registerOptionalExternalJobProvider(sessionId, oracleJobIds, registerGlobalExternalJobProvider, requestSurf, rememberForGeneration, emitFinished);
|
|
266
509
|
sessionActive = true;
|
|
267
510
|
void resolveBackgroundWorkRegister().then((register) => {
|
|
268
511
|
try {
|
|
@@ -278,6 +521,20 @@ export default function surfExtension(pi: Pi) {
|
|
|
278
521
|
// Keep the already-registered fallback provider.
|
|
279
522
|
}
|
|
280
523
|
});
|
|
524
|
+
void resolveExternalJobProviderRegister().then((register) => {
|
|
525
|
+
try {
|
|
526
|
+
if (register === registerGlobalExternalJobProvider || generation !== sessionGeneration) return;
|
|
527
|
+
const nextDispose = registerOptionalExternalJobProvider(sessionId, oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
|
|
528
|
+
if (generation !== sessionGeneration) {
|
|
529
|
+
nextDispose();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
disposeExternal?.();
|
|
533
|
+
disposeExternal = nextDispose;
|
|
534
|
+
} catch {
|
|
535
|
+
// Keep the already-registered fallback provider.
|
|
536
|
+
}
|
|
537
|
+
});
|
|
281
538
|
} catch {
|
|
282
539
|
// The Pi bridge is optional. Browser tools work without pi-subagents.
|
|
283
540
|
}
|
|
@@ -286,7 +543,9 @@ export default function surfExtension(pi: Pi) {
|
|
|
286
543
|
sessionGeneration++;
|
|
287
544
|
sessionActive = false;
|
|
288
545
|
dispose?.();
|
|
546
|
+
disposeExternal?.();
|
|
289
547
|
dispose = undefined;
|
|
548
|
+
disposeExternal = undefined;
|
|
290
549
|
oracleJobIds.clear();
|
|
291
550
|
});
|
|
292
551
|
}
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -78,7 +78,7 @@ Query AI models using your browser's logged-in session. Must be logged into the
|
|
|
78
78
|
```bash
|
|
79
79
|
surf chatgpt "explain this code"
|
|
80
80
|
surf chatgpt "summarize" --with-page # Include current page context
|
|
81
|
-
surf chatgpt "review" --model gpt-
|
|
81
|
+
surf chatgpt "review" --model gpt-5.5 # Specify model
|
|
82
82
|
surf chatgpt "analyze" --file document.pdf # With file attachment
|
|
83
83
|
```
|
|
84
84
|
|
|
@@ -91,7 +91,7 @@ For agent workflows, detach after dispatch and keep the returned `.id`:
|
|
|
91
91
|
```bash
|
|
92
92
|
surf oracle ask "Review this change and identify release risks" \
|
|
93
93
|
--files "src/**/*.ts" --files "package.json" \
|
|
94
|
-
--model
|
|
94
|
+
--model gpt-5.5 --effort pro --detach --json
|
|
95
95
|
|
|
96
96
|
surf oracle status <job-id> --json
|
|
97
97
|
surf oracle result <job-id> --json
|
|
@@ -101,7 +101,9 @@ surf oracle result <job-id> --wait --json
|
|
|
101
101
|
|
|
102
102
|
`status` reads persisted state without touching Chrome. `result` attempts to harvest the answer and returns the job object with `response` once its state is `captured`. A Ctrl-C during waiting exits with status 130 and prints `Recover with: surf oracle result <id>`. Once the job is `awaiting`, the persisted ChatGPT conversation URL is its durable key, so `surf oracle result <id>` can recover after CLI exit, native-host restart, or Chrome restart by reopening that conversation.
|
|
103
103
|
|
|
104
|
-
Treat Pro quota as scarce. Oracle never selects Pro implicitly; request it with `--model pro`. Accepted `--effort` values are `light`, `standard`, `extended`, and `
|
|
104
|
+
Treat Pro quota as scarce. Oracle never selects Pro implicitly; request it with `--model pro` or `--effort pro`. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Accepted `--effort` values are `light`, `standard`, `extended`, `heavy`, and `pro`. Requested model and effort selections are read back before submission, and an unverifiable selection fails with `model_verification_failed` instead of silently continuing. Capacity is one non-terminal oracle job. A `capacity` error includes the in-flight job ID; poll that job or wait for it to finish rather than submitting the same consult again.
|
|
105
|
+
|
|
106
|
+
When loaded as a Pi extension, Surf also registers a `surf-oracle` external-job provider when the runtime exposes that bridge. The provider maps `start`, `status`, `result`, `reattach`, and `follow` to durable Surf Oracle jobs. It returns the conversation URL, requested and verified model and effort, prompt digest, result text, and failure details. `reattach` only harvests an existing job by ID; it never submits the prompt again.
|
|
105
107
|
|
|
106
108
|
Context comes from repeatable `--files` globs. Surf fails closed when a glob matches nothing or a matched file is unreadable, binary, or invalid UTF-8. It also blocks gitignored files and basenames matching `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*.p12`, `*.pfx`, `credentials*`, or `secrets*`. Use `--allow-sensitive` only after intentionally reviewing those files; it overrides the block rather than redacting content. Context up to 60,000 evidence characters is inserted inline, while larger context becomes one private text attachment. The assembly manifest records each path, byte count, SHA-256, inline or bundle disposition, and deny-list outcome.
|
|
107
109
|
|