surf-cli 2.15.1 → 2.16.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 CHANGED
@@ -526,7 +526,7 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
526
526
 
527
527
  #### Oracle
528
528
 
529
- Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`; `pro` selects the current ChatGPT GPT-5.6 Sol Pro web mode.
529
+ Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
530
530
 
531
531
  ```bash
532
532
  surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
@@ -996,9 +996,9 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
996
996
 
997
997
  It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
998
998
 
999
- The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model` and `options.effort` for starts, so a Pi profile can request `model: pro` and reach the ChatGPT GPT-5.6 Sol Pro web mode through Surf. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
999
+ The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model` and `options.effort` for starts, so a Pi profile can request `model: gpt-5.6-sol` plus `effort: pro` and reach ChatGPT GPT-5.6 Sol with Pro effort through Surf. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1000
1000
 
1001
- When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, and `options.model: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
1001
+ When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, `options.model: gpt-5.6-sol`, and `options.effort: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
1002
1002
 
1003
1003
  Shell-based agents should select a unique session with `SURF_SESSION` and call `surf session.ensure` before their first browser command. The optional Pi extension still uses its existing socket-tool interface; callers that coordinate several Pi workers should pass explicit tab targets until session selection is exposed by that integration.
1004
1004
 
package/agents/gpt-pro.md CHANGED
@@ -5,7 +5,8 @@ runner:
5
5
  type: external-job
6
6
  provider: surf-oracle
7
7
  options:
8
- model: pro
8
+ model: gpt-5.6-sol
9
+ effort: pro
9
10
  async: true
10
11
  systemPromptMode: replace
11
12
  inheritProjectContext: false
@@ -4,7 +4,7 @@ const CHATGPT_MODEL_ALIASES = new Map([
4
4
  ["gpt53", "instant"],
5
5
  ["thinking", "thinking"],
6
6
  ["gpt54thinking", "thinking"],
7
- ["pro", "gpt56sol"],
7
+ ["pro", "pro"],
8
8
  ["gpt54pro", "pro"],
9
9
  ["55", "gpt55"],
10
10
  ["gpt55", "gpt55"],
@@ -191,11 +191,22 @@ async function readPicker(cdp, kind, click = false) {
191
191
  `(() => {
192
192
  ${buildClickDispatcher()}
193
193
  const kind = ${JSON.stringify(kind)};
194
- const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
194
+ let nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
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');
197
- return value.includes('thinking') || value.includes('pro');
196
+ if (kind !== 'model') return value.includes('thinking') || value.includes('pro');
197
+ return value.includes('gpt') || value.includes('thinking') || value.includes('instant');
198
198
  });
199
+ if (kind === 'model' && nodes.length === 0) {
200
+ nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
201
+ const value = ((node.getAttribute?.('aria-label') || '') + ' ' + (node.textContent || '')).toLowerCase();
202
+ return value.includes('pro');
203
+ });
204
+ }
205
+ if (kind === 'model' && nodes.length === 0) {
206
+ nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) =>
207
+ node.getAttribute?.('aria-haspopup') === 'menu' || node.getAttribute?.('aria-expanded') !== null
208
+ );
209
+ }
199
210
  const items = nodes.map((node) => {
200
211
  const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
201
212
  const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
@@ -329,6 +340,12 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000, signal) {
329
340
  if (picker?.items?.length !== 1) throw verificationError("model", desiredModel);
330
341
  await delay(300, signal);
331
342
  const menu = await waitForMenu(cdp, "model", timeoutMs, signal);
343
+ const currentAdvancedModel = verifyChatGPTModelSelection(
344
+ menu.items.filter((item) => /\bmodel\b/i.test(String(item?.label || ""))),
345
+ desiredModel,
346
+ );
347
+ if (currentAdvancedModel) return currentAdvancedModel.displayLabel || currentAdvancedModel.label;
348
+
332
349
  const match = resolveChatGPTModelMenuOption(menu.items, desiredModel);
333
350
  if (!match || !(await clickMenuItem(cdp, "model", match))) {
334
351
  throw verificationError("model", desiredModel, menu.items);
@@ -71,7 +71,9 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
71
71
  model,
72
72
  effortRequested: args.effort ?? null,
73
73
  follow: args.follow ?? null,
74
+ requestId: args.requestId ?? null,
74
75
  });
76
+ if (created.requestDeduped) return oracleJobs.getJob(created.id);
75
77
  let createdTabId = null;
76
78
 
77
79
  try {
@@ -109,6 +111,8 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
109
111
  oracleJobs.appendTurn(parent.id, {
110
112
  prompt: args.prompt,
111
113
  dispatchedAt: dispatchedJob.dispatchedAt,
114
+ childJobId: created.id,
115
+ requestId: args.requestId ?? null,
112
116
  });
113
117
  }
114
118
  },
@@ -259,6 +263,8 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
259
263
  oracleJobs.markTurnCaptured(captured.follow, {
260
264
  dispatchedAt: captured.dispatchedAt,
261
265
  capturedAt: captured.capturedAt,
266
+ childJobId: captured.id,
267
+ requestId: captured.requestId ?? null,
262
268
  });
263
269
  }
264
270
  if (captured.tabId) {
@@ -39,6 +39,32 @@ function promptDigest(prompt) {
39
39
  return `sha256:${crypto.createHash("sha256").update(prompt).digest("hex")}`;
40
40
  }
41
41
 
42
+ function stableJson(value) {
43
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
44
+ if (value && typeof value === "object") {
45
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
46
+ }
47
+ return JSON.stringify(value) ?? "null";
48
+ }
49
+
50
+ function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow }) {
51
+ return promptDigest(stableJson({
52
+ promptDigest: promptDigest(prompt),
53
+ contextManifest: contextManifest ?? {},
54
+ model: model ?? null,
55
+ effortRequested: effortRequested ?? null,
56
+ follow: follow ?? null,
57
+ }));
58
+ }
59
+
60
+ function normalizedRequestId(requestId) {
61
+ if (requestId === null || requestId === undefined) return null;
62
+ if (typeof requestId !== "string" || !requestId.trim() || requestId.trim() !== requestId || requestId.length > 256 || requestId.includes("\0")) {
63
+ throw codedError("invalid_request", "oracle requestId must be a non-empty trimmed string");
64
+ }
65
+ return requestId;
66
+ }
67
+
42
68
  function hydrateJobMetadata(job, root = getPrivateStateRoot()) {
43
69
  const prompt = job.promptDigest ? null : readPrivateFile(path.join(jobDirectory(job.id, root), "request.md"), {
44
70
  root,
@@ -66,10 +92,27 @@ function readJobs(root = getPrivateStateRoot()) {
66
92
  .map((job) => hydrateJobMetadata(job, root));
67
93
  }
68
94
 
69
- function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null }) {
95
+ function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null }) {
70
96
  const root = getPrivateStateRoot();
71
97
  const base = oracleRoot(root);
72
98
  ensurePrivateDir(base, root);
99
+ const safeRequestId = normalizedRequestId(requestId);
100
+ const fingerprint = safeRequestId
101
+ ? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow })
102
+ : null;
103
+ if (safeRequestId) {
104
+ const existing = readJobs(root).find((job) => job.requestId === safeRequestId);
105
+ if (existing) {
106
+ if (existing.requestFingerprint !== fingerprint) {
107
+ throw codedError(
108
+ "idempotency_conflict",
109
+ `oracle requestId ${safeRequestId} was already used for a different request`,
110
+ { jobId: existing.id },
111
+ );
112
+ }
113
+ return { ...existing, requestDeduped: true };
114
+ }
115
+ }
73
116
  const inFlight = readJobs(root).find((job) => !TERMINAL_STATES.has(job.state));
74
117
  if (inFlight) {
75
118
  throw codedError(
@@ -108,6 +151,7 @@ function createJob({ prompt, contextManifest = {}, model = null, effortRequested
108
151
  effortRequested,
109
152
  effortVerified: null,
110
153
  promptDigest: promptDigest(prompt),
154
+ ...(safeRequestId ? { requestId: safeRequestId, requestFingerprint: fingerprint } : {}),
111
155
  createdAt: now.toISOString(),
112
156
  dispatchedAt: null,
113
157
  awaitingAt: null,
@@ -215,10 +259,14 @@ function updateTabId(id, tabId) {
215
259
 
216
260
  function appendTurn(id, turn) {
217
261
  const job = getJob(id);
262
+ const duplicate = job.turns.find((existing) => (turn.childJobId && existing.childJobId === turn.childJobId) || (turn.requestId && existing.requestId === turn.requestId));
263
+ if (duplicate) return job;
218
264
  const storedTurn = {
219
265
  prompt: turn.prompt,
220
266
  dispatchedAt: turn.dispatchedAt ?? null,
221
267
  capturedAt: turn.capturedAt ?? null,
268
+ ...(turn.childJobId ? { childJobId: turn.childJobId } : {}),
269
+ ...(turn.requestId ? { requestId: turn.requestId } : {}),
222
270
  };
223
271
  const root = getPrivateStateRoot();
224
272
  const directory = jobDirectory(id, root);
@@ -229,9 +277,9 @@ function appendTurn(id, turn) {
229
277
  return updated;
230
278
  }
231
279
 
232
- function markTurnCaptured(id, { dispatchedAt, capturedAt }) {
280
+ function markTurnCaptured(id, { dispatchedAt, capturedAt, childJobId, requestId }) {
233
281
  const job = getJob(id);
234
- const turnIndex = job.turns.findIndex((turn) => turn.dispatchedAt === dispatchedAt);
282
+ const turnIndex = job.turns.findIndex((turn) => (childJobId && turn.childJobId === childJobId) || (requestId && turn.requestId === requestId) || turn.dispatchedAt === dispatchedAt);
235
283
  if (turnIndex === -1) {
236
284
  throw codedError(
237
285
  "invalid_transition",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.15.1",
3
+ "version": "2.16.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -43,15 +43,10 @@ type ToolResult = { content: Array<{ type: "text" | "image"; text?: string; data
43
43
  type OracleJob = {
44
44
  id: string;
45
45
  state: string;
46
- conversationUrl?: string | null;
47
- model?: string | null;
48
- modelRequested?: string | null;
49
- modelVerified?: string | null;
50
- effortRequested?: string | null;
51
- effortVerified?: string | null;
52
- promptDigest?: string | null;
46
+ conversationUrl: string | null;
47
+ follow: string | null;
53
48
  response?: string;
54
- error?: { code?: string; message?: string } | null;
49
+ error: { code?: string; message?: string } | null;
55
50
  };
56
51
 
57
52
  type BackgroundWorkProvider = {
@@ -75,6 +70,7 @@ type OracleExternalJob = {
75
70
  id: string;
76
71
  state: string;
77
72
  conversationUrl: string | null;
73
+ follow?: string;
78
74
  resultText?: string;
79
75
  failure?: { code: string; message: string };
80
76
  };
@@ -97,6 +93,7 @@ type OracleExternalJobProvider = {
97
93
  status(id: string): Promise<PiExternalJobHandle>;
98
94
  result(id: string): Promise<PiExternalJobResult>;
99
95
  reattach(id: string): Promise<PiExternalJobHandle>;
96
+ followUp?(input: Record<string, unknown>): Promise<PiExternalJobHandle>;
100
97
  };
101
98
 
102
99
  type RegisterExternalJobProvider = (provider: OracleExternalJobProvider) => () => void;
@@ -257,11 +254,40 @@ export async function resolveExternalJobProviderRegister(
257
254
  return registerGlobalExternalJobProvider;
258
255
  }
259
256
 
257
+ function optionalOracleString(value: unknown, field: string): string | undefined {
258
+ if (value === undefined || value === null) return undefined;
259
+ if (typeof value !== "string") throw new Error(`Surf oracle response included an invalid ${field}`);
260
+ return value;
261
+ }
262
+
263
+ function oracleFailure(value: unknown): OracleJob["error"] {
264
+ if (value === undefined || value === null) return null;
265
+ if (typeof value !== "object" || Array.isArray(value)) {
266
+ throw new Error("Surf oracle response included invalid failure details");
267
+ }
268
+ const details = value as Record<string, unknown>;
269
+ const code = optionalOracleString(details.code, "failure code");
270
+ const message = optionalOracleString(details.message, "failure message");
271
+ return { ...(code === undefined ? {} : { code }), ...(message === undefined ? {} : { message }) };
272
+ }
273
+
260
274
  function asOracleJob(value: unknown): OracleJob {
261
275
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Surf oracle response did not include job metadata");
262
- const job = value as Partial<OracleJob>;
263
- if (typeof job.id !== "string" || typeof job.state !== "string") throw new Error("Surf oracle response did not include job id and state");
264
- return job as OracleJob;
276
+ const job = value as Record<string, unknown>;
277
+ if (typeof job.id !== "string" || !job.id || job.id.trim() !== job.id || typeof job.state !== "string") {
278
+ throw new Error("Surf oracle response did not include a valid job id and state");
279
+ }
280
+ const conversationUrl = optionalOracleString(job.conversationUrl, "conversation URL");
281
+ const follow = optionalOracleString(job.follow, "follow job id");
282
+ const response = optionalOracleString(job.response, "result text");
283
+ return {
284
+ id: job.id,
285
+ state: job.state,
286
+ conversationUrl: conversationUrl ?? null,
287
+ follow: follow ?? null,
288
+ error: oracleFailure(job.error),
289
+ ...(response === undefined ? {} : { response }),
290
+ };
265
291
  }
266
292
 
267
293
  function oracleExternalJob(job: OracleJob): OracleExternalJob {
@@ -273,6 +299,7 @@ function oracleExternalJob(job: OracleJob): OracleExternalJob {
273
299
  id: job.id,
274
300
  state: job.state,
275
301
  conversationUrl: job.conversationUrl ?? null,
302
+ ...(typeof job.follow === "string" ? { follow: job.follow } : {}),
276
303
  ...(resultText === undefined ? {} : { resultText }),
277
304
  ...(failure ? { failure } : {}),
278
305
  };
@@ -333,6 +360,7 @@ async function requestOracleJob(request: typeof requestSurf, tool: string, args:
333
360
  }
334
361
 
335
362
  function emitFailedOracleJob(error: unknown, emitTerminal: EmitOracleJob) {
363
+ if (error && typeof error === "object" && "code" in error && error.code === "SURF_REQUEST_ABORTED") return;
336
364
  if (!error || typeof error !== "object" || !("jobId" in error) || typeof error.jobId !== "string") return;
337
365
  emitTerminal({ id: error.jobId, state: "failed" });
338
366
  }
@@ -347,8 +375,41 @@ function oracleOption(input: Record<string, unknown>, key: "model" | "effort"):
347
375
  return typeof direct === "string" ? direct : undefined;
348
376
  }
349
377
 
378
+ function optionalString(input: Record<string, unknown>, key: string): string | undefined {
379
+ const value = input[key];
380
+ return typeof value === "string" && value.trim() ? value : undefined;
381
+ }
382
+
383
+ function parentProviderJobId(input: Record<string, unknown>): string {
384
+ const id = optionalString(input, "parentProviderJobId") ?? optionalString(input, "providerJobId");
385
+ if (!id) throw new Error("parentProviderJobId required");
386
+ return id;
387
+ }
388
+
389
+ function assertFollowJob(job: OracleExternalJob, parentId: string) {
390
+ if (job.id === parentId) throw new Error(`Surf oracle follow-up reused parent job '${parentId}'`);
391
+ if (job.follow !== parentId) throw new Error(`Surf oracle follow-up job '${job.id}' is not linked to parent '${parentId}'`);
392
+ }
393
+
394
+ const ORACLE_STATUS_HARVEST_TIMEOUT_SECONDS = 5;
395
+
396
+ async function requestOracleJobStatus(request: typeof requestSurf, id: string) {
397
+ try {
398
+ return await requestOracleJob(request, "oracle.result", {
399
+ id,
400
+ timeout: ORACLE_STATUS_HARVEST_TIMEOUT_SECONDS,
401
+ });
402
+ } catch (error) {
403
+ if (!error || typeof error !== "object") throw error;
404
+ if ("code" in error && error.code === "SURF_REQUEST_ABORTED") throw error;
405
+ if ("jobId" in error && error.jobId === id) {
406
+ return requestOracleJob(request, "oracle.status", { id });
407
+ }
408
+ throw error;
409
+ }
410
+ }
411
+
350
412
  export function createOracleExternalJobProvider(
351
- sessionId: string,
352
413
  jobIds: Set<string>,
353
414
  request: typeof requestSurf = requestSurf,
354
415
  rememberJob: RememberOracleJob = (jobId) => {
@@ -356,8 +417,9 @@ export function createOracleExternalJobProvider(
356
417
  return true;
357
418
  },
358
419
  emitTerminal: EmitOracleJob = () => false,
420
+ options: { followUp?: boolean } = {},
359
421
  ): OracleExternalJobProvider {
360
- return {
422
+ const provider: OracleExternalJobProvider = {
361
423
  name: "surf-oracle",
362
424
  async start(input) {
363
425
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
@@ -373,7 +435,9 @@ export function createOracleExternalJobProvider(
373
435
  return piExternalJobHandle(job);
374
436
  },
375
437
  async status(id) {
376
- return piExternalJobHandle(await requestOracleJob(request, "oracle.status", { id }));
438
+ const job = await requestOracleJobStatus(request, id);
439
+ emitTerminal({ id: job.id, state: job.state });
440
+ return piExternalJobHandle(job);
377
441
  },
378
442
  result(id) {
379
443
  return requestOracleJob(request, "oracle.result", { id })
@@ -387,7 +451,7 @@ export function createOracleExternalJobProvider(
387
451
  });
388
452
  },
389
453
  reattach(id) {
390
- return requestOracleJob(request, "oracle.result", { id })
454
+ return requestOracleJobStatus(request, id)
391
455
  .then((job) => {
392
456
  rememberJob(job.id);
393
457
  emitTerminal({ id: job.id, state: job.state });
@@ -399,6 +463,27 @@ export function createOracleExternalJobProvider(
399
463
  });
400
464
  },
401
465
  };
466
+ if (options.followUp) {
467
+ provider.followUp = async (input) => {
468
+ const prompt = typeof input.prompt === "string" ? input.prompt : "";
469
+ if (!prompt.trim()) throw new Error("prompt required");
470
+ const parentId = parentProviderJobId(input);
471
+ const model = oracleOption(input, "model");
472
+ const effort = oracleOption(input, "effort");
473
+ const requestId = optionalString(input, "requestId");
474
+ const job = await requestOracleJob(request, "oracle.ask", {
475
+ prompt,
476
+ follow: parentId,
477
+ ...(model !== undefined ? { model } : {}),
478
+ ...(effort !== undefined ? { effort } : {}),
479
+ ...(requestId !== undefined ? { requestId } : {}),
480
+ });
481
+ assertFollowJob(job, parentId);
482
+ rememberJob(job.id);
483
+ return piExternalJobHandle(job);
484
+ };
485
+ }
486
+ return provider;
402
487
  }
403
488
 
404
489
  export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Set<string>, listJobs: () => Array<{ id: string; state: string }>, register: RegisterBackgroundWorkProvider) {
@@ -412,14 +497,21 @@ export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Se
412
497
  }
413
498
 
414
499
  export function registerOptionalExternalJobProvider(
415
- sessionId: string,
416
500
  jobIds: Set<string>,
417
501
  register: RegisterExternalJobProvider,
418
502
  request: typeof requestSurf = requestSurf,
419
503
  rememberJob?: RememberOracleJob,
420
504
  emitTerminal?: EmitOracleJob,
421
505
  ) {
422
- return register(createOracleExternalJobProvider(sessionId, jobIds, request, rememberJob, emitTerminal));
506
+ const provider = createOracleExternalJobProvider(jobIds, request, rememberJob, emitTerminal, { followUp: true });
507
+ try {
508
+ return register(provider);
509
+ } catch (error) {
510
+ if (String(error instanceof Error ? error.message : error).includes("followUp")) {
511
+ return register(createOracleExternalJobProvider(jobIds, request, rememberJob, emitTerminal));
512
+ }
513
+ throw error;
514
+ }
423
515
  }
424
516
 
425
517
  export function rememberOracleJobForSession(jobIds: Set<string>, jobId: unknown, requestGeneration: number, currentGeneration: number, sessionActive: boolean): boolean {
@@ -533,7 +625,7 @@ export default function surfExtension(pi: Pi) {
533
625
  const rememberForGeneration = (jobId: string) => rememberOracleJobForSession(oracleJobIds, jobId, generation, sessionGeneration, sessionActive);
534
626
  const emitFinished = (job: Pick<OracleExternalJob, "id" | "state">) => emitOracleFinished(pi, job);
535
627
  dispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, registerGlobalBackgroundProvider);
536
- disposeExternal = registerOptionalExternalJobProvider(sessionId, oracleJobIds, registerGlobalExternalJobProvider, requestSurf, rememberForGeneration, emitFinished);
628
+ disposeExternal = registerOptionalExternalJobProvider(oracleJobIds, registerGlobalExternalJobProvider, requestSurf, rememberForGeneration, emitFinished);
537
629
  sessionActive = true;
538
630
  void resolveBackgroundWorkRegister().then((register) => {
539
631
  try {
@@ -552,7 +644,7 @@ export default function surfExtension(pi: Pi) {
552
644
  void resolveExternalJobProviderRegister().then((register) => {
553
645
  try {
554
646
  if (register === registerGlobalExternalJobProvider || generation !== sessionGeneration) return;
555
- const nextDispose = registerOptionalExternalJobProvider(sessionId, oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
647
+ const nextDispose = registerOptionalExternalJobProvider(oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
556
648
  if (generation !== sessionGeneration) {
557
649
  nextDispose();
558
650
  return;
@@ -112,11 +112,11 @@ surf oracle result <job-id> --wait --json
112
112
 
113
113
  `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.
114
114
 
115
- 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.
115
+ Treat Pro quota as scarce. Oracle never selects Pro effort implicitly; request it with `--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`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort. 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.
116
116
 
117
- 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`, and `reattach` to durable Surf Oracle jobs and returns pi-subagents' external-job contract shape: `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the conversation URL, the captured result text as `output`, and failure code and message. It honors `options.model` and `options.effort` for starts, so `model: pro` selects ChatGPT GPT-5.6 Sol Pro web mode through the browser. `reattach` only harvests an existing job by ID; it never submits the prompt again.
117
+ 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`, and `reattach` to durable Surf Oracle jobs and returns pi-subagents' external-job contract shape: `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the conversation URL, the captured result text as `output`, and failure code and message. It honors `options.model` and `options.effort` for starts, so `model: gpt-5.6-sol` plus `effort: pro` selects ChatGPT GPT-5.6 Sol with Pro effort through the browser. `reattach` only harvests an existing job by ID; it never submits the prompt again.
118
118
 
119
- When Surf is installed as a Pi package, it exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, and `options.model: pro`. Surf remains useful without Pi or `pi-subagents`.
119
+ When Surf is installed as a Pi package, it exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, `options.model: gpt-5.6-sol`, and `options.effort: pro`. Surf remains useful without Pi or `pi-subagents`.
120
120
 
121
121
  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.
122
122