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.
@@ -0,0 +1,253 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const {
5
+ assertNotSymlink,
6
+ atomicWriteFile,
7
+ atomicWriteJson,
8
+ ensurePrivateDir,
9
+ getPrivateStateRoot,
10
+ readPrivateFile,
11
+ readPrivateJson,
12
+ } = require("./private-state.cjs");
13
+
14
+ const JOB_ID_PATTERN = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
15
+ const TERMINAL_STATES = new Set(["captured", "failed"]);
16
+ const TRANSITIONS = {
17
+ created: new Set(["dispatched", "failed"]),
18
+ dispatched: new Set(["awaiting", "failed"]),
19
+ awaiting: new Set(["captured", "failed"]),
20
+ };
21
+
22
+ function oracleRoot(root = getPrivateStateRoot()) {
23
+ return path.join(root, "oracle");
24
+ }
25
+
26
+ function jobDirectory(id, root = getPrivateStateRoot()) {
27
+ if (!JOB_ID_PATTERN.test(id)) throw codedError("not_found", `oracle job not found: ${id}`);
28
+ return path.join(oracleRoot(root), id);
29
+ }
30
+
31
+ function codedError(code, message, details = {}) {
32
+ const error = new Error(message);
33
+ error.code = code;
34
+ Object.assign(error, details);
35
+ return error;
36
+ }
37
+
38
+ function readJobs(root = getPrivateStateRoot()) {
39
+ const base = oracleRoot(root);
40
+ if (!fs.existsSync(base)) return [];
41
+ const stat = assertNotSymlink(base, false);
42
+ if (!stat.isDirectory()) throw new Error(`oracle state path is not a directory: ${base}`);
43
+ return fs.readdirSync(base)
44
+ .filter((id) => JOB_ID_PATTERN.test(id))
45
+ .sort((a, b) => b.localeCompare(a))
46
+ .map((id) => readPrivateJson(path.join(base, id, "job.json"), null, { root }))
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null }) {
51
+ const root = getPrivateStateRoot();
52
+ const base = oracleRoot(root);
53
+ ensurePrivateDir(base, root);
54
+ const inFlight = readJobs(root).find((job) => !TERMINAL_STATES.has(job.state));
55
+ if (inFlight) {
56
+ throw codedError(
57
+ "capacity",
58
+ `oracle job capacity reached; in-flight job: ${inFlight.id}`,
59
+ { jobId: inFlight.id },
60
+ );
61
+ }
62
+
63
+ const now = new Date();
64
+ const compactTimestamp = now.toISOString().replace(/\D/g, "").slice(0, 14);
65
+ const timestamp = `${compactTimestamp.slice(0, 8)}-${compactTimestamp.slice(8)}`;
66
+ let id;
67
+ let directory;
68
+ for (;;) {
69
+ id = `${timestamp}-${crypto.randomBytes(2).toString("hex")}`;
70
+ directory = path.join(base, id);
71
+ try {
72
+ fs.mkdirSync(directory, { mode: 0o700 });
73
+ break;
74
+ } catch (error) {
75
+ if (error?.code !== "EEXIST") throw error;
76
+ }
77
+ }
78
+
79
+ try {
80
+ ensurePrivateDir(path.join(directory, "turns"), root);
81
+ atomicWriteFile(path.join(directory, "request.md"), prompt, { root, encoding: "utf8" });
82
+ atomicWriteJson(path.join(directory, "context-manifest.json"), contextManifest, { root });
83
+ const job = {
84
+ id,
85
+ state: "created",
86
+ model,
87
+ effortRequested,
88
+ effortVerified: null,
89
+ createdAt: now.toISOString(),
90
+ dispatchedAt: null,
91
+ awaitingAt: null,
92
+ capturedAt: null,
93
+ failedAt: null,
94
+ tabId: null,
95
+ conversationUrl: null,
96
+ promptEcho: null,
97
+ error: null,
98
+ turns: [],
99
+ ...(follow ? { follow } : {}),
100
+ };
101
+ atomicWriteJson(path.join(directory, "job.json"), job, { root });
102
+ return job;
103
+ } catch (error) {
104
+ fs.rmSync(directory, { recursive: true, force: true });
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function getJob(id) {
110
+ const root = getPrivateStateRoot();
111
+ const job = readPrivateJson(path.join(jobDirectory(id, root), "job.json"), null, { root });
112
+ if (!job) throw codedError("not_found", `oracle job not found: ${id}`);
113
+ return job;
114
+ }
115
+
116
+ function getResponse(id) {
117
+ const root = getPrivateStateRoot();
118
+ getJob(id);
119
+ return readPrivateFile(path.join(jobDirectory(id, root), "response.md"), {
120
+ root,
121
+ encoding: "utf8",
122
+ });
123
+ }
124
+
125
+ function transition(id, state, updates) {
126
+ const job = getJob(id);
127
+ if (!TRANSITIONS[job.state]?.has(state)) {
128
+ throw codedError(
129
+ "invalid_transition",
130
+ `oracle job ${id} cannot transition from ${job.state} to ${state}`,
131
+ );
132
+ }
133
+ const updated = { ...job, state, ...updates };
134
+ const root = getPrivateStateRoot();
135
+ atomicWriteJson(path.join(jobDirectory(id, root), "job.json"), updated, { root });
136
+ return updated;
137
+ }
138
+
139
+ function markDispatched(id, { tabId, promptEcho, modelVerified, effortVerified }) {
140
+ return transition(id, "dispatched", {
141
+ dispatchedAt: new Date().toISOString(),
142
+ tabId,
143
+ ...(promptEcho ? { promptEcho } : {}),
144
+ ...(modelVerified ? { model: modelVerified } : {}),
145
+ ...(effortVerified ? { effortVerified } : {}),
146
+ });
147
+ }
148
+
149
+ function markAwaiting(id, { conversationUrl, promptEcho }) {
150
+ return transition(id, "awaiting", {
151
+ awaitingAt: new Date().toISOString(),
152
+ conversationUrl,
153
+ promptEcho,
154
+ });
155
+ }
156
+
157
+ function markCaptured(id, { response }) {
158
+ const job = getJob(id);
159
+ if (!TRANSITIONS[job.state]?.has("captured")) {
160
+ throw codedError(
161
+ "invalid_transition",
162
+ `oracle job ${id} cannot transition from ${job.state} to captured`,
163
+ );
164
+ }
165
+ const root = getPrivateStateRoot();
166
+ atomicWriteFile(path.join(jobDirectory(id, root), "response.md"), response, {
167
+ root,
168
+ encoding: "utf8",
169
+ });
170
+ return transition(id, "captured", { capturedAt: new Date().toISOString() });
171
+ }
172
+
173
+ function markFailed(id, { code, message }) {
174
+ return transition(id, "failed", {
175
+ failedAt: new Date().toISOString(),
176
+ error: { code, message },
177
+ });
178
+ }
179
+
180
+ function updateTabId(id, tabId) {
181
+ const job = getJob(id);
182
+ if (TERMINAL_STATES.has(job.state)) {
183
+ throw codedError(
184
+ "invalid_transition",
185
+ `oracle job ${id} cannot transition from ${job.state} to update tab`,
186
+ );
187
+ }
188
+ const updated = { ...job, tabId };
189
+ const root = getPrivateStateRoot();
190
+ atomicWriteJson(path.join(jobDirectory(id, root), "job.json"), updated, { root });
191
+ return updated;
192
+ }
193
+
194
+ function appendTurn(id, turn) {
195
+ const job = getJob(id);
196
+ const storedTurn = {
197
+ prompt: turn.prompt,
198
+ dispatchedAt: turn.dispatchedAt ?? null,
199
+ capturedAt: turn.capturedAt ?? null,
200
+ };
201
+ const root = getPrivateStateRoot();
202
+ const directory = jobDirectory(id, root);
203
+ const turnName = `${String(job.turns.length + 1).padStart(4, "0")}.json`;
204
+ atomicWriteJson(path.join(directory, "turns", turnName), storedTurn, { root });
205
+ const updated = { ...job, turns: [...job.turns, storedTurn] };
206
+ atomicWriteJson(path.join(directory, "job.json"), updated, { root });
207
+ return updated;
208
+ }
209
+
210
+ function markTurnCaptured(id, { dispatchedAt, capturedAt }) {
211
+ const job = getJob(id);
212
+ const turnIndex = job.turns.findIndex((turn) => turn.dispatchedAt === dispatchedAt);
213
+ if (turnIndex === -1) {
214
+ throw codedError(
215
+ "invalid_transition",
216
+ `oracle job ${id} has no follow turn dispatched at ${dispatchedAt}`,
217
+ );
218
+ }
219
+ const turns = [...job.turns];
220
+ turns[turnIndex] = { ...turns[turnIndex], capturedAt };
221
+ const root = getPrivateStateRoot();
222
+ const directory = jobDirectory(id, root);
223
+ const turnName = `${String(turnIndex + 1).padStart(4, "0")}.json`;
224
+ atomicWriteJson(path.join(directory, "turns", turnName), turns[turnIndex], { root });
225
+ const updated = { ...job, turns };
226
+ atomicWriteJson(path.join(directory, "job.json"), updated, { root });
227
+ return updated;
228
+ }
229
+
230
+ function listJobs({ limit } = {}) {
231
+ const jobs = readJobs();
232
+ return limit === undefined ? jobs : jobs.slice(0, Math.max(0, limit));
233
+ }
234
+
235
+ function adoptOrphans() {
236
+ return listJobs({}).filter((job) => !TERMINAL_STATES.has(job.state));
237
+ }
238
+
239
+ module.exports = {
240
+ adoptOrphans,
241
+ appendTurn,
242
+ createJob,
243
+ getJob,
244
+ getResponse,
245
+ listJobs,
246
+ markAwaiting,
247
+ markCaptured,
248
+ markDispatched,
249
+ markFailed,
250
+ markTurnCaptured,
251
+ oracleRoot,
252
+ updateTabId,
253
+ };
@@ -7,6 +7,7 @@ const COMMANDS = {
7
7
  ai: { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
8
8
  gemini: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
9
9
  chatgpt: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
10
+ "oracle.ask": { primaryArg: "prompt", effect: "page-write", argKinds: { prompt: "user-input" }, sensitiveArgs: ["prompt"] },
10
11
  perplexity: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
11
12
  grok: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
12
13
  navigate: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -82,6 +82,37 @@ surf chatgpt "review" --model gpt-4o # Specify model
82
82
  surf chatgpt "analyze" --file document.pdf # With file attachment
83
83
  ```
84
84
 
85
+ ### Oracle
86
+
87
+ 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.
88
+
89
+ For agent workflows, detach after dispatch and keep the returned `.id`:
90
+
91
+ ```bash
92
+ surf oracle ask "Review this change and identify release risks" \
93
+ --files "src/**/*.ts" --files "package.json" \
94
+ --model pro --effort extended --detach --json
95
+
96
+ surf oracle status <job-id> --json
97
+ surf oracle result <job-id> --json
98
+ # Or let Surf keep polling until capture:
99
+ surf oracle result <job-id> --wait --json
100
+ ```
101
+
102
+ `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.
103
+
104
+ Treat Pro quota as scarce. Oracle never selects Pro implicitly; request it with `--model pro`. Accepted `--effort` values are `light`, `standard`, `extended`, and `heavy`. 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.
105
+
106
+ 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.
107
+
108
+ Continue a captured consult with `follow`. Use the ID returned by each turn for the next turn:
109
+
110
+ ```bash
111
+ surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --detach --json
112
+ surf oracle result <follow-job-id> --wait --json
113
+ surf oracle follow <follow-job-id> "Give the final decision and concrete next steps." --detach --json
114
+ ```
115
+
85
116
  ### Gemini
86
117
  ```bash
87
118
  surf gemini "explain quantum computing"