pi-browser-harness 0.13.0 → 0.14.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.
@@ -2,6 +2,8 @@ export type SerpAnchor = {
2
2
  readonly href: string;
3
3
  readonly heading: string;
4
4
  readonly snippet: string;
5
+ // Visible breadcrumb url ("https://host › path › seg"); the only place the target survives once Google wraps hrefs in /goto.
6
+ readonly cite?: string;
5
7
  };
6
8
 
7
9
  export type SerpExtraction = {
@@ -16,7 +18,7 @@ export type SearchResult = {
16
18
  readonly rank: number;
17
19
  };
18
20
 
19
- export type SerpVerdict = "ok" | "captcha" | "no_results";
21
+ export type SerpVerdict = "ok" | "captcha" | "no_results" | "extraction_mismatch";
20
22
 
21
23
  const CAPTCHA_MARKERS: ReadonlyArray<string> = [
22
24
  "unusual traffic",
@@ -36,12 +38,74 @@ const unwrapRedirect = (href: string): string => {
36
38
  return target ?? href;
37
39
  };
38
40
 
41
+ const CITE_SEPARATOR = /\s*›\s*/;
42
+ const TRUNCATION = /(?:\.\.\.|…)/;
43
+
44
+ // Rebuilds a url from Google's visible breadcrumb cite. Segments after a truncation marker are unknowable, so the
45
+ // path stops at the last complete segment rather than guessing.
46
+ export const urlFromCite = (cite: string): string | undefined => {
47
+ const segments = cite
48
+ .trim()
49
+ .split(CITE_SEPARATOR)
50
+ .map((segment) => segment.trim())
51
+ .filter((segment) => segment.length > 0);
52
+ const origin = segments[0];
53
+ if (origin === undefined || TRUNCATION.test(origin)) return undefined;
54
+ // A cite naming any other scheme (ftp:, mailto:) is not a web result; prefixing it would forge a bogus https url.
55
+ if (/^[a-z][a-z0-9+.-]*:/i.test(origin) && !/^https?:\/\//i.test(origin)) return undefined;
56
+ const withScheme = /^https?:\/\//i.test(origin) ? origin : `https://${origin}`;
57
+ const path: string[] = [];
58
+ for (const segment of segments.slice(1)) {
59
+ if (TRUNCATION.test(segment)) break;
60
+ path.push(segment);
61
+ }
62
+ const url = [withScheme.replace(/\/$/, ""), ...path].join("/");
63
+ return isExternalResult(url) ? url : undefined;
64
+ };
65
+
66
+ // Only a real Google /goto url counts: the daemon fetches these, so a look-alike host, userinfo, a port or http
67
+ // must never qualify. Hosts are google.com or www.google.<tld>, nothing else.
68
+ const GOOGLE_GOTO_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/;
69
+ export const isGotoRedirect = (href: string): boolean => {
70
+ try {
71
+ const u = new URL(href);
72
+ return (
73
+ u.protocol === "https:" &&
74
+ u.username === "" &&
75
+ u.password === "" &&
76
+ u.port === "" &&
77
+ GOOGLE_GOTO_HOST.test(u.hostname.toLowerCase()) &&
78
+ u.pathname === "/goto"
79
+ );
80
+ } catch {
81
+ return false;
82
+ }
83
+ };
84
+
85
+ export type ResolvedTargets = ReadonlyMap<string, string>;
86
+
87
+ // Direct external hrefs win. A /goto wrapper is opaque: the server-resolved redirect target is exact, and the cite is
88
+ // a display breadcrumb that only approximates the path, so it is the fallback when no target was resolved.
89
+ const resolveResultUrl = (anchor: SerpAnchor, resolved: ResolvedTargets): string | undefined => {
90
+ if (!isGotoRedirect(anchor.href)) {
91
+ const unwrapped = unwrapRedirect(anchor.href);
92
+ return isExternalResult(unwrapped) ? unwrapped : undefined;
93
+ }
94
+ const target = resolved.get(anchor.href);
95
+ if (target !== undefined && isExternalResult(target)) return target;
96
+ if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite);
97
+ return undefined;
98
+ };
99
+
100
+ // Any Google property, on any TLD (google.com, google.co.uk, consent.google.de, googleusercontent.com), is never a
101
+ // result: a sorry or consent page is where a rate-limited /goto redirect lands.
102
+ const GOOGLE_PROPERTY = /(?:^|\.)google(?:usercontent)?\.(?:com?\.[a-z]{2}|[a-z]{2,3})$/;
103
+
39
104
  const isExternalResult = (url: string): boolean => {
40
105
  if (!/^https?:\/\//i.test(url)) return false;
41
106
  try {
42
107
  const host = new URL(url).hostname.toLowerCase();
43
- if (host === "google.com" || host.endsWith(".google.com")) return false;
44
- if (host === "googleusercontent.com" || host.endsWith(".googleusercontent.com")) return false;
108
+ if (GOOGLE_PROPERTY.test(host)) return false;
45
109
  return true;
46
110
  } catch {
47
111
  return false;
@@ -57,13 +121,17 @@ const dedupeKey = (url: string): string => {
57
121
  }
58
122
  };
59
123
 
60
- export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => {
124
+ export const parseGoogleSerp = (
125
+ anchors: ReadonlyArray<SerpAnchor>,
126
+ limit: number,
127
+ resolved: ResolvedTargets = new Map(),
128
+ ): ReadonlyArray<SearchResult> => {
61
129
  const seen = new Set<string>();
62
130
  const results: SearchResult[] = [];
63
131
  for (const anchor of anchors) {
64
132
  if (results.length >= limit) break;
65
- const url = unwrapRedirect(anchor.href);
66
- if (!isExternalResult(url)) continue;
133
+ const url = resolveResultUrl(anchor, resolved);
134
+ if (url === undefined) continue;
67
135
  const key = dedupeKey(url);
68
136
  if (seen.has(key)) continue;
69
137
  seen.add(key);
@@ -77,9 +145,11 @@ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: numbe
77
145
  return results;
78
146
  };
79
147
 
80
- export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => {
148
+ export const classifySerp = (pageText: string, resultCount: number, anchorCount: number): SerpVerdict => {
81
149
  if (resultCount > 0) return "ok";
82
150
  const lower = pageText.toLowerCase();
83
151
  if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha";
152
+ // Anchors were found but every one was filtered out: the markup changed, not the result set.
153
+ if (anchorCount > 0) return "extraction_mismatch";
84
154
  return "no_results";
85
155
  };
@@ -0,0 +1,41 @@
1
+ // Resolves one Google /goto?url=<opaque blob> link to its target by reading the server's redirect, without
2
+ // following it. The blob cannot be decoded client-side and the page's <cite> is only a display breadcrumb, so this
3
+ // is the one source of the exact url. Google answers the redirect to a cookie-less GET; HEAD returns 200 instead.
4
+
5
+ import { isGotoRedirect } from "./google-serp";
6
+
7
+ const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
8
+
9
+ const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36";
10
+
11
+ export type RedirectResolver = (href: string, signal: AbortSignal) => Promise<string | undefined>;
12
+
13
+ export type ResolveOptions = {
14
+ // Defence in depth: the parser already filters to /goto hrefs, and the resolver refuses anything else again so a
15
+ // caller bug cannot turn it into a generic url fetcher. Tests against a local server opt out explicitly.
16
+ readonly requireGoto?: boolean;
17
+ };
18
+
19
+ export const fetchRedirectTarget = async (
20
+ href: string,
21
+ signal: AbortSignal,
22
+ opts: ResolveOptions = {},
23
+ ): Promise<string | undefined> => {
24
+ if ((opts.requireGoto ?? true) && !isGotoRedirect(href)) return undefined;
25
+ try {
26
+ const response = await fetch(href, {
27
+ method: "GET",
28
+ redirect: "manual",
29
+ signal,
30
+ headers: { "user-agent": USER_AGENT, accept: "text/html" },
31
+ });
32
+ // The body is never read; cancelling it releases the socket without waiting for Google to finish sending.
33
+ await response.body?.cancel().catch(() => undefined);
34
+ if (!REDIRECT_STATUSES.has(response.status)) return undefined;
35
+ const location = response.headers.get("location");
36
+ if (location === null || location === "") return undefined;
37
+ return new URL(location, href).toString();
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ };
@@ -16,9 +16,47 @@ import { pageCallExpression } from "../page/inject";
16
16
  import { extractSerp } from "../page/serp.page";
17
17
  import type * as extracted from "../page/serp.page";
18
18
  import { ExpandableTextSchema, renderExpandableText } from "../render";
19
- import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
19
+ import { deadlineSignal, runLimited } from "../concurrency";
20
+ import {
21
+ classifySerp,
22
+ isGotoRedirect,
23
+ parseGoogleSerp,
24
+ type ResolvedTargets,
25
+ type SearchResult,
26
+ type SerpExtraction,
27
+ } from "./google-serp";
28
+ import { fetchRedirectTarget, type RedirectResolver } from "./redirect-resolver";
20
29
 
21
30
  const LOAD_TIMEOUT_MS = 15_000;
31
+ const RESOLVE_BUDGET_MS = 5_000;
32
+ const RESOLVE_CONCURRENCY = 5;
33
+
34
+ export type WebSearchDeps = {
35
+ readonly resolveRedirect: RedirectResolver;
36
+ readonly resolveBudgetMs?: number;
37
+ };
38
+
39
+ // Resolves every /goto anchor in parallel under one time budget; a slot that fails or runs out of time is simply
40
+ // absent from the map, and the parser falls back to that anchor's cite.
41
+ const resolveGotoTargets = async (
42
+ anchors: ReadonlyArray<SerpExtraction["anchors"][number]>,
43
+ deps: WebSearchDeps,
44
+ signal: AbortSignal | undefined,
45
+ ): Promise<ResolvedTargets> => {
46
+ const hrefs = anchors.map((a) => a.href).filter(isGotoRedirect);
47
+ if (hrefs.length === 0) return new Map();
48
+ const deadline = deadlineSignal(deps.resolveBudgetMs ?? RESOLVE_BUDGET_MS, signal);
49
+ const targets = await runLimited(hrefs, { limit: RESOLVE_CONCURRENCY, signal: deadline.signal }, (href, s) =>
50
+ deps.resolveRedirect(href, s),
51
+ );
52
+ deadline.clear();
53
+ const resolved = new Map<string, string>();
54
+ hrefs.forEach((href, i) => {
55
+ const target = targets[i];
56
+ if (target !== undefined) resolved.set(href, target);
57
+ });
58
+ return resolved;
59
+ };
22
60
 
23
61
  const WebSearchArgs = Type.Object({
24
62
  query: Type.String({ description: "The search query." }),
@@ -42,7 +80,12 @@ const buildSerpUrl = (query: string, limit: number): string =>
42
80
 
43
81
  const SerpExtractionSchema = Type.Object({
44
82
  anchors: Type.Array(
45
- Type.Object({ href: Type.String(), heading: Type.String(), snippet: Type.String() }),
83
+ Type.Object({
84
+ href: Type.String(),
85
+ heading: Type.String(),
86
+ snippet: Type.String(),
87
+ cite: Type.Optional(Type.String()),
88
+ }),
46
89
  ),
47
90
  pageText: Type.String(),
48
91
  });
@@ -74,7 +117,7 @@ const formatResults = (query: string, results: ReadonlyArray<SearchResult>): str
74
117
  return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
75
118
  };
76
119
 
77
- export const webSearchTool = defineBrowserTool({
120
+ export const createWebSearchTool = (deps: WebSearchDeps) => defineBrowserTool({
78
121
  name: "browser_web_search",
79
122
  label: "Browser Web Search",
80
123
  description:
@@ -83,6 +126,7 @@ export const webSearchTool = defineBrowserTool({
83
126
  promptGuidelines: [
84
127
  "On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.",
85
128
  "On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
129
+ "If results were on the page but none could be parsed it fails with details.reason:'extraction_mismatch' (plus anchorCount) — Google's markup changed; report it rather than retrying.",
86
130
  "Runs in its own isolated tab and never disturbs the user's current tab.",
87
131
  ],
88
132
  parameters: WebSearchArgs,
@@ -112,15 +156,27 @@ export const webSearchTool = defineBrowserTool({
112
156
  }
113
157
  const extraction: SerpExtraction = parsedJson.data;
114
158
 
115
- const results = parseGoogleSerp(extraction.anchors, limit);
116
- const verdict = classifySerp(extraction.pageText, results.length);
117
- if (verdict !== "ok") {
159
+ const resolved = await resolveGotoTargets(extraction.anchors, deps, signal);
160
+ const results = parseGoogleSerp(extraction.anchors, limit, resolved);
161
+ const anchorCount = extraction.anchors.length;
162
+ const verdict = classifySerp(extraction.pageText, results.length, anchorCount);
163
+ if (verdict === "captcha") {
118
164
  return err({
119
165
  kind: "invalid_state",
120
- message: verdict === "captcha" ? "Google served a CAPTCHA / bot wall" : "no results found",
166
+ message: "Google served a CAPTCHA / bot wall",
121
167
  details: { reason: verdict, serpUrl },
122
168
  });
123
169
  }
170
+ if (verdict === "extraction_mismatch") {
171
+ return err({
172
+ kind: "invalid_state",
173
+ message: `found ${anchorCount} result anchor(s) but none parsed to an external url (Google markup may have changed)`,
174
+ details: { reason: verdict, anchorCount, serpUrl },
175
+ });
176
+ }
177
+ if (verdict === "no_results") {
178
+ return err({ kind: "invalid_state", message: "no results found", details: { reason: verdict, serpUrl } });
179
+ }
124
180
 
125
181
  const body = formatResults(args.query, results);
126
182
  const truncated = await applyTruncation(body, "search");
@@ -146,3 +202,5 @@ export const webSearchTool = defineBrowserTool({
146
202
  return renderExpandableText(result.details.render, expanded);
147
203
  },
148
204
  });
205
+
206
+ export const webSearchTool = createWebSearchTool({ resolveRedirect: fetchRedirectTarget });
@@ -12,7 +12,9 @@ import {
12
12
  buildTree,
13
13
  collectInteractiveTargets,
14
14
  countNodes,
15
+ firstNodes,
15
16
  INTERACTIVE_ROLES,
17
+ keepOnly,
16
18
  liveValue,
17
19
  type SlimNode,
18
20
  stripInternals,
@@ -136,12 +138,12 @@ export const snapshotTool = defineBrowserTool({
136
138
  const pageUrl = piRes.data.kind === "dialog" ? "" : piRes.data.url;
137
139
  const pageTitle = piRes.data.kind === "dialog" ? "" : piRes.data.title;
138
140
 
139
- const slim = buildTree(rawNodes, {
140
- interestingOnly: args.interestingOnly ?? true,
141
- maxNodes: args.maxNodes ?? 1000,
142
- });
141
+ // Every element gets a ref, so a tool or a recipe can reach one the shown outline cut off; maxNodes limits
142
+ // only what is shown.
143
+ const full = buildTree(rawNodes, { interestingOnly: args.interestingOnly ?? true, maxNodes: Number.POSITIVE_INFINITY });
144
+ const shown = firstNodes(full, args.maxNodes ?? 1000);
143
145
 
144
- const targets = collectInteractiveTargets(slim);
146
+ const targets = collectInteractiveTargets(full);
145
147
 
146
148
  const refMap = new Map<string, number>();
147
149
  targets.forEach(({ node, backendId }, i) => {
@@ -153,7 +155,8 @@ export const snapshotTool = defineBrowserTool({
153
155
  if (targets.length > 0) {
154
156
  // Geometry and live values are best-effort decoration: whatever lands inside the budget is kept, the rest is omitted.
155
157
  const deadline = deadlineSignal(BOX_BUDGET_MS, signal);
156
- await runLimited(targets, { limit: BOX_CONCURRENCY, signal: deadline.signal }, async ({ node, backendId }, sig) => {
158
+ const visible = targets.filter(({ node }) => shown.has(node));
159
+ await runLimited(visible, { limit: BOX_CONCURRENCY, signal: deadline.signal }, async ({ node, backendId }, sig) => {
157
160
  const box = await boxOf(client, backendId);
158
161
  if (!box.success || sig.aborted) return;
159
162
  if (box.data.x < 0 || box.data.y < 0) return;
@@ -172,6 +175,8 @@ export const snapshotTool = defineBrowserTool({
172
175
  }
173
176
  session.setRefMap(refMap, refSig);
174
177
 
178
+ const slim = keepOnly(full, shown);
179
+
175
180
  const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripInternals(slim), null, 2);
176
181
  const trunc = await applyTruncation(text, "snapshot");
177
182
 
package/src/index.ts CHANGED
@@ -12,8 +12,11 @@ import { createDaemonTransport } from "./daemon/transport";
12
12
  import type { CdpTransport } from "./cdp/types";
13
13
  import { setDebugClicks } from "./util/debug";
14
14
  import { asBoolean, asString } from "./util/guards";
15
- import type { BrowserTool } from "./util/tool";
15
+ import { type BrowserTool, setCallObserver } from "./util/tool";
16
16
  import type { Result } from "./util/result";
17
+ import { activeJournal, createJournal, journalDir, setActiveJournal } from "./recipes/journal";
18
+ import { observeCall } from "./recipes/recordable";
19
+ import { registerRecipeCommand } from "./recipes/command";
17
20
 
18
21
  export { defineBrowserTool } from "./util/tool";
19
22
  export type {
@@ -178,9 +181,17 @@ export const createExtension = (deps: ExtensionDeps = {}): ((pi: ExtensionAPI) =
178
181
  client.setProfilePin(profilePin);
179
182
  }
180
183
 
184
+ setActiveJournal(createJournal(journalDir(), ctx.sessionManager.getSessionId()));
185
+ setCallObserver(observeCall);
186
+
181
187
  harnessRegistry.register(pi, client);
182
188
  registerSetupCommand(pi, client);
183
189
  registerProfileCommand(pi, client);
190
+ registerRecipeCommand(pi, client);
191
+ });
192
+
193
+ pi.on("agent_start", async () => {
194
+ await activeJournal()?.beginRun();
184
195
  });
185
196
 
186
197
  pi.on("session_shutdown", async () => {
package/src/prompt.ts CHANGED
@@ -27,7 +27,8 @@ const joinShort = (names: ReadonlyArray<string>): string => names.map(shortToolN
27
27
  export function getBrowserSystemPrompt(connected = true, tools: ReadonlyArray<PromptTool> = ALL_TOOLS): string {
28
28
  const connectionNotice = connected
29
29
  ? ""
30
- : "Browser is not connected. Run browser_setup or /browser-setup to connect.\n\n";
30
+ : // "Not connected" alone read to some models as "no browser tools", and they fell back to curl.
31
+ "The browser_* tools are available in this session; the browser is just not connected yet. Call browser_setup first to connect (the user can also run /browser-setup), then use the browser_* tools. When the user asks for the browser, do not swap in curl, another fetch tool or another browser tool.\n\n";
31
32
  const lanes = laneNames(tools);
32
33
  return `
33
34
  ## Browser Control
@@ -0,0 +1,96 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { BrowserClient } from "../client";
3
+ import { missingParams, runRecipe, unknownParams, unknownParamsMessage } from "./replay";
4
+ import { formatFailureHandoff, formatOutputs, listText } from "./run-tool";
5
+ import { findRecipe, listRecipes, recordRun } from "./store";
6
+ import { approveProjectRecipe, trustWritten } from "./trust";
7
+
8
+ const TOKEN = /([^\s=]+)="([^"]*)"|(\S+)/g;
9
+
10
+ // `NAME key=value key="two words"`: the first bare word is the name, a double-quoted value stays whole, and the
11
+ // bare words after an unquoted value join it, so `topic=Grace Hopper` is "Grace Hopper", not "Grace".
12
+ export const parseCommandArgs = (raw: string): { readonly name: string | undefined; readonly params: Readonly<Record<string, string>> } => {
13
+ let name: string | undefined;
14
+ let open: string | undefined;
15
+ const params: Record<string, string> = {};
16
+ for (const m of raw.matchAll(TOKEN)) {
17
+ const [, quotedKey, quotedValue, bare] = m;
18
+ if (quotedKey !== undefined && quotedValue !== undefined) {
19
+ params[quotedKey] = quotedValue;
20
+ open = undefined;
21
+ } else if (bare?.includes("=") === true) {
22
+ const at = bare.indexOf("=");
23
+ open = bare.slice(0, at);
24
+ params[open] = bare.slice(at + 1);
25
+ } else if (bare !== undefined && name === undefined) {
26
+ name = bare;
27
+ } else if (bare !== undefined && open !== undefined) {
28
+ params[open] = `${params[open] ?? ""} ${bare}`;
29
+ }
30
+ }
31
+ return { name, params };
32
+ };
33
+
34
+ // Runs the replay engine directly, so a command run never starts a model turn.
35
+ export const registerRecipeCommand = (pi: Pick<ExtensionAPI, "registerCommand" | "sendUserMessage" | "sendMessage">, client: BrowserClient): void => {
36
+ pi.registerCommand("browser-recipe", {
37
+ description: "Replay a saved browser recipe with no model calls: /browser-recipe NAME key=value. No name lists recipes.",
38
+ getArgumentCompletions: async (prefix) => {
39
+ const all = await listRecipes(process.cwd());
40
+ return all
41
+ .map((s) => s.recipe.name)
42
+ .filter((n) => n.startsWith(prefix))
43
+ .map((n) => ({ value: n, label: n }));
44
+ },
45
+ handler: async (raw, ctx) => {
46
+ const { name, params } = parseCommandArgs(raw);
47
+ if (name === undefined) {
48
+ ctx.ui.notify(await listText(ctx.cwd), "info");
49
+ return;
50
+ }
51
+ const alive = await client.ensureAlive();
52
+ if (!alive.success) {
53
+ ctx.ui.notify(`Browser not connected: ${alive.error.message}. Run /browser-setup first.`, "error");
54
+ return;
55
+ }
56
+ const stored = await findRecipe(ctx.cwd, name);
57
+ if (!stored.success) {
58
+ ctx.ui.notify(stored.error, "error");
59
+ return;
60
+ }
61
+ const unknown = unknownParams(stored.data.recipe, params);
62
+ if (unknown.length > 0) {
63
+ ctx.ui.notify(unknownParamsMessage(stored.data.recipe, unknown), "error");
64
+ return;
65
+ }
66
+ const missing = missingParams(stored.data.recipe, params);
67
+ if (missing.length > 0) {
68
+ ctx.ui.notify(`Recipe ${name} needs: ${missing.map((p) => `${p}=… (e.g. ${stored.data.recipe.params[p]?.example ?? ""})`).join(" ")}`, "error");
69
+ return;
70
+ }
71
+ const approved = await approveProjectRecipe(ctx, stored.data, params);
72
+ if (!approved.success) {
73
+ ctx.ui.notify(approved.error, "error");
74
+ return;
75
+ }
76
+ ctx.ui.notify(`Running recipe ${name}…`, "info");
77
+ const outcome = await runRecipe({ client, extensionCtx: ctx, recipe: stored.data.recipe, params });
78
+ const at = new Date().toISOString();
79
+ if (outcome.ok) {
80
+ await recordRun(stored.data, { at, result: "ok" });
81
+ await trustWritten(stored.data, ctx.cwd);
82
+ ctx.ui.notify(`Recipe ${name} done: ${outcome.steps} steps ✓`, "info");
83
+ // Shown in the session without starting a model turn; the agent sees it with the user's next message.
84
+ const data = formatOutputs(outcome.outputs);
85
+ if (data !== "") pi.sendMessage({ customType: "browser-recipe-output", content: `/browser-recipe ${name}\n${data}`, display: true }, { triggerTurn: false });
86
+ return;
87
+ }
88
+ await recordRun(stored.data, { at, result: "failed", step: outcome.failure.step });
89
+ await trustWritten(stored.data, ctx.cwd);
90
+ ctx.ui.notify(`Recipe ${name} failed at step ${outcome.failure.step}; handing it to the agent to finish the task and repair the recipe.`, "warning");
91
+ const report = `/browser-recipe ${name} failed. ${formatFailureHandoff(stored.data.recipe, params, outcome.failure)}`;
92
+ if (ctx.isIdle()) pi.sendUserMessage(report);
93
+ else pi.sendUserMessage(report, { deliverAs: "followUp" });
94
+ },
95
+ });
96
+ };