surf-cli 2.15.0 → 2.15.2

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.
@@ -327,8 +327,11 @@ function parseAiStudioGenerateContentText(rawText) {
327
327
  let parsed;
328
328
  try {
329
329
  parsed = JSON.parse(normalized);
330
- } catch (e) {
331
- throw new Error(`Invalid GenerateContent JSON (${normalized.length} chars): ${e.message}`);
330
+ } catch (error) {
331
+ const message = error instanceof Error ? error.message : String(error);
332
+ throw new Error(`Invalid GenerateContent JSON (${normalized.length} chars): ${message}`, {
333
+ cause: error,
334
+ });
332
335
  }
333
336
 
334
337
  const segments = [];
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -70,11 +70,12 @@
70
70
  "@types/node": "^26.1.2",
71
71
  "@vitest/coverage-v8": "^4.1.9",
72
72
  "@vitest/ui": "^4.1.9",
73
- "puppeteer": "25.5.0",
73
+ "pi-subagents": "^0.52.0",
74
+ "puppeteer": "25.6.0",
75
+ "typebox": "^1.3.11",
74
76
  "typescript": "^7.0.2",
75
77
  "vite": "^8.1.4",
76
- "vitest": "^4.1.9",
77
- "typebox": "^1.3.11"
78
+ "vitest": "^4.1.9"
78
79
  },
79
80
  "pi": {
80
81
  "extensions": [
@@ -1,5 +1,4 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash } from "node:crypto";
3
2
  import { Type } from "typebox";
4
3
 
5
4
  const require = createRequire(import.meta.url);
@@ -73,29 +72,31 @@ type BackgroundWorkRegistry = {
73
72
  };
74
73
 
75
74
  type OracleExternalJob = {
76
- provider: "surf-oracle";
77
75
  id: string;
78
76
  state: string;
79
77
  conversationUrl: string | null;
80
- requestedModel: string | null;
81
- verifiedModel: string | null;
82
- requestedEffort: string | null;
83
- verifiedEffort: string | null;
84
- promptDigest: string | null;
85
78
  resultText?: string;
86
- resultArtifact?: { kind: "inline-text"; bytes: number };
87
79
  failure?: { code: string; message: string };
88
80
  };
89
81
 
82
+ type PiExternalJobState = "queued" | "running" | "completed" | "failed";
83
+
84
+ type PiExternalJobHandle = {
85
+ providerJobId: string;
86
+ state: PiExternalJobState;
87
+ conversationUrl?: string;
88
+ failureCode?: string;
89
+ failureMessage?: string;
90
+ };
91
+
92
+ type PiExternalJobResult = PiExternalJobHandle & { output?: string };
93
+
90
94
  type OracleExternalJobProvider = {
91
95
  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>;
96
+ start(input: Record<string, unknown>): Promise<PiExternalJobHandle>;
97
+ status(id: string): Promise<PiExternalJobHandle>;
98
+ result(id: string): Promise<PiExternalJobResult>;
99
+ reattach(id: string): Promise<PiExternalJobHandle>;
99
100
  };
100
101
 
101
102
  type RegisterExternalJobProvider = (provider: OracleExternalJobProvider) => () => void;
@@ -251,15 +252,11 @@ export async function resolveExternalJobProviderRegister(
251
252
  return module.registerExternalJobProvider as RegisterExternalJobProvider;
252
253
  }
253
254
  } catch {
254
- // The provider bridge is optional until pi-subagents ships this consumer API.
255
+ // The Pi bridge is optional. Surf also runs in other coding-agent harnesses and as a direct CLI.
255
256
  }
256
257
  return registerGlobalExternalJobProvider;
257
258
  }
258
259
 
259
- function digestPrompt(prompt: string) {
260
- return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
261
- }
262
-
263
260
  function asOracleJob(value: unknown): OracleJob {
264
261
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Surf oracle response did not include job metadata");
265
262
  const job = value as Partial<OracleJob>;
@@ -272,22 +269,53 @@ function oracleExternalJob(job: OracleJob): OracleExternalJob {
272
269
  const failure = job.error
273
270
  ? { code: job.error.code || "failed", message: job.error.message || "Surf oracle job failed" }
274
271
  : undefined;
275
- const requestedModel = Object.hasOwn(job, "modelRequested") ? job.modelRequested ?? null : job.model ?? null;
276
272
  return {
277
- provider: "surf-oracle",
278
273
  id: job.id,
279
274
  state: job.state,
280
275
  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") } }),
276
+ ...(resultText === undefined ? {} : { resultText }),
287
277
  ...(failure ? { failure } : {}),
288
278
  };
289
279
  }
290
280
 
281
+ // pi-subagents' external-job contract rejects unknown fields, null values,
282
+ // untrimmed strings, and non-contract states, so map oracle payloads at this
283
+ // boundary instead of passing them through.
284
+ const PI_STATE_BY_ORACLE_STATE: Record<string, PiExternalJobState> = {
285
+ created: "queued",
286
+ dispatched: "running",
287
+ awaiting: "running",
288
+ captured: "completed",
289
+ failed: "failed",
290
+ };
291
+ const PI_MAX_FAILURE_CODE_CHARS = 128;
292
+ const PI_MAX_FAILURE_MESSAGE_CHARS = 4_096;
293
+ const PI_MAX_OUTPUT_CHARS = 1024 * 1024;
294
+
295
+ function piBounded(value: string, maxChars: number): string {
296
+ return value.slice(0, maxChars).trim();
297
+ }
298
+
299
+ export function piExternalJobHandle(job: OracleExternalJob): PiExternalJobHandle {
300
+ const state = PI_STATE_BY_ORACLE_STATE[job.state];
301
+ if (!state) throw new Error(`Surf oracle job ${job.id} reported unknown state '${job.state}'`);
302
+ const conversationUrl = job.conversationUrl ?? undefined;
303
+ const failureCode = job.failure ? piBounded(job.failure.code, PI_MAX_FAILURE_CODE_CHARS) : "";
304
+ const failureMessage = job.failure ? piBounded(job.failure.message, PI_MAX_FAILURE_MESSAGE_CHARS) : "";
305
+ return {
306
+ providerJobId: job.id,
307
+ state,
308
+ ...(conversationUrl ? { conversationUrl } : {}),
309
+ ...(failureCode ? { failureCode } : {}),
310
+ ...(failureMessage ? { failureMessage } : {}),
311
+ };
312
+ }
313
+
314
+ export function piExternalJobResult(job: OracleExternalJob): PiExternalJobResult {
315
+ const output = job.resultText === undefined ? "" : piBounded(job.resultText, PI_MAX_OUTPUT_CHARS);
316
+ return { ...piExternalJobHandle(job), ...(output ? { output } : {}) };
317
+ }
318
+
291
319
  async function requestOracleJob(request: typeof requestSurf, tool: string, args: Record<string, unknown>) {
292
320
  const result = await request(tool, args);
293
321
  if (result.isError) {
@@ -298,6 +326,7 @@ async function requestOracleJob(request: typeof requestSurf, tool: string, args:
298
326
  const error = new Error(message);
299
327
  if (typeof details?.code === "string") Object.assign(error, { code: details.code });
300
328
  if (typeof details?.jobId === "string") Object.assign(error, { jobId: details.jobId });
329
+ if (details?.code === "capacity" && typeof details?.jobId === "string") Object.assign(error, { blockingJobId: details.jobId });
301
330
  throw error;
302
331
  }
303
332
  return oracleExternalJob(asOracleJob(result.details));
@@ -330,8 +359,6 @@ export function createOracleExternalJobProvider(
330
359
  ): OracleExternalJobProvider {
331
360
  return {
332
361
  name: "surf-oracle",
333
- kind: "external-job",
334
- wakeChannels: [ORACLE_FINISHED_CHANNEL],
335
362
  async start(input) {
336
363
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
337
364
  if (!prompt.trim()) throw new Error("prompt required");
@@ -343,47 +370,34 @@ export function createOracleExternalJobProvider(
343
370
  ...(effort !== undefined ? { effort } : {}),
344
371
  });
345
372
  rememberJob(job.id);
346
- return { ...job, promptDigest: job.promptDigest ?? digestPrompt(prompt) };
373
+ return piExternalJobHandle(job);
347
374
  },
348
- status(id) {
349
- return requestOracleJob(request, "oracle.status", { id });
375
+ async status(id) {
376
+ return piExternalJobHandle(await requestOracleJob(request, "oracle.status", { id }));
350
377
  },
351
- result(id, input = {}) {
352
- return requestOracleJob(request, "oracle.result", { id, ...(typeof input.timeout === "number" ? { timeout: input.timeout } : {}) })
378
+ result(id) {
379
+ return requestOracleJob(request, "oracle.result", { id })
353
380
  .then((job) => {
354
381
  emitTerminal({ id: job.id, state: job.state });
355
- return job;
382
+ return piExternalJobResult(job);
356
383
  })
357
384
  .catch((error) => {
358
385
  emitFailedOracleJob(error, emitTerminal);
359
386
  throw error;
360
387
  });
361
388
  },
362
- reattach(id, input = {}) {
363
- return requestOracleJob(request, "oracle.result", { id, ...(typeof input.timeout === "number" ? { timeout: input.timeout } : {}) })
389
+ reattach(id) {
390
+ return requestOracleJob(request, "oracle.result", { id })
364
391
  .then((job) => {
365
392
  rememberJob(job.id);
366
393
  emitTerminal({ id: job.id, state: job.state });
367
- return job;
394
+ return piExternalJobHandle(job);
368
395
  })
369
396
  .catch((error) => {
370
397
  emitFailedOracleJob(error, emitTerminal);
371
398
  throw error;
372
399
  });
373
400
  },
374
- async follow(id, message, input = {}) {
375
- if (!message.trim()) throw new Error("message required");
376
- const model = oracleOption(input, "model");
377
- const effort = oracleOption(input, "effort");
378
- const job = await requestOracleJob(request, "oracle.ask", {
379
- follow: id,
380
- prompt: message,
381
- ...(model !== undefined ? { model } : {}),
382
- ...(effort !== undefined ? { effort } : {}),
383
- });
384
- rememberJob(job.id);
385
- return { ...job, promptDigest: job.promptDigest ?? digestPrompt(message) };
386
- },
387
401
  };
388
402
  }
389
403
 
@@ -40,7 +40,7 @@ Remote paths are client-local by default. `local:./file` is explicit client-loca
40
40
  ## CLI Quick Reference
41
41
 
42
42
  ```bash
43
- surf --help # Full help
43
+ surf --help # Basic help
44
44
  surf <group> # Group help (tab, scroll, page, wait, dialog, emulate, form, perf, ai)
45
45
  surf --help-full # All commands
46
46
  surf --find <term> # Search tools
@@ -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`, `reattach`, and `follow` to durable Surf Oracle jobs. It honors `options.model` and `options.effort` for starts and follows, so `model: pro` selects ChatGPT GPT-5.6 Sol Pro web mode through the browser. 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.
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