tau-coding-agent 0.1.6 → 0.2.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.
Files changed (80) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -79
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +17 -2
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +109 -191
  13. package/extensions/loop.ts +149 -145
  14. package/extensions/memory.ts +138 -86
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +41 -38
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/triage.ts +41 -44
  28. package/extensions/sandbox/bash.ts +775 -0
  29. package/extensions/sandbox/command.ts +605 -0
  30. package/extensions/sandbox/config.ts +764 -0
  31. package/extensions/sandbox/index.ts +132 -3338
  32. package/extensions/sandbox/permissions/dialog.ts +118 -0
  33. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  34. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  35. package/extensions/sandbox/permissions/network.ts +164 -0
  36. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  37. package/extensions/sandbox/runtime.ts +616 -0
  38. package/extensions/stash.ts +28 -15
  39. package/extensions/subagent/README.md +73 -0
  40. package/extensions/subagent/index.ts +822 -0
  41. package/extensions/subagent/interrupt.ts +117 -0
  42. package/extensions/subagent/permissions.ts +101 -0
  43. package/extensions/subagent/rpc.ts +177 -0
  44. package/extensions/tool-display-mode.ts +267 -64
  45. package/extensions/usage/index.ts +249 -356
  46. package/extensions/usage/openrouter.ts +10 -2
  47. package/extensions/websearch/README.md +12 -47
  48. package/extensions/websearch/config.ts +5 -2
  49. package/extensions/websearch/index.ts +94 -109
  50. package/extensions/websearch/output.ts +51 -0
  51. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  52. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  53. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  54. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  55. package/extensions/websearch/providers/pi-model.shared.ts +33 -39
  56. package/extensions/websearch/providers/shared.ts +87 -2
  57. package/extensions/websearch/types.ts +0 -3
  58. package/extensions/worktree.ts +132 -172
  59. package/package.json +10 -5
  60. package/skills/browser-tools/SKILL.md +29 -234
  61. package/skills/browser-tools/references/cookies.md +36 -0
  62. package/skills/browser-tools/references/interaction.md +90 -0
  63. package/skills/browser-tools/references/logging.md +34 -0
  64. package/skills/git-clean-history/SKILL.md +6 -6
  65. package/skills/git-commit/SKILL.md +5 -3
  66. package/skills/github-pull-request/SKILL.md +60 -0
  67. package/skills/github-pull-request/references/create.md +40 -0
  68. package/skills/github-pull-request/references/stewardship.md +60 -0
  69. package/skills/oracle/SKILL.md +3 -3
  70. package/skills/oracle/scripts/oracle +10 -1
  71. package/skills/sentry/SKILL.md +15 -185
  72. package/skills/sentry/references/events.md +79 -0
  73. package/skills/sentry/references/issues.md +62 -0
  74. package/skills/sentry/references/logs.md +46 -0
  75. package/skills/update-changelog/SKILL.md +27 -121
  76. package/skills/web-design/SKILL.md +16 -105
  77. package/themes/tau-dark.json +4 -0
  78. package/extensions/openai-fast.ts +0 -229
  79. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  80. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -95,8 +95,16 @@ async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<
95
95
  throw keyResult.reason;
96
96
  }
97
97
 
98
- const totalCredits = readNumber(creditsPayload.data?.total_credits) ?? 0;
99
- const totalUsage = readNumber(creditsPayload.data?.total_usage) ?? 0;
98
+ const totalCredits = readNumber(creditsPayload?.data?.total_credits);
99
+ const totalUsage = readNumber(creditsPayload?.data?.total_usage);
100
+ if (
101
+ totalCredits === undefined ||
102
+ totalCredits < 0 ||
103
+ totalUsage === undefined ||
104
+ totalUsage < 0
105
+ ) {
106
+ throw new Error("OpenRouter returned invalid credit totals.");
107
+ }
100
108
  const balance = Math.max(0, totalCredits - totalUsage);
101
109
 
102
110
  const keyLimit = readNumber(keyPayload?.data?.limit);
@@ -1,36 +1,14 @@
1
1
  # websearch
2
2
 
3
- Web search via Gemini, OpenAI, or Claude, leveraging Pi or browser sessions.
3
+ Search the web with Gemini, OpenAI, or Claude using Pi credentials or existing browser sessions. The `websearch` tool tries the configured routes and falls back when a search fails.
4
4
 
5
- Current routes:
5
+ ## Configuration
6
6
 
7
- - `pi:openai-codex`
8
- - `pi:anthropic`
9
- - `pi:gemini`
10
- - `firefox:gemini`
11
- - `firefox:openai-codex`
12
- - `chromium:gemini`
13
- - `chromium:openai-codex`
14
-
15
- ## Config
16
-
17
- Optional config file:
18
-
19
- - `~/.pi/agent/websearch.json`
20
-
21
- Example:
7
+ Optionally create `websearch.json` in your Pi agent directory (normally `~/.pi/agent`):
22
8
 
23
9
  ```json
24
10
  {
25
- "routes": [
26
- "pi:openai-codex",
27
- "pi:anthropic",
28
- "pi:gemini",
29
- "firefox:gemini",
30
- "firefox:openai-codex",
31
- "chromium:gemini",
32
- "chromium:openai-codex"
33
- ],
11
+ "routes": ["pi:openai-codex", "pi:anthropic", "pi:gemini", "firefox:gemini", "chromium:gemini"],
34
12
  "profiles": {
35
13
  "firefox": "default-release",
36
14
  "chromium": "Default"
@@ -38,31 +16,18 @@ Example:
38
16
  }
39
17
  ```
40
18
 
41
- Defaults:
19
+ The routes shown are the default order. Set `routes` to choose which routes to use and their order.
42
20
 
43
- - default routes match the example above
44
- - if `routes` is set, only those routes are tried, in that order
45
- - Pi routes try the current Pi model first within the matching provider family, then fall back to another available model from that family
21
+ Pi routes use Pi's authentication. Browser routes use existing Google sessions for Gemini. Browser profiles are discovered automatically unless pinned with `profiles`.
46
22
 
47
- `profiles` only applies to browser-backed routes. It pins which local Firefox or Chromium profile to read cookies from.
23
+ Browser access supports Firefox and Chromium-family browsers on macOS and Linux. macOS may prompt for Keychain access to decrypt cookies.
48
24
 
49
25
  ## Usage
50
26
 
51
- In Pi, the extension exposes:
52
-
53
- - tool: `websearch`
54
-
55
- Examples:
56
-
57
- - `websearch({ query: "latest Todoist release notes" })`
58
- - `websearch({ query: "how does Pi compaction work" })`
27
+ ```js
28
+ websearch({ query: "how does Pi compaction work" });
29
+ ```
59
30
 
60
- ## Notes
31
+ ## Output
61
32
 
62
- - Pi-backed API routes are preferred, with browser fallbacks after that.
63
- - Browser routes use existing local browser sessions.
64
- - Browser profiles are auto-discovered unless pinned in config.
65
- - Supported Chromium-family browsers are Chromium, Chrome, Brave, and Edge.
66
- - Browser discovery currently supports macOS and Linux, not Windows.
67
- - On macOS, Chromium-based browsers may prompt for Keychain access so cookies can be decrypted.
68
- - If one route fails, the extension falls through to the next one.
33
+ Output is limited to 2,000 lines or 50 KB. Longer results include a path to the full text, including sources, in a temporary file.
@@ -11,9 +11,7 @@ const DEFAULT_ROUTES: WebsearchRouteId[] = [
11
11
  "pi:anthropic",
12
12
  "pi:gemini",
13
13
  "firefox:gemini",
14
- "firefox:openai-codex",
15
14
  "chromium:gemini",
16
- "chromium:openai-codex",
17
15
  ];
18
16
 
19
17
  function sanitizeRoutes(value: unknown): WebsearchRouteId[] {
@@ -26,6 +24,11 @@ function sanitizeRoutes(value: unknown): WebsearchRouteId[] {
26
24
  const result: WebsearchRouteId[] = [];
27
25
 
28
26
  for (const item of value) {
27
+ if (item === "firefox:openai-codex" || item === "chromium:openai-codex") {
28
+ throw new Error(
29
+ `Invalid websearch route: ${item} (removed). Use pi:openai-codex and sign in with Pi /login.`,
30
+ );
31
+ }
29
32
  if (typeof item !== "string" || !valid.has(item as WebsearchRouteId)) {
30
33
  throw new Error(`Invalid websearch route: ${String(item)}`);
31
34
  }
@@ -1,6 +1,9 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import {
3
+ DEFAULT_MAX_BYTES,
4
+ DEFAULT_MAX_LINES,
3
5
  defineTool,
6
+ formatSize,
4
7
  getMarkdownTheme,
5
8
  keyHint,
6
9
  type ExtensionAPI,
@@ -12,15 +15,15 @@ import { Type } from "typebox";
12
15
  import { createBrowserSession, discoverProfiles } from "./browser/discovery.js";
13
16
  import { loadConfig } from "./config.js";
14
17
  import { renderSearchResultMarkdown } from "./normalize.js";
18
+ import { limitOutput } from "./output.js";
15
19
  import { isPiAnthropicModel, searchWithPiAnthropic } from "./providers/anthropic.pi.js";
16
20
  import { browserGemini } from "./providers/gemini.browser.js";
17
21
  import { isPiGeminiModel, searchWithPiGemini } from "./providers/gemini.pi.js";
18
- import { browserOpenAICodex } from "./providers/openai-codex.browser.js";
19
22
  import { isPiOpenAICodexModel, searchWithPiOpenAICodex } from "./providers/openai-codex.pi.js";
20
23
  import type { PiModelSelection } from "./providers/pi-model.shared.js";
21
- import { selectCurrentPiModel, selectFallbackPiModel } from "./providers/pi-model.shared.js";
24
+ import { getPiModelCandidates, selectNextPiModel } from "./providers/pi-model.shared.js";
25
+ import { isModelUnavailableError } from "./providers/shared.js";
22
26
  import type {
23
- BrowserProfile,
24
27
  WebsearchAuthSource,
25
28
  WebsearchBackendId,
26
29
  WebsearchBrowserFamily,
@@ -29,10 +32,13 @@ import type {
29
32
  WebsearchRouteId,
30
33
  } from "./types.js";
31
34
 
32
- const PI_ROUTE_HANDLERS: Record<`pi:${WebsearchBackendId}`, PiRouteHandler> = {
35
+ type PiRouteId = `pi:${WebsearchBackendId}`;
36
+
37
+ const PI_ROUTE_HANDLERS: Record<PiRouteId, PiRouteHandler> = {
33
38
  "pi:openai-codex": {
34
39
  predicate: isPiOpenAICodexModel,
35
40
  search: searchWithPiOpenAICodex,
41
+ fallbackModels: ["gpt-5.6-luna", "gpt-5.5"],
36
42
  },
37
43
  "pi:anthropic": {
38
44
  predicate: isPiAnthropicModel,
@@ -51,12 +57,17 @@ interface SearchSummary {
51
57
  authSource: WebsearchAuthSource;
52
58
  browserName?: string;
53
59
  profile?: string;
54
- accountLabel?: string;
55
60
  sources: number;
56
61
  }
57
62
 
63
+ interface PiRoutePlan {
64
+ remaining: Model<Api>[];
65
+ attemptsLeft: number;
66
+ }
67
+
58
68
  interface PiRouteHandler {
59
69
  predicate: (model: Model<Api>) => boolean;
70
+ fallbackModels?: readonly string[];
60
71
  search: (
61
72
  selection: PiModelSelection,
62
73
  query: string,
@@ -64,6 +75,64 @@ interface PiRouteHandler {
64
75
  ) => Promise<WebsearchResult>;
65
76
  }
66
77
 
78
+ async function runSearch(
79
+ ctx: ExtensionContext,
80
+ query: string,
81
+ signal?: AbortSignal,
82
+ ): Promise<SearchSummary> {
83
+ const config = loadConfig();
84
+ const plans = new Map<PiRouteId, PiRoutePlan>();
85
+ let pending = config.routes;
86
+ let lastError: string | null = null;
87
+
88
+ while (pending.length > 0) {
89
+ const retries: PiRouteId[] = [];
90
+ for (const route of pending) {
91
+ throwIfAborted(signal);
92
+ let selection: PiModelSelection | null = null;
93
+ let plan: PiRoutePlan | undefined;
94
+ try {
95
+ if (isPiRoute(route)) {
96
+ const handler = PI_ROUTE_HANDLERS[route];
97
+ plan = plans.get(route);
98
+ if (!plan) {
99
+ const remaining = getPiModelCandidates(ctx, handler.predicate, handler.fallbackModels);
100
+ plan = { remaining, attemptsLeft: handler.fallbackModels ? remaining.length : 2 };
101
+ plans.set(route, plan);
102
+ }
103
+ selection = await selectNextPiModel(ctx, plan.remaining, signal);
104
+ if (!selection) continue;
105
+ throwIfAborted(signal);
106
+ plan.attemptsLeft--;
107
+ return toSearchSummary(route, await handler.search(selection, query, signal));
108
+ }
109
+ const result = await searchBrowserRoute(route, config, query, signal);
110
+ if (result) return result;
111
+ } catch (error) {
112
+ if (isAbortError(error, signal)) throw error;
113
+ lastError = error instanceof Error ? error.message : String(error);
114
+ if (
115
+ isPiRoute(route) &&
116
+ selection &&
117
+ plan &&
118
+ plan.attemptsLeft > 0 &&
119
+ plan.remaining.length > 0 &&
120
+ isModelUnavailableError(error, selection.model)
121
+ ) {
122
+ retries.push(route);
123
+ }
124
+ }
125
+ }
126
+ pending = retries;
127
+ }
128
+
129
+ throw new Error(lastError ?? "No configured websearch route is available.");
130
+ }
131
+
132
+ function isPiRoute(route: WebsearchRouteId): route is PiRouteId {
133
+ return route.startsWith("pi:");
134
+ }
135
+
67
136
  function toSearchSummary(route: WebsearchRouteId, result: WebsearchResult): SearchSummary {
68
137
  return {
69
138
  result: renderSearchResultMarkdown(result.answer, result.sources),
@@ -72,53 +141,10 @@ function toSearchSummary(route: WebsearchRouteId, result: WebsearchResult): Sear
72
141
  authSource: result.authSource,
73
142
  browserName: result.browserName,
74
143
  profile: result.profile,
75
- accountLabel: result.accountLabel,
76
144
  sources: result.sources.length,
77
145
  };
78
146
  }
79
147
 
80
- function currentPiRoute(ctx: ExtensionContext): `pi:${WebsearchBackendId}` | null {
81
- if (!ctx.model) return null;
82
-
83
- for (const [route, handler] of Object.entries(PI_ROUTE_HANDLERS) as Array<
84
- [`pi:${WebsearchBackendId}`, PiRouteHandler]
85
- >) {
86
- if (handler.predicate(ctx.model)) return route;
87
- }
88
-
89
- return null;
90
- }
91
-
92
- async function executePiRoute(
93
- route: `pi:${WebsearchBackendId}`,
94
- ctx: ExtensionContext,
95
- query: string,
96
- mode: "current" | "fallback",
97
- signal?: AbortSignal,
98
- ): Promise<SearchSummary | null> {
99
- const handler = PI_ROUTE_HANDLERS[route];
100
- const selection =
101
- mode === "current"
102
- ? await selectCurrentPiModel(ctx, handler.predicate)
103
- : await selectFallbackPiModel(ctx, handler.predicate);
104
-
105
- return selection ? toSearchSummary(route, await handler.search(selection, query, signal)) : null;
106
- }
107
-
108
- async function searchPiRoute(
109
- route: `pi:${WebsearchBackendId}`,
110
- ctx: ExtensionContext,
111
- query: string,
112
- signal?: AbortSignal,
113
- ): Promise<SearchSummary | null> {
114
- if (route === currentPiRoute(ctx)) {
115
- const current = await executePiRoute(route, ctx, query, "current", signal);
116
- if (current) return current;
117
- }
118
-
119
- return executePiRoute(route, ctx, query, "fallback", signal);
120
- }
121
-
122
148
  function isAbortError(error: unknown, signal?: AbortSignal): boolean {
123
149
  return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
124
150
  }
@@ -128,32 +154,17 @@ function throwIfAborted(signal?: AbortSignal): void {
128
154
  throw signal.reason ?? new Error("The operation was aborted.");
129
155
  }
130
156
 
131
- async function getBrowserProfiles(
132
- cache: Partial<Record<WebsearchBrowserFamily, BrowserProfile[]>>,
133
- family: WebsearchBrowserFamily,
134
- config: WebsearchConfig,
135
- ): Promise<BrowserProfile[]> {
136
- const cached = cache[family];
137
- if (cached) return cached;
138
-
139
- const profiles = await discoverProfiles([family], config.profiles);
140
- cache[family] = profiles;
141
- return profiles;
142
- }
143
-
144
157
  async function searchBrowserRoute(
145
158
  route: WebsearchRouteId,
146
159
  config: WebsearchConfig,
147
160
  query: string,
148
- profileCache: Partial<Record<WebsearchBrowserFamily, BrowserProfile[]>>,
149
161
  signal?: AbortSignal,
150
162
  ): Promise<SearchSummary | null> {
151
163
  const profileFamily: WebsearchBrowserFamily = route.startsWith("firefox:")
152
164
  ? "firefox"
153
165
  : "chromium";
154
- const browserProvider = route.endsWith(":gemini") ? browserGemini : browserOpenAICodex;
155
166
  const configuredProfileName = config.profiles[profileFamily];
156
- const profiles = await getBrowserProfiles(profileCache, profileFamily, config);
167
+ const profiles = await discoverProfiles([profileFamily], config.profiles);
157
168
  let lastError: string | null = null;
158
169
 
159
170
  if (configuredProfileName && profiles.length === 0) {
@@ -164,7 +175,7 @@ async function searchBrowserRoute(
164
175
  throwIfAborted(signal);
165
176
 
166
177
  try {
167
- const session = await createBrowserSession(profile, browserProvider.domains);
178
+ const session = await createBrowserSession(profile, browserGemini.domains);
168
179
  if (!session) {
169
180
  if (configuredProfileName) {
170
181
  throw new Error(
@@ -173,7 +184,7 @@ async function searchBrowserRoute(
173
184
  }
174
185
  continue;
175
186
  }
176
- return toSearchSummary(route, await browserProvider.search(session, query, signal));
187
+ return toSearchSummary(route, await browserGemini.search(session, query, signal));
177
188
  } catch (error) {
178
189
  if (isAbortError(error, signal)) throw error;
179
190
  lastError = error instanceof Error ? error.message : String(error);
@@ -184,43 +195,6 @@ async function searchBrowserRoute(
184
195
  return null;
185
196
  }
186
197
 
187
- async function searchRoute(
188
- route: WebsearchRouteId,
189
- ctx: ExtensionContext,
190
- config: WebsearchConfig,
191
- query: string,
192
- profileCache: Partial<Record<WebsearchBrowserFamily, BrowserProfile[]>>,
193
- signal?: AbortSignal,
194
- ): Promise<SearchSummary | null> {
195
- return route.startsWith("pi:")
196
- ? await searchPiRoute(route as `pi:${WebsearchBackendId}`, ctx, query, signal)
197
- : await searchBrowserRoute(route, config, query, profileCache, signal);
198
- }
199
-
200
- async function runSearch(
201
- ctx: ExtensionContext,
202
- query: string,
203
- signal?: AbortSignal,
204
- ): Promise<SearchSummary> {
205
- const config = loadConfig();
206
- const profileCache: Partial<Record<WebsearchBrowserFamily, BrowserProfile[]>> = {};
207
- let lastError: string | null = null;
208
-
209
- for (const route of config.routes) {
210
- throwIfAborted(signal);
211
-
212
- try {
213
- const result = await searchRoute(route, ctx, config, query, profileCache, signal);
214
- if (result) return result;
215
- } catch (error) {
216
- if (isAbortError(error, signal)) throw error;
217
- lastError = error instanceof Error ? error.message : String(error);
218
- }
219
- }
220
-
221
- throw new Error(lastError ?? "No configured websearch route is available.");
222
- }
223
-
224
198
  const COLLAPSED_RESULT_LINES = 10;
225
199
 
226
200
  function trimTrailingEmptyLines(lines: string[]): string[] {
@@ -261,11 +235,11 @@ export default function (pi: ExtensionAPI) {
261
235
  defineTool({
262
236
  name: "websearch",
263
237
  label: "Websearch",
264
- description: "Web search via Gemini, OpenAI, or Claude, leveraging Pi or browser sessions.",
265
- promptSnippet:
266
- "Search the web for current or external information unavailable in local files",
238
+ description: `Search the web for sourced answers. Output is limited to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first), including the truncation notice. Full truncated output is saved to a temporary file.`,
239
+ promptSnippet: "Discover web sources for current or external information",
267
240
  promptGuidelines: [
268
241
  "Use websearch for recent facts, live service behavior, or external documentation that is not already present in the repo.",
242
+ "When asked to read a supplied URL, fetch that page directly. Use websearch to discover sources or independently verify information.",
269
243
  "Do not use websearch when repository files or supplied context already answer the question.",
270
244
  ],
271
245
  parameters: Type.Object({
@@ -293,16 +267,27 @@ export default function (pi: ExtensionAPI) {
293
267
  return container;
294
268
  },
295
269
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
296
- const result = await runSearch(ctx, params.query, signal);
270
+ let result: SearchSummary;
271
+ try {
272
+ result = await runSearch(ctx, params.query, signal);
273
+ } catch (error) {
274
+ if (isAbortError(error, signal)) throw error;
275
+ const output = await limitOutput(
276
+ error instanceof Error ? error.message : String(error),
277
+ signal,
278
+ );
279
+ throw new Error(output.text);
280
+ }
281
+ const output = await limitOutput(result.result, signal);
297
282
  return {
298
- content: [{ type: "text", text: result.result }],
283
+ content: [{ type: "text", text: output.text }],
299
284
  details: {
285
+ ...output.details,
300
286
  route: result.route,
301
287
  backend: result.backend,
302
288
  authSource: result.authSource,
303
289
  browserName: result.browserName,
304
290
  profile: result.profile,
305
- accountLabel: result.accountLabel,
306
291
  sources: result.sources,
307
292
  },
308
293
  };
@@ -0,0 +1,51 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import {
5
+ DEFAULT_MAX_BYTES,
6
+ DEFAULT_MAX_LINES,
7
+ formatSize,
8
+ truncateHead,
9
+ withFileMutationQueue,
10
+ type TruncationResult,
11
+ } from "@earendil-works/pi-coding-agent";
12
+
13
+ interface OutputDetails {
14
+ truncation?: Omit<TruncationResult, "content">;
15
+ fullOutputPath?: string;
16
+ }
17
+
18
+ /** Limit the complete model-facing text; retain full output only in a private, published file. */
19
+ export async function limitOutput(
20
+ output: string,
21
+ signal?: AbortSignal,
22
+ ): Promise<{ text: string; details: OutputDetails }> {
23
+ signal?.throwIfAborted();
24
+ if (!truncateHead(output).truncated) return { text: output, details: {} };
25
+
26
+ const directory = await mkdtemp(path.join(os.tmpdir(), "pi-websearch-"));
27
+ try {
28
+ const fullOutputPath = path.join(directory, "output.txt");
29
+ const notice = `\n\n[Output truncated (${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} limit). Full output saved to: ${fullOutputPath}]`;
30
+ const maxBytes = DEFAULT_MAX_BYTES - Buffer.byteLength(notice, "utf8");
31
+ const maxLines = DEFAULT_MAX_LINES - (notice.split("\n").length - 1);
32
+ if (maxBytes <= 0 || maxLines <= 0) {
33
+ throw new Error("Websearch output path is too long for the truncation notice.");
34
+ }
35
+ const { content, ...truncation } = truncateHead(output, { maxBytes, maxLines });
36
+ await withFileMutationQueue(fullOutputPath, async () => {
37
+ await writeFile(fullOutputPath, output, {
38
+ encoding: "utf8",
39
+ mode: 0o600,
40
+ flag: "wx",
41
+ signal,
42
+ });
43
+ });
44
+ signal?.throwIfAborted();
45
+ // Published files remain readable after completion/shutdown, like native tool output files.
46
+ return { text: content + notice, details: { truncation, fullOutputPath } };
47
+ } catch (error) {
48
+ await rm(directory, { recursive: true, force: true });
49
+ throw error;
50
+ }
51
+ }
@@ -4,7 +4,7 @@ import { dedupeSources, extractMarkdownSources } from "../normalize.js";
4
4
  import type { WebsearchResult } from "../types.js";
5
5
  import type { PiModelSelection } from "./pi-model.shared.js";
6
6
  import { buildWebsearchPrompt, WEBSEARCH_SYSTEM_PROMPT } from "./search-prompt.shared.js";
7
- import { fetchText, withTimeout } from "./shared.js";
7
+ import { applyResolvedHeaders, fetchText, getResolvedHeader, withTimeout } from "./shared.js";
8
8
 
9
9
  export async function searchWithPiAnthropic(
10
10
  selection: PiModelSelection,
@@ -55,35 +55,33 @@ export async function searchWithPiAnthropic(
55
55
 
56
56
  function buildAnthropicHeaders(selection: PiModelSelection): Record<string, string> {
57
57
  const credential = getAnthropicCredential(selection);
58
- const headers = { ...selection.headers };
59
-
60
58
  if (isAnthropicOAuthToken(credential)) {
61
- return {
62
- ...headers,
63
- ...(hasHeader(headers, "authorization") || !credential
64
- ? {}
65
- : { authorization: `Bearer ${credential}` }),
59
+ return applyResolvedHeaders(
60
+ {
61
+ ...(credential ? { authorization: `Bearer ${credential}` } : {}),
62
+ "anthropic-version": "2023-06-01",
63
+ "anthropic-beta":
64
+ "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,web-search-2025-03-05",
65
+ "anthropic-dangerous-direct-browser-access": "true",
66
+ "content-type": "application/json",
67
+ accept: "application/json",
68
+ "x-app": "cli",
69
+ "user-agent": "claude-cli/2.1.62",
70
+ },
71
+ selection.headers,
72
+ );
73
+ }
74
+
75
+ return applyResolvedHeaders(
76
+ {
77
+ ...(credential ? { "x-api-key": credential } : {}),
66
78
  "anthropic-version": "2023-06-01",
67
- "anthropic-beta":
68
- "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,web-search-2025-03-05",
69
- "anthropic-dangerous-direct-browser-access": "true",
79
+ "anthropic-beta": "web-search-2025-03-05",
70
80
  "content-type": "application/json",
71
81
  accept: "application/json",
72
- "x-app": "cli",
73
- "user-agent": "claude-cli/2.1.62",
74
- };
75
- }
76
-
77
- return {
78
- ...headers,
79
- ...(hasHeader(headers, "authorization") || hasHeader(headers, "x-api-key") || !credential
80
- ? {}
81
- : { "x-api-key": credential }),
82
- "anthropic-version": "2023-06-01",
83
- "anthropic-beta": "web-search-2025-03-05",
84
- "content-type": "application/json",
85
- accept: "application/json",
86
- };
82
+ },
83
+ selection.headers,
84
+ );
87
85
  }
88
86
 
89
87
  function resolveAnthropicMessagesUrl(baseUrl?: string): string {
@@ -104,32 +102,17 @@ function getAnthropicCredential(selection: PiModelSelection): string | undefined
104
102
  return (
105
103
  selection.apiKey ??
106
104
  getBearerToken(selection.headers) ??
107
- getHeaderValue(selection.headers, "x-api-key")
105
+ getResolvedHeader(selection.headers, "x-api-key")
108
106
  );
109
107
  }
110
108
 
111
- function getBearerToken(headers?: Record<string, string>): string | undefined {
112
- const authorization = getHeaderValue(headers, "authorization");
109
+ function getBearerToken(headers?: Record<string, string | null>): string | undefined {
110
+ const authorization = getResolvedHeader(headers, "authorization");
113
111
  if (!authorization) return undefined;
114
112
  const match = authorization.match(/^Bearer\s+(.+)$/i);
115
113
  return match?.[1];
116
114
  }
117
115
 
118
- function hasHeader(headers: Record<string, string>, name: string): boolean {
119
- return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
120
- }
121
-
122
- function getHeaderValue(
123
- headers: Record<string, string> | undefined,
124
- name: string,
125
- ): string | undefined {
126
- if (!headers) return undefined;
127
- const key = Object.keys(headers).find(
128
- (headerName) => headerName.toLowerCase() === name.toLowerCase(),
129
- );
130
- return key ? headers[key] : undefined;
131
- }
132
-
133
116
  function isAnthropicOAuthToken(apiKey?: string): boolean {
134
117
  return Boolean(apiKey?.includes("sk-ant-oat"));
135
118
  }