surf-cli 2.10.0 → 2.11.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 +13 -0
- package/native/chatgpt-client-response.cjs +336 -0
- package/native/chatgpt-client-selection.cjs +119 -0
- package/native/chatgpt-client-ui.cjs +481 -0
- package/native/chatgpt-client.cjs +252 -664
- package/native/cli.cjs +53 -0
- package/native/host-helpers.cjs +26 -1
- package/native/host-sessions.cjs +1 -0
- package/native/host.cjs +29 -5
- package/native/oracle-cli.cjs +434 -0
- package/native/oracle-context.cjs +311 -0
- package/native/oracle-host.cjs +301 -0
- package/native/oracle-jobs.cjs +253 -0
- package/native/workflow-definition.cjs +1 -0
- package/package.json +1 -1
- package/skills/surf/SKILL.md +31 -0
package/native/cli.cjs
CHANGED
|
@@ -19,6 +19,11 @@ const {
|
|
|
19
19
|
const { executeDoSteps } = require("./do-executor.cjs");
|
|
20
20
|
const { openClientTransport } = require("./client-transport.cjs");
|
|
21
21
|
const { version: VERSION } = require("../package.json");
|
|
22
|
+
const {
|
|
23
|
+
formatOracleError,
|
|
24
|
+
formatOracleOutput,
|
|
25
|
+
handleOracleCli,
|
|
26
|
+
} = require("./oracle-cli.cjs");
|
|
22
27
|
const { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser } = require("./playbook-cli.cjs");
|
|
23
28
|
|
|
24
29
|
const IS_WIN = process.platform === "win32";
|
|
@@ -73,6 +78,28 @@ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
|
|
|
73
78
|
});
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
async function runWithBrowserLock(lockOptions, endpoint, operation) {
|
|
82
|
+
let releaseBrowserLock = () => {};
|
|
83
|
+
if (!lockOptions.noLock) {
|
|
84
|
+
const lock = acquireBrowserLock(endpoint.key, SURF_TMP, {
|
|
85
|
+
timeoutMs: lockOptions.timeoutMs,
|
|
86
|
+
});
|
|
87
|
+
releaseBrowserLock = lock.release;
|
|
88
|
+
}
|
|
89
|
+
const release = () => {
|
|
90
|
+
const releaseCurrent = releaseBrowserLock;
|
|
91
|
+
releaseBrowserLock = () => {};
|
|
92
|
+
releaseCurrent();
|
|
93
|
+
};
|
|
94
|
+
process.once("exit", release);
|
|
95
|
+
try {
|
|
96
|
+
return await operation();
|
|
97
|
+
} finally {
|
|
98
|
+
process.removeListener("exit", release);
|
|
99
|
+
release();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
// Cross-platform image resize (macOS: sips, Linux: ImageMagick)
|
|
77
104
|
function resizeImage(filePath, maxSize) {
|
|
78
105
|
const platform = process.platform;
|
|
@@ -153,6 +180,28 @@ try {
|
|
|
153
180
|
process.exit(1);
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
if (args[0] === "oracle") {
|
|
184
|
+
handleOracleCli(args, {
|
|
185
|
+
endpoint,
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
withBrowserLock: (operation) => runWithBrowserLock(
|
|
188
|
+
parseBrowserLockOptions(args.includes("--no-lock")),
|
|
189
|
+
endpoint,
|
|
190
|
+
operation,
|
|
191
|
+
),
|
|
192
|
+
})
|
|
193
|
+
.then((result) => {
|
|
194
|
+
if (!result.handled) throw new Error("Oracle command was not handled");
|
|
195
|
+
if (result.value !== undefined) console.log(formatOracleOutput(result.value, result.json));
|
|
196
|
+
process.exit(0);
|
|
197
|
+
})
|
|
198
|
+
.catch((error) => {
|
|
199
|
+
console.error(formatOracleError(error, args.includes("--json")));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
156
205
|
if (["playbook", "pb", "use"].includes(args[0])) {
|
|
157
206
|
if (playbookCommandNeedsBrowser(args)) {
|
|
158
207
|
installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
|
|
@@ -1496,6 +1545,7 @@ Common Commands:
|
|
|
1496
1545
|
search <term> Search for text in page (alias: find)
|
|
1497
1546
|
window.new <url> Create isolated browser window
|
|
1498
1547
|
doctor Diagnose native host/socket setup
|
|
1548
|
+
oracle ask <prompt> Start a durable ChatGPT consult
|
|
1499
1549
|
wait <seconds> Wait N seconds
|
|
1500
1550
|
|
|
1501
1551
|
Quick Examples:
|
|
@@ -1555,6 +1605,9 @@ const showFullHelp = () => {
|
|
|
1555
1605
|
|
|
1556
1606
|
Usage: surf <command> [args] [options]
|
|
1557
1607
|
|
|
1608
|
+
Oracle:
|
|
1609
|
+
surf oracle <ask|status|result|follow|list>
|
|
1610
|
+
|
|
1558
1611
|
Playbooks:
|
|
1559
1612
|
surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>
|
|
1560
1613
|
surf use <playbook> <op> [--arg value]
|
package/native/host-helpers.cjs
CHANGED
|
@@ -13,6 +13,21 @@ function normalizeModelString(model) {
|
|
|
13
13
|
return String(model || "").trim().toLowerCase();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function formatToolError(error) {
|
|
17
|
+
const message = error instanceof Error
|
|
18
|
+
? error.message
|
|
19
|
+
: typeof error === "string"
|
|
20
|
+
? error
|
|
21
|
+
: error?.message || String(error);
|
|
22
|
+
const result = { content: [{ type: "text", text: message }] };
|
|
23
|
+
if (error && typeof error === "object") {
|
|
24
|
+
result.message = message;
|
|
25
|
+
if (typeof error.code === "string") result.code = error.code;
|
|
26
|
+
if (typeof error.jobId === "string") result.jobId = error.jobId;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
/**
|
|
17
32
|
* Format tool result content for MCP response
|
|
18
33
|
* @param {*} result - The result object from the extension
|
|
@@ -1082,6 +1097,16 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
1082
1097
|
case "history.search":
|
|
1083
1098
|
if (!a.query) throw new Error("query required");
|
|
1084
1099
|
return { type: "HISTORY_SEARCH", query: a.query, limit: a.limit !== undefined ? parseInt(a.limit, 10) : 20 };
|
|
1100
|
+
case "oracle.ask":
|
|
1101
|
+
if (!a.prompt) throw new Error("prompt required");
|
|
1102
|
+
return { ...a, type: "ORACLE_ASK" };
|
|
1103
|
+
case "oracle.status":
|
|
1104
|
+
return { ...a, type: "ORACLE_STATUS" };
|
|
1105
|
+
case "oracle.result":
|
|
1106
|
+
if (!a.id) throw new Error("id required");
|
|
1107
|
+
return { ...a, type: "ORACLE_RESULT" };
|
|
1108
|
+
case "oracle.list":
|
|
1109
|
+
return { type: "ORACLE_LIST" };
|
|
1085
1110
|
case "chatgpt":
|
|
1086
1111
|
if (!a.query) throw new Error("query required");
|
|
1087
1112
|
return {
|
|
@@ -1196,4 +1221,4 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
1196
1221
|
}
|
|
1197
1222
|
}
|
|
1198
1223
|
|
|
1199
|
-
module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, buildProviderUploadMessage };
|
|
1224
|
+
module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage };
|
package/native/host-sessions.cjs
CHANGED
package/native/host.cjs
CHANGED
|
@@ -14,7 +14,8 @@ const perplexityClient = require("./perplexity-client.cjs");
|
|
|
14
14
|
const grokClient = require("./grok-client.cjs");
|
|
15
15
|
const aistudioClient = require("./aistudio-client.cjs");
|
|
16
16
|
const aistudioBuild = require("./aistudio-build.cjs");
|
|
17
|
-
const { mapToolToMessage, mapComputerAction, formatToolContent, buildProviderUploadMessage } = require("./host-helpers.cjs");
|
|
17
|
+
const { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage } = require("./host-helpers.cjs");
|
|
18
|
+
const { createOracleHost } = require("./oracle-host.cjs");
|
|
18
19
|
|
|
19
20
|
const IS_WIN = process.platform === "win32";
|
|
20
21
|
const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
|
|
@@ -402,6 +403,15 @@ aiQueue = new BoundedAiQueue({
|
|
|
402
403
|
: handler(),
|
|
403
404
|
});
|
|
404
405
|
|
|
406
|
+
const oracleHost = createOracleHost({
|
|
407
|
+
queueAiRequest,
|
|
408
|
+
requestCallExtension,
|
|
409
|
+
buildProviderUploadMessage,
|
|
410
|
+
log,
|
|
411
|
+
});
|
|
412
|
+
const adoptedOracleJobs = oracleHost.adoptOrphans();
|
|
413
|
+
log(`Oracle adoption: ${adoptedOracleJobs.length} job(s); ids=${adoptedOracleJobs.map((job) => job.id).join(",") || "none"}`);
|
|
414
|
+
|
|
405
415
|
function sendSocket(socket, value, options = {}) {
|
|
406
416
|
const writer = socketWriters.get(socket);
|
|
407
417
|
return writer ? writer.send(value, options) : writeFrame(socket, value);
|
|
@@ -672,8 +682,14 @@ function sendToolResponse(socket, id, result, error) {
|
|
|
672
682
|
} catch (transferFailure) {
|
|
673
683
|
finalError = transferFailure.message;
|
|
674
684
|
}
|
|
675
|
-
|
|
676
|
-
|
|
685
|
+
const formattedError = finalError ? formatToolError(finalError) : null;
|
|
686
|
+
if (formattedError && request) {
|
|
687
|
+
const rewrittenMessage = rewriteTransferPaths(
|
|
688
|
+
formattedError.content[0].text,
|
|
689
|
+
request.pathRewrites || [],
|
|
690
|
+
);
|
|
691
|
+
formattedError.content[0].text = rewrittenMessage;
|
|
692
|
+
if (formattedError.message) formattedError.message = rewrittenMessage;
|
|
677
693
|
}
|
|
678
694
|
if (request?.tool && !request.tool.startsWith("playbook.")) {
|
|
679
695
|
const metadata = commandMetadata(request.tool);
|
|
@@ -697,7 +713,7 @@ function sendToolResponse(socket, id, result, error) {
|
|
|
697
713
|
: finalError ? "error" : "completed";
|
|
698
714
|
await completeOwnedRequest(context, id, outcome);
|
|
699
715
|
const response = { type: "tool_response", id };
|
|
700
|
-
if (
|
|
716
|
+
if (formattedError) response.error = formattedError;
|
|
701
717
|
else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
|
|
702
718
|
if (!context?.closed) await sendSocket(socket, response);
|
|
703
719
|
})().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
|
|
@@ -800,6 +816,13 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
|
|
|
800
816
|
.catch((error) => sendToolResponse(socket, originalId, null, error.message));
|
|
801
817
|
return;
|
|
802
818
|
}
|
|
819
|
+
|
|
820
|
+
if (extensionMsg.type.startsWith("ORACLE_")) {
|
|
821
|
+
Promise.resolve(oracleHost.handle(requestContext, extensionMsg))
|
|
822
|
+
.then((result) => sendToolResponse(socket, originalId, result, null))
|
|
823
|
+
.catch((error) => sendToolResponse(socket, originalId, null, error));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
803
826
|
|
|
804
827
|
if (extensionMsg.type === "BATCH_EXECUTE") {
|
|
805
828
|
executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId, requestContext);
|
|
@@ -1948,6 +1971,7 @@ const handleClient = (socket) => {
|
|
|
1948
1971
|
}
|
|
1949
1972
|
log(`Handling tool_request: ${msg.method} ${tool}${principal ? ` for ${principal.label}` : ""}`);
|
|
1950
1973
|
try {
|
|
1974
|
+
if (tool.startsWith("oracle.")) oracleHost.assertLocal(request);
|
|
1951
1975
|
if (isRemote) {
|
|
1952
1976
|
await applyRequestTransfers(msg, request, transferState, ensureTransferState);
|
|
1953
1977
|
}
|
|
@@ -1955,7 +1979,7 @@ const handleClient = (socket) => {
|
|
|
1955
1979
|
requestStorage.run(request, () => handleToolRequest(msg, socket, request));
|
|
1956
1980
|
} catch (e) {
|
|
1957
1981
|
await discardRequestTransfers(msg, transferState);
|
|
1958
|
-
sendToolResponse(socket, msg.id || null, null, e.message || "Request failed");
|
|
1982
|
+
sendToolResponse(socket, msg.id || null, null, e?.code ? e : e.message || "Request failed");
|
|
1959
1983
|
}
|
|
1960
1984
|
return;
|
|
1961
1985
|
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
const { openClientTransport } = require("./client-transport.cjs");
|
|
2
|
+
const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
|
|
3
|
+
const { assembleContext } = require("./oracle-context.cjs");
|
|
4
|
+
|
|
5
|
+
const RESULT_TIMEOUT_SECONDS = 20;
|
|
6
|
+
const POLL_DELAYS_MS = [5000, 10000, 20000, 40000, 60000];
|
|
7
|
+
const ORACLE_ERROR_CODES = new Set([
|
|
8
|
+
"auth",
|
|
9
|
+
"capacity",
|
|
10
|
+
"cloudflare",
|
|
11
|
+
"context_incomplete",
|
|
12
|
+
"dispatch_failed",
|
|
13
|
+
"harvest_failed",
|
|
14
|
+
"invalid_transition",
|
|
15
|
+
"model_verification_failed",
|
|
16
|
+
"not_found",
|
|
17
|
+
"rate_limit",
|
|
18
|
+
"remote_unsupported",
|
|
19
|
+
"sensitive_blocked",
|
|
20
|
+
"timeout",
|
|
21
|
+
]);
|
|
22
|
+
const HELP = `Usage: surf oracle <ask|status|result|follow|list>
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
ask <prompt> Start a consult and wait for its response
|
|
26
|
+
follow <id> <prompt> Continue a captured consult
|
|
27
|
+
status [id] Show a job (newest when id is omitted)
|
|
28
|
+
result <id> [--wait] Try to capture a result, optionally keep waiting
|
|
29
|
+
list List jobs newest-first
|
|
30
|
+
|
|
31
|
+
Ask/follow options:
|
|
32
|
+
--files <glob> Add context files (repeatable)
|
|
33
|
+
--model <model> Select a ChatGPT model
|
|
34
|
+
--effort <effort> Select reasoning effort
|
|
35
|
+
--detach Return after dispatch
|
|
36
|
+
--allow-sensitive Allow deny-listed context files
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--json Output machine-readable JSON
|
|
40
|
+
--no-lock Bypass the browser request lock`;
|
|
41
|
+
|
|
42
|
+
function codedError(code, message, details = {}) {
|
|
43
|
+
const error = new Error(message);
|
|
44
|
+
error.code = code;
|
|
45
|
+
Object.assign(error, details);
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireOptionValue(argv, index, name) {
|
|
50
|
+
const value = argv[index + 1];
|
|
51
|
+
if (!value || value.startsWith("--")) {
|
|
52
|
+
throw codedError("invalid_transition", `--${name} requires a value`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseOptions(argv) {
|
|
58
|
+
const positional = [];
|
|
59
|
+
const options = { files: [] };
|
|
60
|
+
const valueOptions = new Set(["files", "model", "effort"]);
|
|
61
|
+
const booleanOptions = new Set([
|
|
62
|
+
"allow-sensitive",
|
|
63
|
+
"detach",
|
|
64
|
+
"json",
|
|
65
|
+
"no-lock",
|
|
66
|
+
"wait",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
70
|
+
const value = argv[index];
|
|
71
|
+
if (!value.startsWith("--")) {
|
|
72
|
+
positional.push(value);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const name = value.slice(2);
|
|
76
|
+
if (valueOptions.has(name)) {
|
|
77
|
+
const optionValue = requireOptionValue(argv, index, name);
|
|
78
|
+
if (name === "files") options.files.push(optionValue);
|
|
79
|
+
else options[name] = optionValue;
|
|
80
|
+
index += 1;
|
|
81
|
+
} else if (booleanOptions.has(name)) {
|
|
82
|
+
options[name] = true;
|
|
83
|
+
} else {
|
|
84
|
+
throw codedError("invalid_transition", `Unknown oracle option: --${name}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { positional, options };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertAllowedOptions(command, options) {
|
|
91
|
+
const common = new Set(["json", "no-lock"]);
|
|
92
|
+
const ask = new Set([
|
|
93
|
+
...common,
|
|
94
|
+
"allow-sensitive",
|
|
95
|
+
"detach",
|
|
96
|
+
"effort",
|
|
97
|
+
"files",
|
|
98
|
+
"model",
|
|
99
|
+
]);
|
|
100
|
+
const allowed = command === "ask" || command === "follow"
|
|
101
|
+
? ask
|
|
102
|
+
: command === "result" ? new Set([...common, "wait"]) : common;
|
|
103
|
+
for (const [name, value] of Object.entries(options)) {
|
|
104
|
+
if (name === "files" && value.length === 0) continue;
|
|
105
|
+
if (value !== undefined && !allowed.has(name)) {
|
|
106
|
+
throw codedError("invalid_transition", `--${name} is not supported by oracle ${command}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseOracleCommand(argv) {
|
|
112
|
+
if (argv[0] !== "oracle") return { handled: false };
|
|
113
|
+
const command = argv[1];
|
|
114
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
115
|
+
return { handled: true, command: "help", json: false };
|
|
116
|
+
}
|
|
117
|
+
if (!["ask", "follow", "status", "result", "list"].includes(command)) {
|
|
118
|
+
throw codedError("invalid_transition", `Unknown oracle command: ${command}`);
|
|
119
|
+
}
|
|
120
|
+
const parsed = parseOptions(argv.slice(2));
|
|
121
|
+
assertAllowedOptions(command, parsed.options);
|
|
122
|
+
const json = parsed.options.json === true;
|
|
123
|
+
|
|
124
|
+
if (command === "ask") {
|
|
125
|
+
const prompt = parsed.positional.join(" ");
|
|
126
|
+
if (!prompt.trim()) {
|
|
127
|
+
throw codedError("dispatch_failed", "Usage: surf oracle ask <prompt> [options]");
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
handled: true,
|
|
131
|
+
command,
|
|
132
|
+
prompt,
|
|
133
|
+
files: parsed.options.files,
|
|
134
|
+
model: parsed.options.model,
|
|
135
|
+
effort: parsed.options.effort,
|
|
136
|
+
detach: parsed.options.detach === true,
|
|
137
|
+
allowSensitive: parsed.options["allow-sensitive"] === true,
|
|
138
|
+
json,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (command === "follow") {
|
|
143
|
+
const id = parsed.positional[0];
|
|
144
|
+
const prompt = parsed.positional.slice(1).join(" ");
|
|
145
|
+
if (!id?.trim() || !prompt.trim()) {
|
|
146
|
+
throw codedError("dispatch_failed", "Usage: surf oracle follow <id> <prompt> [options]");
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
handled: true,
|
|
150
|
+
command,
|
|
151
|
+
id,
|
|
152
|
+
prompt,
|
|
153
|
+
files: parsed.options.files,
|
|
154
|
+
model: parsed.options.model,
|
|
155
|
+
effort: parsed.options.effort,
|
|
156
|
+
detach: parsed.options.detach === true,
|
|
157
|
+
allowSensitive: parsed.options["allow-sensitive"] === true,
|
|
158
|
+
json,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (command === "status") {
|
|
163
|
+
if (parsed.positional.length > 1 || parsed.positional[0] === "") {
|
|
164
|
+
throw codedError("not_found", "Usage: surf oracle status [id]");
|
|
165
|
+
}
|
|
166
|
+
return { handled: true, command, id: parsed.positional[0], json };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (command === "result") {
|
|
170
|
+
if (parsed.positional.length !== 1 || !parsed.positional[0].trim()) {
|
|
171
|
+
throw codedError("not_found", "Usage: surf oracle result <id> [--wait]");
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
handled: true,
|
|
175
|
+
command,
|
|
176
|
+
id: parsed.positional[0],
|
|
177
|
+
wait: parsed.options.wait === true,
|
|
178
|
+
json,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (parsed.positional.length > 0) {
|
|
183
|
+
throw codedError("invalid_transition", "Usage: surf oracle list");
|
|
184
|
+
}
|
|
185
|
+
return { handled: true, command, json };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function composeAskRequest(spec, context) {
|
|
189
|
+
const prompt = context ? `${spec.prompt}\n\n${context.envelope}` : spec.prompt;
|
|
190
|
+
return {
|
|
191
|
+
prompt,
|
|
192
|
+
...(spec.model ? { model: spec.model } : {}),
|
|
193
|
+
...(spec.effort ? { effort: spec.effort } : {}),
|
|
194
|
+
...(context ? { contextManifest: context.manifest } : {}),
|
|
195
|
+
...(context?.bundlePath ? { bundlePath: context.bundlePath } : {}),
|
|
196
|
+
...(spec.id ? { follow: spec.id } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function unwrapResponse(response) {
|
|
201
|
+
if (response?.error) {
|
|
202
|
+
const message = response.error.message
|
|
203
|
+
|| response.error.content?.[0]?.text
|
|
204
|
+
|| JSON.stringify(response.error);
|
|
205
|
+
throw codedError(response.error.code || "timeout", message, {
|
|
206
|
+
...(response.error.jobId ? { jobId: response.error.jobId } : {}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const text = response?.result?.content?.[0]?.text;
|
|
210
|
+
if (text === undefined) return response?.result;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(text);
|
|
213
|
+
} catch {
|
|
214
|
+
return text;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function requestHost(endpoint, tool, args, withBrowserLock) {
|
|
219
|
+
const execute = async () => {
|
|
220
|
+
const timeoutMs = resolveRequestDeadlineMs(tool, args);
|
|
221
|
+
const transport = await openClientTransport(endpoint, { requestTimeoutMs: timeoutMs });
|
|
222
|
+
try {
|
|
223
|
+
const request = {
|
|
224
|
+
type: "tool_request",
|
|
225
|
+
method: "execute_tool",
|
|
226
|
+
params: { tool, args },
|
|
227
|
+
id: `oracle-${Date.now()}-${Math.random()}`,
|
|
228
|
+
};
|
|
229
|
+
return unwrapResponse(await transport.request(request, timeoutMs));
|
|
230
|
+
} finally {
|
|
231
|
+
await transport.close();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
return withBrowserLock ? withBrowserLock(execute) : execute();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function recoveryHint(id) {
|
|
238
|
+
return `Recover with: surf oracle result ${id}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function classifyError(error, fallbackCode) {
|
|
242
|
+
if (!ORACLE_ERROR_CODES.has(error?.code)) error.code = fallbackCode;
|
|
243
|
+
return error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function shapeOracleError(error, fallbackCode = "timeout") {
|
|
247
|
+
const jobId = typeof error?.jobId === "string" ? error.jobId : undefined;
|
|
248
|
+
let message = error?.message || String(error);
|
|
249
|
+
if (error?.recoverable && jobId && !message.includes("Recover with:")) {
|
|
250
|
+
message = `${message}\n${recoveryHint(jobId)}`;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
error: {
|
|
254
|
+
code: ORACLE_ERROR_CODES.has(error?.code) ? error.code : fallbackCode,
|
|
255
|
+
message,
|
|
256
|
+
},
|
|
257
|
+
...(jobId ? { jobId } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatOracleError(error, json = false) {
|
|
262
|
+
const shaped = shapeOracleError(error);
|
|
263
|
+
if (json) return JSON.stringify(shaped, null, 2);
|
|
264
|
+
return `Error: ${shaped.error.message}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatOracleOutput(value, json = false) {
|
|
268
|
+
if (json) return JSON.stringify(value, null, 2);
|
|
269
|
+
if (typeof value === "string") return value;
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
if (value.length === 0) return "No oracle jobs.";
|
|
272
|
+
return value.map((job) => `${job.id}\t${job.state}`).join("\n");
|
|
273
|
+
}
|
|
274
|
+
const lines = [value.id, value.state];
|
|
275
|
+
if (value.response !== undefined) lines.push(value.response);
|
|
276
|
+
return lines.filter((line) => line !== undefined).join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function waitForResult(job, spec, io) {
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
const interrupt = () => {
|
|
282
|
+
const message = `Interrupted. ${recoveryHint(job.id)}`;
|
|
283
|
+
if (spec.json) {
|
|
284
|
+
io.stderr.write(`${JSON.stringify({
|
|
285
|
+
error: { code: "timeout", message },
|
|
286
|
+
jobId: job.id,
|
|
287
|
+
}, null, 2)}\n`);
|
|
288
|
+
} else {
|
|
289
|
+
io.stderr.write(`${recoveryHint(job.id)}\n`);
|
|
290
|
+
}
|
|
291
|
+
process.exit(130);
|
|
292
|
+
};
|
|
293
|
+
process.once("SIGINT", interrupt);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
let current = job;
|
|
297
|
+
let pollIndex = 0;
|
|
298
|
+
while (current.state !== "captured") {
|
|
299
|
+
try {
|
|
300
|
+
current = await requestHost(
|
|
301
|
+
io.endpoint,
|
|
302
|
+
"oracle.result",
|
|
303
|
+
{
|
|
304
|
+
id: current.id,
|
|
305
|
+
timeout: RESULT_TIMEOUT_SECONDS,
|
|
306
|
+
},
|
|
307
|
+
io.withBrowserLock,
|
|
308
|
+
);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
error.jobId ||= current.id;
|
|
311
|
+
error.recoverable = true;
|
|
312
|
+
throw classifyError(error, "timeout");
|
|
313
|
+
}
|
|
314
|
+
if (!spec.json && io.stderr.isTTY) {
|
|
315
|
+
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
316
|
+
io.stderr.write(`[${elapsedSeconds}s] ${current.state}\n`);
|
|
317
|
+
}
|
|
318
|
+
if (current.state === "failed") {
|
|
319
|
+
throw codedError(
|
|
320
|
+
current.error?.code || "harvest_failed",
|
|
321
|
+
current.error?.message || `oracle job ${current.id} failed`,
|
|
322
|
+
{ jobId: current.id },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (current.state !== "captured") {
|
|
326
|
+
const delayMs = POLL_DELAYS_MS[Math.min(pollIndex, POLL_DELAYS_MS.length - 1)];
|
|
327
|
+
pollIndex += 1;
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return current;
|
|
332
|
+
} finally {
|
|
333
|
+
process.removeListener("SIGINT", interrupt);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function handleOracleCli(argv, {
|
|
338
|
+
endpoint,
|
|
339
|
+
cwd = process.cwd(),
|
|
340
|
+
stderr = process.stderr,
|
|
341
|
+
withBrowserLock,
|
|
342
|
+
} = {}) {
|
|
343
|
+
const spec = parseOracleCommand(argv);
|
|
344
|
+
if (!spec.handled) return spec;
|
|
345
|
+
if (spec.command === "help") return { handled: true, value: HELP, json: false };
|
|
346
|
+
if (endpoint?.kind === "remote") {
|
|
347
|
+
throw codedError(
|
|
348
|
+
"remote_unsupported",
|
|
349
|
+
"oracle commands are not supported with remote endpoints",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const io = { endpoint, stderr, withBrowserLock };
|
|
354
|
+
if (spec.command === "status") {
|
|
355
|
+
try {
|
|
356
|
+
const value = await requestHost(endpoint, "oracle.status", spec.id ? { id: spec.id } : {});
|
|
357
|
+
return { handled: true, value, json: spec.json };
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw classifyError(error, "timeout");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (spec.command === "list") {
|
|
363
|
+
try {
|
|
364
|
+
const value = await requestHost(endpoint, "oracle.list", {});
|
|
365
|
+
return { handled: true, value, json: spec.json };
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw classifyError(error, "timeout");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (spec.command === "result") {
|
|
371
|
+
if (spec.wait) {
|
|
372
|
+
const value = await waitForResult({ id: spec.id, state: "created" }, spec, io);
|
|
373
|
+
return { handled: true, value, json: spec.json };
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const value = await requestHost(
|
|
377
|
+
endpoint,
|
|
378
|
+
"oracle.result",
|
|
379
|
+
{
|
|
380
|
+
id: spec.id,
|
|
381
|
+
timeout: RESULT_TIMEOUT_SECONDS,
|
|
382
|
+
},
|
|
383
|
+
withBrowserLock,
|
|
384
|
+
);
|
|
385
|
+
return { handled: true, value, json: spec.json };
|
|
386
|
+
} catch (error) {
|
|
387
|
+
error.jobId ||= spec.id;
|
|
388
|
+
throw classifyError(error, "timeout");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const context = spec.files.length > 0
|
|
393
|
+
? await assembleContext({
|
|
394
|
+
files: spec.files,
|
|
395
|
+
cwd,
|
|
396
|
+
allowSensitive: spec.allowSensitive,
|
|
397
|
+
})
|
|
398
|
+
: null;
|
|
399
|
+
const request = composeAskRequest(spec, context);
|
|
400
|
+
const dispatchInterrupt = () => {
|
|
401
|
+
stderr.write(
|
|
402
|
+
"Interrupted during dispatch. A job may already have been created. Run surf oracle status or surf oracle list to find it.\n",
|
|
403
|
+
);
|
|
404
|
+
process.exit(130);
|
|
405
|
+
};
|
|
406
|
+
process.once("SIGINT", dispatchInterrupt);
|
|
407
|
+
let value;
|
|
408
|
+
try {
|
|
409
|
+
value = await requestHost(endpoint, "oracle.ask", request, withBrowserLock);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
throw classifyError(error, "dispatch_failed");
|
|
412
|
+
} finally {
|
|
413
|
+
process.removeListener("SIGINT", dispatchInterrupt);
|
|
414
|
+
}
|
|
415
|
+
if (spec.detach || value.state === "captured") {
|
|
416
|
+
if (spec.detach && value.state === "dispatched") {
|
|
417
|
+
stderr.write(
|
|
418
|
+
`Warning: the durable conversation URL is not yet captured. Run surf oracle result ${value.id} promptly.\n`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return { handled: true, value, json: spec.json };
|
|
422
|
+
}
|
|
423
|
+
value = await waitForResult(value, spec, io);
|
|
424
|
+
return { handled: true, value, json: spec.json };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
composeAskRequest,
|
|
429
|
+
formatOracleError,
|
|
430
|
+
formatOracleOutput,
|
|
431
|
+
handleOracleCli,
|
|
432
|
+
parseOracleCommand,
|
|
433
|
+
shapeOracleError,
|
|
434
|
+
};
|