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 +3 -3
- package/agents/gpt-pro.md +2 -1
- package/native/chatgpt-client-selection.cjs +1 -1
- package/native/chatgpt-client-ui.cjs +20 -3
- package/native/oracle-host.cjs +6 -0
- package/native/oracle-jobs.cjs +51 -3
- package/package.json +1 -1
- package/pi-extension/surf.ts +111 -19
- package/skills/surf/SKILL.md +3 -3
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
|
|
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
|
|
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.
|
|
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
|
@@ -191,11 +191,22 @@ async function readPicker(cdp, kind, click = false) {
|
|
|
191
191
|
`(() => {
|
|
192
192
|
${buildClickDispatcher()}
|
|
193
193
|
const kind = ${JSON.stringify(kind)};
|
|
194
|
-
|
|
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
|
|
197
|
-
return value.includes('thinking') || value.includes('
|
|
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/native/oracle-host.cjs
CHANGED
|
@@ -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) {
|
package/native/oracle-jobs.cjs
CHANGED
|
@@ -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
package/pi-extension/surf.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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
|
|
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
|
|
263
|
-
if (typeof job.id !== "string" ||
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
647
|
+
const nextDispose = registerOptionalExternalJobProvider(oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
|
|
556
648
|
if (generation !== sessionGeneration) {
|
|
557
649
|
nextDispose();
|
|
558
650
|
return;
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -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 `--
|
|
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
|
|
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.
|
|
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
|
|