surf-cli 2.16.0 → 2.17.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,13 +526,13 @@ 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`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
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, one direct local attachment with `--file`, and verifies requested model and reasoning effort before submission. Add `--github` when the consult needs the ChatGPT Chat tab and connected GitHub tool. 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
- surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
532
+ surf oracle ask "review this change" --files "src/**/*.ts" --file ./design.md --model gpt-5.5 --effort pro --github --detach --json
533
533
  surf oracle status <job-id> --json
534
534
  surf oracle result <job-id> --wait --json
535
- surf oracle follow <job-id> "challenge that recommendation" --detach --json
535
+ surf oracle follow <job-id> "challenge that recommendation" --file ./follow-up.md --github --detach --json
536
536
  ```
537
537
 
538
538
  Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
@@ -996,7 +996,7 @@ 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: 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.
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`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, 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, while `github: true` requires Chat mode and the connected GitHub tool. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1000
1000
 
1001
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
 
@@ -50,12 +50,16 @@ function modelCandidateMatches(item, targetModel) {
50
50
  }
51
51
 
52
52
  function effortCandidateMatches(item, targetEffort) {
53
- const variants = new Set(
54
- [item?.label, item?.testId]
55
- .flatMap((value) => normalizedWords(value))
56
- .filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
53
+ const labelVariants = new Set(
54
+ normalizedWords(item?.label).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
57
55
  );
58
- return variants.size === 1 && variants.has(targetEffort);
56
+ if (labelVariants.size > 0) {
57
+ return labelVariants.size === 1 && labelVariants.has(targetEffort);
58
+ }
59
+ const testIdVariants = new Set(
60
+ normalizedWords(item?.testId).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
61
+ );
62
+ return testIdVariants.size === 1 && testIdVariants.has(targetEffort);
59
63
  }
60
64
 
61
65
  function uniqueMatch(items, matches) {
@@ -29,6 +29,8 @@ const SELECTORS = {
29
29
  effortMenuItem: 'button, [role="menuitem"], [role="menuitemradio"]',
30
30
  effortMenuLabel: '.__menu-label, [class*="menu-label"]',
31
31
  effortSubmenuTrigger: '[role="menuitem"][aria-haspopup="menu"], button[aria-haspopup="menu"]',
32
+ toolsButton:
33
+ 'button[data-testid="composer-plus-btn"], button[aria-label="Add files and more"]',
32
34
  selectedMenuIndicator:
33
35
  '[aria-checked="true"], [aria-selected="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
34
36
  assistantMessage:
@@ -170,6 +172,315 @@ async function waitForPromptReady(cdp, timeoutMs = 30000, signal) {
170
172
  return false;
171
173
  }
172
174
 
175
+ function uiError(code, message) {
176
+ const error = new Error(message);
177
+ error.code = code;
178
+ return error;
179
+ }
180
+
181
+ async function readChatTabState(cdp, signal) {
182
+ return evaluate(
183
+ cdp,
184
+ `(() => {
185
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
186
+ const visible = (node) => {
187
+ if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
188
+ const style = window.getComputedStyle?.(node);
189
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
190
+ const rect = node.getBoundingClientRect?.();
191
+ return !rect || (rect.width > 0 && rect.height > 0);
192
+ };
193
+ const selected = (node) => node.getAttribute?.('aria-selected') === 'true' ||
194
+ node.getAttribute?.('aria-current') === 'page' ||
195
+ node.getAttribute?.('data-state') === 'active' ||
196
+ node.getAttribute?.('data-state') === 'selected' ||
197
+ node.getAttribute?.('data-active') === 'true' ||
198
+ /\\b(active|selected)\\b/i.test(String(node.className || ''));
199
+ const labelFor = (node) => [
200
+ node.innerText || node.textContent || '',
201
+ node.getAttribute?.('aria-label') || '',
202
+ node.getAttribute?.('title') || '',
203
+ ].join(' ').replace(/\\s+/g, ' ').trim();
204
+ const matchesName = (node, name) => [
205
+ node.innerText || node.textContent || '',
206
+ node.getAttribute?.('aria-label') || '',
207
+ node.getAttribute?.('title') || '',
208
+ ].some((value) => {
209
+ const normalized = normalize(value);
210
+ return normalized === name || normalized === name + ' tab' || normalized === 'switch to ' + name;
211
+ });
212
+ const nodes = Array.from(new Set([
213
+ ...document.querySelectorAll('[role="tab"], button, a'),
214
+ ])).filter(visible);
215
+ const details = (node) => ({ label: labelFor(node).slice(0, 120), selected: selected(node) });
216
+ const matches = (name) => nodes
217
+ .filter((node) => matchesName(node, name))
218
+ .map(details);
219
+ return { chat: matches('chat'), work: matches('work') };
220
+ })()`,
221
+ signal,
222
+ );
223
+ }
224
+
225
+ async function selectChatTab(cdp, timeoutMs = 8000, signal) {
226
+ throwIfAborted(signal);
227
+ const read = () => readChatTabState(cdp, signal);
228
+ let state = await read();
229
+ if (state?.chat?.length === 1 && state.chat[0].selected) return state.chat[0].label;
230
+ if (state?.chat?.length === 0 && state?.work?.length > 0) {
231
+ throw uiError(
232
+ "chat_mode_unavailable",
233
+ "ChatGPT only exposes Work mode; the Chat tab is required for Oracle",
234
+ );
235
+ }
236
+ if (state?.chat?.length !== 1) {
237
+ throw uiError(
238
+ "chat_mode_selector_drift",
239
+ state?.chat?.length
240
+ ? "ChatGPT Chat tab selector drift: Chat tab is ambiguous"
241
+ : "ChatGPT Chat tab selector drift: Chat tab was not found",
242
+ );
243
+ }
244
+
245
+ const clicked = await evaluate(
246
+ cdp,
247
+ `(() => {
248
+ ${buildClickDispatcher()}
249
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
250
+ const matchesName = (node) => [
251
+ node.innerText || node.textContent || '',
252
+ node.getAttribute?.('aria-label') || '',
253
+ node.getAttribute?.('title') || '',
254
+ ].some((value) => {
255
+ const normalized = normalize(value);
256
+ return normalized === 'chat' || normalized === 'chat tab' || normalized === 'switch to chat';
257
+ });
258
+ const nodes = Array.from(new Set([
259
+ ...document.querySelectorAll('[role="tab"], button, a'),
260
+ ])).filter(matchesName);
261
+ return nodes.length === 1 ? dispatchClickSequence(nodes[0]) : false;
262
+ })()`,
263
+ signal,
264
+ );
265
+ if (!clicked) {
266
+ throw uiError("chat_mode_selector_drift", "ChatGPT Chat tab selector drift: Chat tab could not be clicked");
267
+ }
268
+
269
+ const deadline = Date.now() + timeoutMs;
270
+ while (Date.now() < deadline) {
271
+ await delay(100, signal);
272
+ state = await read();
273
+ if (state?.chat?.length === 1 && state.chat[0].selected) return state.chat[0].label;
274
+ if (state?.chat?.length === 0 && state?.work?.length > 0) {
275
+ throw uiError(
276
+ "chat_mode_unavailable",
277
+ "ChatGPT only exposes Work mode; the Chat tab is required for Oracle",
278
+ );
279
+ }
280
+ }
281
+ throw uiError("chat_mode_selection_failed", "ChatGPT Chat tab could not be selected before timeout");
282
+ }
283
+
284
+ async function readGitHubToolState(cdp, signal) {
285
+ return evaluate(
286
+ cdp,
287
+ `(() => {
288
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
289
+ const visible = (node) => {
290
+ if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
291
+ const style = window.getComputedStyle?.(node);
292
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
293
+ const rect = node.getBoundingClientRect?.();
294
+ return !rect || (rect.width > 0 && rect.height > 0);
295
+ };
296
+ const labelFor = (node) => [
297
+ node.innerText || node.textContent || '',
298
+ node.getAttribute?.('aria-label') || '',
299
+ node.getAttribute?.('title') || '',
300
+ node.getAttribute?.('data-testid') || '',
301
+ node.getAttribute?.('data-tool') || '',
302
+ ].join(' ').replace(/\\s+/g, ' ').trim();
303
+ const selected = (node, label) => node.getAttribute?.('aria-checked') === 'true' ||
304
+ node.getAttribute?.('aria-selected') === 'true' ||
305
+ node.getAttribute?.('aria-pressed') === 'true' ||
306
+ node.getAttribute?.('data-selected') === 'true' ||
307
+ node.getAttribute?.('data-active') === 'true' ||
308
+ ['checked', 'selected', 'on', 'active'].includes(normalize(node.getAttribute?.('data-state'))) ||
309
+ /\\b(active|selected)\\b/i.test(label);
310
+ const controls = Array.from(new Set([
311
+ ...document.querySelectorAll('[role="menuitem"], [role="option"], [role="button"], button, [data-testid*="github" i], [data-tool*="github" i], [aria-label*="github" i], [title*="github" i]'),
312
+ ])).filter(visible);
313
+ const github = controls.filter((node) => normalize(labelFor(node)).includes('github'));
314
+ return {
315
+ controls: github.map((node) => {
316
+ const label = labelFor(node).slice(0, 160);
317
+ return {
318
+ label,
319
+ role: node.getAttribute?.('role') || (node.tagName === 'BUTTON' ? 'button' : null),
320
+ selected: selected(node, label),
321
+ disconnected: /\\b(disconnected|not connected|connect|authorize|sign in)\\b/i.test(label),
322
+ testId: node.getAttribute?.('data-testid') || null,
323
+ };
324
+ }),
325
+ };
326
+ })()`,
327
+ signal,
328
+ );
329
+ }
330
+
331
+ async function openToolsMenu(cdp, signal) {
332
+ return evaluate(
333
+ cdp,
334
+ `(() => {
335
+ ${buildClickDispatcher()}
336
+ const selectors = ${JSON.stringify(SELECTORS.toolsButton.split(", "))};
337
+ const visible = (node) => {
338
+ if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
339
+ const style = window.getComputedStyle?.(node);
340
+ return !style || (style.display !== 'none' && style.visibility !== 'hidden');
341
+ };
342
+ for (const selector of selectors) {
343
+ const node = Array.from(document.querySelectorAll(selector)).find(visible);
344
+ if (node) return dispatchClickSequence(node);
345
+ }
346
+ return false;
347
+ })()`,
348
+ signal,
349
+ );
350
+ }
351
+
352
+ async function clickGitHubTool(cdp, signal) {
353
+ return evaluate(
354
+ cdp,
355
+ `(() => {
356
+ ${buildClickDispatcher()}
357
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
358
+ const labelFor = (node) => [
359
+ node.innerText || node.textContent || '',
360
+ node.getAttribute?.('aria-label') || '',
361
+ node.getAttribute?.('title') || '',
362
+ node.getAttribute?.('data-testid') || '',
363
+ node.getAttribute?.('data-tool') || '',
364
+ ].join(' ').replace(/\\s+/g, ' ').trim();
365
+ const controls = Array.from(new Set([
366
+ ...document.querySelectorAll('[role="menuitem"], [role="option"], [role="button"], button, [data-testid*="github" i], [data-tool*="github" i], [aria-label*="github" i], [title*="github" i]'),
367
+ ])).filter((node) => normalize(labelFor(node)).includes('github'));
368
+ const menuItems = controls.filter((node) => ['menuitem', 'option'].includes(node.getAttribute?.('role')));
369
+ const candidates = menuItems.length > 0 ? menuItems : controls;
370
+ return candidates.length === 1 ? dispatchClickSequence(candidates[0]) : false;
371
+ })()`,
372
+ signal,
373
+ );
374
+ }
375
+
376
+ async function selectGitHubTool(cdp, timeoutMs = 10000, signal) {
377
+ throwIfAborted(signal);
378
+ let state = await readGitHubToolState(cdp, signal);
379
+ const findConnected = () => state?.controls?.filter((control) => !control.disconnected) || [];
380
+ if (state?.controls?.some((control) => control.disconnected)) {
381
+ throw uiError(
382
+ "github_tool_disconnected",
383
+ "ChatGPT GitHub tool is missing or disconnected; connect GitHub before running Oracle with --github",
384
+ );
385
+ }
386
+ if (findConnected().length === 1 && state.controls[0].selected) return state.controls[0].label;
387
+ if (state?.controls?.length > 1) {
388
+ throw uiError("github_tool_selector_drift", "ChatGPT GitHub tool selector drift: GitHub tool is ambiguous");
389
+ }
390
+ if (state?.controls?.length === 0) {
391
+ const opened = await openToolsMenu(cdp, signal);
392
+ if (!opened) {
393
+ throw uiError("github_tool_selector_drift", "ChatGPT tools selector drift: tools menu could not be opened");
394
+ }
395
+ const menuDeadline = Date.now() + timeoutMs;
396
+ while (Date.now() < menuDeadline) {
397
+ await delay(100, signal);
398
+ state = await readGitHubToolState(cdp, signal);
399
+ if (state?.controls?.some((control) => control.disconnected)) {
400
+ throw uiError(
401
+ "github_tool_disconnected",
402
+ "ChatGPT GitHub tool is missing or disconnected; connect GitHub before running Oracle with --github",
403
+ );
404
+ }
405
+ if (state?.controls?.length > 0) break;
406
+ }
407
+ }
408
+ if (!state?.controls?.length) {
409
+ throw uiError("github_tool_missing", "ChatGPT GitHub tool is not available in the tools menu");
410
+ }
411
+ if (state.controls.length === 1 && state.controls[0].selected) return state.controls[0].label;
412
+ if (state.controls.length !== 1 || !(await clickGitHubTool(cdp, signal))) {
413
+ throw uiError("github_tool_selector_drift", "ChatGPT GitHub tool selector drift: GitHub tool could not be selected");
414
+ }
415
+ const deadline = Date.now() + timeoutMs;
416
+ while (Date.now() < deadline) {
417
+ await delay(100, signal);
418
+ state = await readGitHubToolState(cdp, signal);
419
+ if (state?.controls?.some((control) => control.disconnected)) {
420
+ throw uiError(
421
+ "github_tool_disconnected",
422
+ "ChatGPT GitHub tool became disconnected while selecting it",
423
+ );
424
+ }
425
+ if (state?.controls?.length === 1 && state.controls[0].selected) return state.controls[0].label;
426
+ }
427
+ throw uiError("github_tool_selection_failed", "ChatGPT GitHub tool could not be verified after selection");
428
+ }
429
+
430
+ async function waitForChatGPTAttachment(cdp, filePaths, timeoutMs = 30000, signal) {
431
+ const expectedNames = (Array.isArray(filePaths) ? filePaths : [filePaths])
432
+ .map((filePath) => String(filePath).split(/[\\/]/).pop().toLowerCase());
433
+ const deadline = Date.now() + timeoutMs;
434
+ while (Date.now() < deadline) {
435
+ const state = await evaluate(
436
+ cdp,
437
+ `(() => {
438
+ const scope = document.querySelector('form') || document.querySelector('[data-testid*="composer"]') || document;
439
+ const text = (scope.innerText || scope.textContent || '').replace(/\\s+/g, ' ').trim();
440
+ const inputs = Array.from(scope.querySelectorAll?.('input[type="file"]') || []);
441
+ const fileCount = inputs.reduce((count, input) => count + (input.files?.length || 0), 0);
442
+ const attachmentNodes = Array.from(scope.querySelectorAll?.('[data-testid*="attachment" i], [data-testid*="file" i], [aria-label*="remove attachment" i], [aria-label*="remove file" i]') || [])
443
+ .filter((node) => node.tagName !== 'INPUT' || node.type !== 'file');
444
+ const hasAttachmentNode = attachmentNodes.some((node) => {
445
+ const rect = node.getBoundingClientRect?.();
446
+ return !rect || (rect.width > 0 && rect.height > 0);
447
+ });
448
+ const attachmentLabels = attachmentNodes
449
+ .filter((node) => {
450
+ const rect = node.getBoundingClientRect?.();
451
+ return !rect || (rect.width > 0 && rect.height > 0);
452
+ })
453
+ .map((node) => [
454
+ node.innerText || node.textContent || '',
455
+ node.getAttribute?.('aria-label') || '',
456
+ node.getAttribute?.('title') || '',
457
+ ].join(' ').replace(/\\s+/g, ' ').trim().toLowerCase())
458
+ .filter(Boolean);
459
+ const processingError = /\\b(upload failed|failed to upload|couldn.t upload|unsupported file|file too large|processing failed|error processing)\\b/i.test(text);
460
+ return { fileCount, hasAttachmentNode, attachmentLabels, processingError, text: text.slice(-300) };
461
+ })()`,
462
+ signal,
463
+ );
464
+ if (state?.processingError) {
465
+ throw uiError(
466
+ "attachment_processing",
467
+ `ChatGPT attachment processing failed${state.text ? `: ${state.text}` : ""}`,
468
+ );
469
+ }
470
+ const attachmentLabels = Array.isArray(state?.attachmentLabels) ? state.attachmentLabels : [];
471
+ const hasExpectedName = expectedNames.every((expected) =>
472
+ attachmentLabels.some((label) => label.includes(expected)),
473
+ );
474
+ const hasPotentialFilename = attachmentLabels.some((label) => /(?:^|\\s)[\\w.-]+\\.[a-z0-9]{1,8}(?:$|\\s|[)\\]])/i.test(label));
475
+ if (state?.hasAttachmentNode && (hasExpectedName || !hasPotentialFilename)) return true;
476
+ await delay(250, signal);
477
+ }
478
+ throw uiError(
479
+ "attachment_processing",
480
+ `ChatGPT attachment processing did not complete for ${expectedNames.filter(Boolean).join(", ") || "the selected file"}`,
481
+ );
482
+ }
483
+
173
484
  function verificationError(kind, requested, items = [], invalid = false) {
174
485
  const safeRequested = String(requested || "").replace(/\s+/g, " ").trim().slice(0, 80);
175
486
  const available = boundedOptionLabels(items);
@@ -191,14 +502,33 @@ async function readPicker(cdp, kind, click = false) {
191
502
  `(() => {
192
503
  ${buildClickDispatcher()}
193
504
  const kind = ${JSON.stringify(kind)};
505
+ const effortChoices = new Set(${JSON.stringify(CHATGPT_EFFORT_CHOICES)});
506
+ const effortOwnerLabels = new Set([...effortChoices, 'thinking']);
507
+ const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
508
+ const labelFor = (node) => {
509
+ const labelledBy = String(node.getAttribute?.('aria-labelledby') || '')
510
+ .split(/\s+/)
511
+ .map((id) => document.getElementById(id)?.textContent || '')
512
+ .join(' ');
513
+ const text = (node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim();
514
+ const aria = (node.getAttribute?.('aria-label') || '').replace(/\s+/g, ' ').trim();
515
+ const title = (node.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim();
516
+ return [text, aria, labelledBy, title].filter(Boolean).join(' | ');
517
+ };
194
518
  let nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
195
- const value = ((node.getAttribute?.('aria-label') || '') + ' ' + (node.textContent || '')).toLowerCase();
196
- if (kind !== 'model') return value.includes('thinking') || value.includes('pro');
519
+ const value = normalize(labelFor(node));
520
+ if (kind !== 'model') {
521
+ const words = value.split(/\s+/).filter(Boolean);
522
+ const hasEffort = words.some((word) => effortOwnerLabels.has(word));
523
+ const looksLikeModel = node.getAttribute?.('data-testid') === 'model-switcher-dropdown-button' ||
524
+ value.includes('current model') || value.includes('gpt') || value.includes('instant');
525
+ return hasEffort && !looksLikeModel;
526
+ }
197
527
  return value.includes('gpt') || value.includes('thinking') || value.includes('instant');
198
528
  });
199
529
  if (kind === 'model' && nodes.length === 0) {
200
530
  nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
201
- const value = ((node.getAttribute?.('aria-label') || '') + ' ' + (node.textContent || '')).toLowerCase();
531
+ const value = normalize(labelFor(node));
202
532
  return value.includes('pro');
203
533
  });
204
534
  }
@@ -208,13 +538,19 @@ async function readPicker(cdp, kind, click = false) {
208
538
  );
209
539
  }
210
540
  const items = nodes.map((node) => {
211
- const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
541
+ const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
212
542
  const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
543
+ const labelledBy = String(node.getAttribute?.('aria-labelledby') || '')
544
+ .split(/\\s+/)
545
+ .map((id) => document.getElementById(id)?.textContent || '')
546
+ .join(' ')
547
+ .replace(/\\s+/g, ' ')
548
+ .trim();
213
549
  const title = (node.getAttribute?.('title') || '').replace(/\\s+/g, ' ').trim();
214
550
  return {
215
551
  role: node.getAttribute?.('role') || (node.tagName === 'BUTTON' ? 'button' : null),
216
- label: [text, aria, title].filter(Boolean).join(' | ').slice(0, 240),
217
- displayLabel: (text || aria || title).slice(0, 80),
552
+ label: [text, aria, labelledBy, title].filter(Boolean).join(' | ').slice(0, 240),
553
+ displayLabel: (text || aria || labelledBy || title).slice(0, 80),
218
554
  testId: node.getAttribute?.('data-testid') || null,
219
555
  };
220
556
  });
@@ -370,6 +706,10 @@ async function selectEffort(cdp, desiredEffort, timeoutMs = 8000, signal) {
370
706
  throwIfAborted(signal);
371
707
  const normalizedEffort = normalizeChatGPTEffortChoice(desiredEffort);
372
708
  if (!normalizedEffort) throw verificationError("effort", desiredEffort, [], true);
709
+ const current = await readPicker(cdp, "effort");
710
+ const currentVerified = verifyChatGPTEffortSelection(current?.items, normalizedEffort);
711
+ if (currentVerified) return currentVerified.displayLabel || currentVerified.label;
712
+
373
713
  const picker = await readPicker(cdp, "effort", true);
374
714
  if (picker?.items?.length !== 1) throw verificationError("effort", desiredEffort);
375
715
  await delay(300, signal);
@@ -407,6 +747,13 @@ async function typePrompt(cdp, inputCdp, prompt, signal) {
407
747
  const node = document.querySelector(selector);
408
748
  if (!node) continue;
409
749
  dispatchClickSequence(node);
750
+ if ('value' in node) {
751
+ node.value = '';
752
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
753
+ } else {
754
+ node.textContent = '';
755
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
756
+ }
410
757
  if (typeof node.focus === 'function') node.focus();
411
758
  const doc = node.ownerDocument;
412
759
  const selection = doc?.getSelection?.();
@@ -516,10 +863,13 @@ module.exports = {
516
863
  normalizeChatGPTModelChoice,
517
864
  resolveChatGPTEffortMenuOption,
518
865
  resolveChatGPTModelMenuOption,
866
+ selectChatTab,
519
867
  selectEffort,
868
+ selectGitHubTool,
520
869
  selectModel,
521
870
  verifyChatGPTEffortSelection,
522
871
  verifyChatGPTModelSelection,
872
+ waitForChatGPTAttachment,
523
873
  typePrompt,
524
874
  waitForPageLoad,
525
875
  waitForPromptReady,
@@ -11,10 +11,13 @@ const {
11
11
  resolveChatGPTEffortMenuOption,
12
12
  resolveChatGPTModelMenuOption,
13
13
  selectEffort,
14
+ selectChatTab,
15
+ selectGitHubTool,
14
16
  selectModel,
15
17
  typePrompt,
16
18
  verifyChatGPTEffortSelection,
17
19
  verifyChatGPTModelSelection,
20
+ waitForChatGPTAttachment,
18
21
  waitForPageLoad,
19
22
  waitForPromptReady,
20
23
  } = require("./chatgpt-client-ui.cjs");
@@ -32,6 +35,12 @@ const {
32
35
 
33
36
  const CHATGPT_URL = "https://chatgpt.com/";
34
37
  const RESPONSE_STARTED_AT = Symbol("responseStartedAt");
38
+ const ATTACHMENT_ERROR_CODES = new Set([
39
+ "attachment_chooser_interception",
40
+ "attachment_file_access",
41
+ "attachment_processing",
42
+ "attachment_selector_drift",
43
+ ]);
35
44
 
36
45
  function hasRequiredCookies(cookies) {
37
46
  if (!cookies || !Array.isArray(cookies)) return false;
@@ -93,6 +102,7 @@ async function dispatch(options) {
93
102
  cdpEvaluate,
94
103
  cdpCommand,
95
104
  uploadFile,
105
+ github = false,
96
106
  beforeSubmit,
97
107
  afterSubmit,
98
108
  startUrl,
@@ -141,6 +151,12 @@ async function dispatch(options) {
141
151
  throw codedError("ChatGPT login required", "auth");
142
152
  }
143
153
  log("Login verified");
154
+ if (github) {
155
+ await selectChatTab(cdp, 10000, signal);
156
+ log("Verified Chat tab");
157
+ await selectGitHubTool(cdp, 10000, signal);
158
+ log("Verified GitHub tool");
159
+ }
144
160
  const promptReady = await waitForPromptReady(cdp, 30000, signal);
145
161
  if (!promptReady) {
146
162
  throw new Error("Prompt textarea not ready");
@@ -152,31 +168,55 @@ async function dispatch(options) {
152
168
  modelVerified = await selectModel(cdp, model, 8000, signal);
153
169
  log(`Verified model: ${modelVerified}`);
154
170
  }
155
- if (effort) {
156
- effortVerified = await selectEffort(cdp, effort, 8000, signal);
157
- log(`Verified effort: ${effortVerified}`);
158
- }
159
171
  if (file) {
160
172
  if (!uploadFile) {
161
- throw new Error(
173
+ throw codedError(
162
174
  "ChatGPT file upload unavailable: native host did not provide upload callback",
175
+ "attachment_chooser_interception",
163
176
  );
164
177
  }
165
178
  const files = Array.isArray(file) ? file : [file];
166
179
  const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
167
180
  log(`Uploading ${absFiles.length} file(s) to ChatGPT...`);
168
- const uploadResult = await guardedUploadFile(tabId, absFiles);
181
+ let uploadResult;
182
+ try {
183
+ uploadResult = await guardedUploadFile(tabId, absFiles);
184
+ } catch (error) {
185
+ throw classifyError(
186
+ error,
187
+ "attachment_chooser_interception",
188
+ ["attachment_file_access", "attachment_processing", "attachment_selector_drift", "attachment_chooser_interception"],
189
+ );
190
+ }
169
191
  if (uploadResult?.error) {
170
- throw new Error(`ChatGPT file upload failed: ${uploadResult.error}`);
192
+ const uploadCode = ATTACHMENT_ERROR_CODES.has(uploadResult.errorCode)
193
+ ? uploadResult.errorCode
194
+ : "attachment_chooser_interception";
195
+ throw codedError(
196
+ `ChatGPT file upload failed: ${uploadResult.error}`,
197
+ uploadCode,
198
+ );
171
199
  }
172
200
  if (!uploadResult?.success) {
173
- throw new Error("ChatGPT file upload failed: upload did not report success");
201
+ throw codedError(
202
+ "ChatGPT attachment processing failed: upload did not report success",
203
+ "attachment_processing",
204
+ );
174
205
  }
175
206
  log("File uploaded, waiting for ChatGPT attachment processing...");
176
207
  await delay(1500, signal);
208
+ try {
209
+ await waitForChatGPTAttachment(cdp, absFiles, 30000, signal);
210
+ } catch (error) {
211
+ throw classifyError(error, "attachment_processing", ["attachment_processing"]);
212
+ }
177
213
  }
178
214
  await typePrompt(cdp, inputCdp, prompt, signal);
179
215
  log("Prompt typed");
216
+ if (effort) {
217
+ effortVerified = await selectEffort(cdp, effort, 8000, signal);
218
+ log(`Verified effort: ${effortVerified}`);
219
+ }
180
220
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
181
221
  if (beforeSubmit) await raceAbort(beforeSubmit, signal);
182
222
  await clickSend(cdp, inputCdp, signal);
@@ -201,6 +241,17 @@ async function dispatch(options) {
201
241
  throw classifyError(error, "dispatch_failed", [
202
242
  "auth",
203
243
  "cloudflare",
244
+ "attachment_chooser_interception",
245
+ "attachment_file_access",
246
+ "attachment_processing",
247
+ "attachment_selector_drift",
248
+ "chat_mode_selection_failed",
249
+ "chat_mode_selector_drift",
250
+ "chat_mode_unavailable",
251
+ "github_tool_disconnected",
252
+ "github_tool_missing",
253
+ "github_tool_selection_failed",
254
+ "github_tool_selector_drift",
204
255
  "model_verification_failed",
205
256
  ]);
206
257
  }
@@ -353,5 +404,8 @@ module.exports = {
353
404
  extractConversationUrl,
354
405
  verifyChatGPTEffortSelection,
355
406
  verifyChatGPTModelSelection,
407
+ selectChatTab,
408
+ selectGitHubTool,
409
+ waitForChatGPTAttachment,
356
410
  CHATGPT_URL,
357
411
  };
@@ -270,6 +270,11 @@ function validateLocalToolPaths(tool, args = {}) {
270
270
  ? args.files.map((value) => normalize("files", value))
271
271
  : String(args.files).split(",").map((value) => normalize("files", value.trim())).join(",");
272
272
  }
273
+ if (tool === "oracle.ask" && args.file !== undefined) {
274
+ normalized.file = Array.isArray(args.file)
275
+ ? args.file.map((value) => normalize("file", value))
276
+ : normalize("file", args.file);
277
+ }
273
278
  if (tool === "screenshot") {
274
279
  if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
275
280
  for (const field of ["savePath", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
@@ -1,16 +1,30 @@
1
1
  const { openClientTransport } = require("./client-transport.cjs");
2
2
  const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
3
3
  const { assembleContext } = require("./oracle-context.cjs");
4
+ const fs = require("fs");
5
+ const path = require("path");
4
6
 
5
7
  const RESULT_TIMEOUT_SECONDS = 20;
6
8
  const POLL_DELAYS_MS = [5000, 10000, 20000, 40000, 60000];
7
9
  const ORACLE_ERROR_CODES = new Set([
8
10
  "auth",
11
+ "attachment_chooser_interception",
12
+ "attachment_file_access",
13
+ "attachment_processing",
14
+ "attachment_selector_drift",
9
15
  "capacity",
16
+ "chat_mode_selection_failed",
17
+ "chat_mode_selector_drift",
18
+ "chat_mode_unavailable",
10
19
  "cloudflare",
11
20
  "context_incomplete",
12
21
  "dispatch_failed",
22
+ "github_tool_disconnected",
23
+ "github_tool_missing",
24
+ "github_tool_selection_failed",
25
+ "github_tool_selector_drift",
13
26
  "harvest_failed",
27
+ "invalid_request",
14
28
  "invalid_transition",
15
29
  "model_verification_failed",
16
30
  "not_found",
@@ -30,8 +44,10 @@ Commands:
30
44
 
31
45
  Ask/follow options:
32
46
  --files <glob> Add context files (repeatable)
47
+ --file <path> Attach one local file
33
48
  --model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
34
49
  --effort <effort> Select effort: light, standard, extended, heavy, pro
50
+ --github Require the ChatGPT Chat tab and GitHub tool
35
51
  --detach Return after dispatch
36
52
  --allow-sensitive Allow deny-listed context files
37
53
 
@@ -56,11 +72,12 @@ function requireOptionValue(argv, index, name) {
56
72
 
57
73
  function parseOptions(argv) {
58
74
  const positional = [];
59
- const options = { files: [] };
60
- const valueOptions = new Set(["files", "model", "effort"]);
75
+ const options = { files: [], file: [] };
76
+ const valueOptions = new Set(["files", "file", "model", "effort"]);
61
77
  const booleanOptions = new Set([
62
78
  "allow-sensitive",
63
79
  "detach",
80
+ "github",
64
81
  "json",
65
82
  "no-lock",
66
83
  "wait",
@@ -75,7 +92,7 @@ function parseOptions(argv) {
75
92
  const name = value.slice(2);
76
93
  if (valueOptions.has(name)) {
77
94
  const optionValue = requireOptionValue(argv, index, name);
78
- if (name === "files") options.files.push(optionValue);
95
+ if (name === "files" || name === "file") options[name].push(optionValue);
79
96
  else options[name] = optionValue;
80
97
  index += 1;
81
98
  } else if (booleanOptions.has(name)) {
@@ -94,14 +111,16 @@ function assertAllowedOptions(command, options) {
94
111
  "allow-sensitive",
95
112
  "detach",
96
113
  "effort",
114
+ "file",
97
115
  "files",
116
+ "github",
98
117
  "model",
99
118
  ]);
100
119
  const allowed = command === "ask" || command === "follow"
101
120
  ? ask
102
121
  : command === "result" ? new Set([...common, "wait"]) : common;
103
122
  for (const [name, value] of Object.entries(options)) {
104
- if (name === "files" && value.length === 0) continue;
123
+ if ((name === "files" || name === "file") && value.length === 0) continue;
105
124
  if (value !== undefined && !allowed.has(name)) {
106
125
  throw codedError("invalid_transition", `--${name} is not supported by oracle ${command}`);
107
126
  }
@@ -131,8 +150,12 @@ function parseOracleCommand(argv) {
131
150
  command,
132
151
  prompt,
133
152
  files: parsed.options.files,
153
+ ...(parsed.options.file.length > 0
154
+ ? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
155
+ : {}),
134
156
  model: parsed.options.model,
135
157
  effort: parsed.options.effort,
158
+ github: parsed.options.github === true,
136
159
  detach: parsed.options.detach === true,
137
160
  allowSensitive: parsed.options["allow-sensitive"] === true,
138
161
  json,
@@ -151,8 +174,12 @@ function parseOracleCommand(argv) {
151
174
  id,
152
175
  prompt,
153
176
  files: parsed.options.files,
177
+ ...(parsed.options.file.length > 0
178
+ ? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
179
+ : {}),
154
180
  model: parsed.options.model,
155
181
  effort: parsed.options.effort,
182
+ github: parsed.options.github === true,
156
183
  detach: parsed.options.detach === true,
157
184
  allowSensitive: parsed.options["allow-sensitive"] === true,
158
185
  json,
@@ -191,12 +218,44 @@ function composeAskRequest(spec, context) {
191
218
  prompt,
192
219
  ...(spec.model ? { model: spec.model } : {}),
193
220
  ...(spec.effort ? { effort: spec.effort } : {}),
221
+ ...(spec.file ? { file: spec.file } : {}),
222
+ ...(spec.github ? { github: true } : {}),
194
223
  ...(context ? { contextManifest: context.manifest } : {}),
195
224
  ...(context?.bundlePath ? { bundlePath: context.bundlePath } : {}),
196
225
  ...(spec.id ? { follow: spec.id } : {}),
197
226
  };
198
227
  }
199
228
 
229
+ async function resolveOracleAttachment(value, cwd = process.cwd()) {
230
+ const values = value === undefined || value === null
231
+ ? []
232
+ : Array.isArray(value) ? value : [value];
233
+ if (values.length > 1) {
234
+ throw codedError(
235
+ "attachment_file_access",
236
+ "Oracle supports one explicit local attachment; provide a single --file path",
237
+ );
238
+ }
239
+ if (values.length === 0) return undefined;
240
+ const requested = values[0];
241
+ if (typeof requested !== "string" || !requested.trim()) {
242
+ throw codedError("attachment_file_access", "Oracle attachment file access failed: --file must be a path");
243
+ }
244
+ const resolved = path.resolve(cwd, requested);
245
+ try {
246
+ const stats = await fs.promises.stat(resolved);
247
+ if (!stats.isFile()) throw new Error("not a regular file");
248
+ await fs.promises.access(resolved, fs.constants.R_OK);
249
+ } catch (error) {
250
+ throw codedError(
251
+ "attachment_file_access",
252
+ `Oracle attachment file access failed for ${resolved}: ${error?.message || error}`,
253
+ { path: resolved },
254
+ );
255
+ }
256
+ return resolved;
257
+ }
258
+
200
259
  function unwrapResponse(response) {
201
260
  if (response?.error) {
202
261
  const message = response.error.message
@@ -389,6 +448,7 @@ async function handleOracleCli(argv, {
389
448
  }
390
449
  }
391
450
 
451
+ const attachment = await resolveOracleAttachment(spec.file, cwd);
392
452
  const context = spec.files.length > 0
393
453
  ? await assembleContext({
394
454
  files: spec.files,
@@ -396,7 +456,7 @@ async function handleOracleCli(argv, {
396
456
  allowSensitive: spec.allowSensitive,
397
457
  })
398
458
  : null;
399
- const request = composeAskRequest(spec, context);
459
+ const request = composeAskRequest({ ...spec, ...(attachment ? { file: attachment } : {}) }, context);
400
460
  const dispatchInterrupt = () => {
401
461
  stderr.write(
402
462
  "Interrupted during dispatch. A job may already have been created. Run surf oracle status or surf oracle list to find it.\n",
@@ -430,5 +490,6 @@ module.exports = {
430
490
  formatOracleOutput,
431
491
  handleOracleCli,
432
492
  parseOracleCommand,
493
+ resolveOracleAttachment,
433
494
  shapeOracleError,
434
495
  };
@@ -1,3 +1,5 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
1
3
  const chatgptClient = require("./chatgpt-client.cjs");
2
4
  const oracleJobs = require("./oracle-jobs.cjs");
3
5
 
@@ -23,6 +25,39 @@ function withJobId(error, jobId, fallbackCode) {
23
25
  return result;
24
26
  }
25
27
 
28
+ async function resolveOracleAttachments(args) {
29
+ const explicit = args?.file === undefined || args?.file === null
30
+ ? []
31
+ : Array.isArray(args.file) ? args.file : [args.file];
32
+ if (explicit.length > 1) {
33
+ throw codedError(
34
+ "attachment_file_access",
35
+ "Oracle supports one explicit local attachment; provide a single --file path",
36
+ );
37
+ }
38
+ const requested = [...explicit, ...(args?.bundlePath ? [args.bundlePath] : [])];
39
+ const resolved = [];
40
+ for (const filePath of requested) {
41
+ if (typeof filePath !== "string" || !filePath.trim()) {
42
+ throw codedError("attachment_file_access", "Oracle attachment file access failed: file path is required");
43
+ }
44
+ const absolutePath = path.resolve(filePath);
45
+ try {
46
+ const stats = await fs.promises.stat(absolutePath);
47
+ if (!stats.isFile()) throw new Error("not a regular file");
48
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
49
+ } catch (error) {
50
+ throw codedError(
51
+ "attachment_file_access",
52
+ `Oracle attachment file access failed for ${absolutePath}: ${error?.message || error}`,
53
+ { path: absolutePath },
54
+ );
55
+ }
56
+ resolved.push(absolutePath);
57
+ }
58
+ return resolved;
59
+ }
60
+
26
61
  function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderUploadMessage, log }) {
27
62
  const closeTab = (request, tabId) => requestCallExtension(
28
63
  request,
@@ -65,6 +100,7 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
65
100
  async function ask(request, args) {
66
101
  assertLocalOracleRequest(request);
67
102
  const model = args.model ? chatgptClient.normalizeChatGPTModelChoice(args.model) : null;
103
+ const explicitAttachmentPaths = await resolveOracleAttachments({ ...args, bundlePath: undefined });
68
104
  const created = oracleJobs.createJob({
69
105
  prompt: args.prompt,
70
106
  contextManifest: args.contextManifest,
@@ -72,11 +108,16 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
72
108
  effortRequested: args.effort ?? null,
73
109
  follow: args.follow ?? null,
74
110
  requestId: args.requestId ?? null,
111
+ attachmentPaths: explicitAttachmentPaths,
112
+ github: args.github === true,
75
113
  });
76
114
  if (created.requestDeduped) return oracleJobs.getJob(created.id);
77
115
  let createdTabId = null;
78
116
 
79
117
  try {
118
+ const attachmentPaths = args?.bundlePath
119
+ ? [...explicitAttachmentPaths, ...(await resolveOracleAttachments({ bundlePath: args.bundlePath }))]
120
+ : explicitAttachmentPaths;
80
121
  let parent = null;
81
122
  if (args.follow) {
82
123
  parent = oracleJobs.getJob(args.follow);
@@ -93,7 +134,10 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
93
134
  prompt: args.prompt,
94
135
  model,
95
136
  effort: args.effort,
96
- file: args.bundlePath,
137
+ file: attachmentPaths.length > 0
138
+ ? attachmentPaths.length === 1 ? attachmentPaths[0] : attachmentPaths
139
+ : undefined,
140
+ ...(args.github === true ? { github: true } : {}),
97
141
  startUrl: parent?.conversationUrl,
98
142
  createTab: async () => {
99
143
  const tabInfo = await browserOptions(request).createTab();
@@ -304,4 +348,4 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
304
348
  };
305
349
  }
306
350
 
307
- module.exports = { assertLocalOracleRequest, createOracleHost };
351
+ module.exports = { assertLocalOracleRequest, createOracleHost, resolveOracleAttachments };
@@ -47,13 +47,15 @@ function stableJson(value) {
47
47
  return JSON.stringify(value) ?? "null";
48
48
  }
49
49
 
50
- function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow }) {
50
+ function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github }) {
51
51
  return promptDigest(stableJson({
52
52
  promptDigest: promptDigest(prompt),
53
53
  contextManifest: contextManifest ?? {},
54
54
  model: model ?? null,
55
55
  effortRequested: effortRequested ?? null,
56
56
  follow: follow ?? null,
57
+ attachmentPaths: attachmentPaths ?? [],
58
+ github: github === true,
57
59
  }));
58
60
  }
59
61
 
@@ -92,13 +94,13 @@ function readJobs(root = getPrivateStateRoot()) {
92
94
  .map((job) => hydrateJobMetadata(job, root));
93
95
  }
94
96
 
95
- function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null }) {
97
+ function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null, attachmentPaths = [], github = false }) {
96
98
  const root = getPrivateStateRoot();
97
99
  const base = oracleRoot(root);
98
100
  ensurePrivateDir(base, root);
99
101
  const safeRequestId = normalizedRequestId(requestId);
100
102
  const fingerprint = safeRequestId
101
- ? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow })
103
+ ? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github })
102
104
  : null;
103
105
  if (safeRequestId) {
104
106
  const existing = readJobs(root).find((job) => job.requestId === safeRequestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -71,7 +71,7 @@
71
71
  "@vitest/coverage-v8": "^4.1.9",
72
72
  "@vitest/ui": "^4.1.9",
73
73
  "pi-subagents": "^0.52.0",
74
- "puppeteer": "25.6.0",
74
+ "puppeteer": "25.8.0",
75
75
  "typebox": "^1.3.11",
76
76
  "typescript": "^7.0.2",
77
77
  "vite": "^8.1.4",
@@ -375,6 +375,27 @@ function oracleOption(input: Record<string, unknown>, key: "model" | "effort"):
375
375
  return typeof direct === "string" ? direct : undefined;
376
376
  }
377
377
 
378
+ function oracleAttachmentOption(input: Record<string, unknown>): string | string[] | undefined {
379
+ const options = input.options;
380
+ const optionRecord = options && typeof options === "object" && !Array.isArray(options)
381
+ ? options as Record<string, unknown>
382
+ : undefined;
383
+ const optionValue = optionRecord?.file;
384
+ const value = optionValue === undefined ? input.file : optionValue;
385
+ if (typeof value === "string") return value;
386
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value as string[];
387
+ return undefined;
388
+ }
389
+
390
+ function oracleBooleanOption(input: Record<string, unknown>, key: string): boolean {
391
+ const options = input.options;
392
+ const optionValue = options && typeof options === "object" && !Array.isArray(options)
393
+ ? (options as Record<string, unknown>)[key]
394
+ : undefined;
395
+ const value = optionValue === undefined ? input[key] : optionValue;
396
+ return value === true;
397
+ }
398
+
378
399
  function optionalString(input: Record<string, unknown>, key: string): string | undefined {
379
400
  const value = input[key];
380
401
  return typeof value === "string" && value.trim() ? value : undefined;
@@ -426,10 +447,14 @@ export function createOracleExternalJobProvider(
426
447
  if (!prompt.trim()) throw new Error("prompt required");
427
448
  const model = oracleOption(input, "model");
428
449
  const effort = oracleOption(input, "effort");
450
+ const file = oracleAttachmentOption(input);
451
+ const github = oracleBooleanOption(input, "github");
429
452
  const job = await requestOracleJob(request, "oracle.ask", {
430
453
  prompt,
431
454
  ...(model !== undefined ? { model } : {}),
432
455
  ...(effort !== undefined ? { effort } : {}),
456
+ ...(file !== undefined ? { file } : {}),
457
+ ...(github ? { github: true } : {}),
433
458
  });
434
459
  rememberJob(job.id);
435
460
  return piExternalJobHandle(job);
@@ -470,12 +495,16 @@ export function createOracleExternalJobProvider(
470
495
  const parentId = parentProviderJobId(input);
471
496
  const model = oracleOption(input, "model");
472
497
  const effort = oracleOption(input, "effort");
498
+ const file = oracleAttachmentOption(input);
499
+ const github = oracleBooleanOption(input, "github");
473
500
  const requestId = optionalString(input, "requestId");
474
501
  const job = await requestOracleJob(request, "oracle.ask", {
475
502
  prompt,
476
503
  follow: parentId,
477
504
  ...(model !== undefined ? { model } : {}),
478
505
  ...(effort !== undefined ? { effort } : {}),
506
+ ...(file !== undefined ? { file } : {}),
507
+ ...(github ? { github: true } : {}),
479
508
  ...(requestId !== undefined ? { requestId } : {}),
480
509
  });
481
510
  assertFollowJob(job, parentId);
@@ -590,8 +619,8 @@ export default function surfExtension(pi: Pi) {
590
619
  pi.registerTool({
591
620
  name: "surf_oracle_ask",
592
621
  label: "surf_oracle_ask",
593
- description: "Start a durable local Surf ChatGPT oracle job.",
594
- parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), follow: Type.Optional(Type.String()) }),
622
+ description: "Start a durable local Surf ChatGPT oracle job, optionally with one local file and GitHub context.",
623
+ parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), file: Type.Optional(Type.String()), github: Type.Optional(Type.Boolean()), follow: Type.Optional(Type.String()) }),
595
624
  async execute(_id: string, args: Record<string, unknown>) {
596
625
  const requestGeneration = sessionGeneration;
597
626
  try {
@@ -95,14 +95,14 @@ surf chatgpt "analyze" --file document.pdf # With file attachment
95
95
 
96
96
  ### Oracle
97
97
 
98
- Use `surf chatgpt` for quick one-shot questions. Use `surf oracle` for long-running or Pro coding consults that need a durable job, explicit model and effort selection, file context, recovery, or follow-up turns. Oracle is local-only.
98
+ Use `surf chatgpt` for quick one-shot questions. Use `surf oracle` for long-running or Pro coding consults that need a durable job, explicit model and effort selection, file context, a direct local attachment, recovery, or follow-up turns. Oracle is local-only.
99
99
 
100
100
  For agent workflows, detach after dispatch and keep the returned `.id`:
101
101
 
102
102
  ```bash
103
103
  surf oracle ask "Review this change and identify release risks" \
104
104
  --files "src/**/*.ts" --files "package.json" \
105
- --model gpt-5.5 --effort pro --detach --json
105
+ --model gpt-5.5 --effort pro --file ./design.md --github --detach --json
106
106
 
107
107
  surf oracle status <job-id> --json
108
108
  surf oracle result <job-id> --json
@@ -114,16 +114,16 @@ surf oracle result <job-id> --wait --json
114
114
 
115
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: 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.
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`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so `model: gpt-5.6-sol` plus `effort: pro` selects ChatGPT GPT-5.6 Sol with Pro effort through the browser, while `github: true` requires Chat mode and the connected GitHub tool. `reattach` only harvests an existing job by ID; it never submits the prompt again.
118
118
 
119
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
- 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.
121
+ Context comes from repeatable `--files` globs. Use `--file <path>` for one additional local attachment; `--github` requires Chat mode and a connected GitHub tool. 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
 
123
123
  Continue a captured consult with `follow`. Use the ID returned by each turn for the next turn:
124
124
 
125
125
  ```bash
126
- surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --detach --json
126
+ surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --file ./follow-up.md --github --detach --json
127
127
  surf oracle result <follow-job-id> --wait --json
128
128
  surf oracle follow <follow-job-id> "Give the final decision and concrete next steps." --detach --json
129
129
  ```