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.
@@ -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
- if (["instant", "gpt53"].includes(normalized)) return "instant";
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
- if (normalizeChatGPTModelChoice(value) === targetModel) return true;
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
- item?.role === "menuitemradio" &&
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: '[data-testid="model-switcher-dropdown-button"]',
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="menuitemradio"][data-testid^="model-switcher-"]',
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[0] : containers.find((container) => {
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 === 1 ? dispatchClickSequence(matches[0]) : false;
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 (!verified) throw verificationError("model", desiredModel, menu.items);
321
- return verified.displayLabel || verified.label;
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-4o, o1, etc.",
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-4o', desc: "Specify model" },
281
+ { cmd: 'chatgpt "analyze" --model gpt-5.5', desc: "Specify model" },
282
282
  ]
283
283
  },
284
284
  "gemini": {
@@ -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")
@@ -30,8 +30,8 @@ Commands:
30
30
 
31
31
  Ask/follow options:
32
32
  --files <glob> Add context files (repeatable)
33
- --model <model> Select a ChatGPT model
34
- --effort <effort> Select reasoning effort
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -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) return textResult(error.content?.map((item) => item.text ?? "").join("\n") || "Surf request failed", true);
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
- emitOracleFinished(pi, result.details ?? { id: args.id, state: result.isError ? "failed" : undefined });
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
  }
@@ -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-4o # Specify model
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 pro --effort extended --detach --json
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 `heavy`. 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.
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