surf-cli 2.15.2 → 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.
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.15.2",
3
+ "version": "2.16.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -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?: string | null;
47
- model?: string | null;
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?: { code?: string; message?: string } | null;
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 Partial<OracleJob>;
263
- if (typeof job.id !== "string" || typeof job.state !== "string") throw new Error("Surf oracle response did not include job id and state");
264
- return job as OracleJob;
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
- return {
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
- return piExternalJobHandle(await requestOracleJob(request, "oracle.status", { id }));
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 requestOracleJob(request, "oracle.result", { id })
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
- return register(createOracleExternalJobProvider(sessionId, jobIds, request, rememberJob, emitTerminal));
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(sessionId, oracleJobIds, registerGlobalExternalJobProvider, requestSurf, rememberForGeneration, emitFinished);
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(sessionId, oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
647
+ const nextDispose = registerOptionalExternalJobProvider(oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
556
648
  if (generation !== sessionGeneration) {
557
649
  nextDispose();
558
650
  return;