@oh-my-pi/pi-coding-agent 3.37.1 → 4.0.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 (70) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +193 -47
  9. package/src/core/auth-storage.ts +16 -3
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +1 -1
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
@@ -44,6 +44,22 @@ export interface FindToolDetails {
44
44
  error?: string;
45
45
  }
46
46
 
47
+ /**
48
+ * Pluggable operations for the find tool.
49
+ * Override these to delegate file search to remote systems (e.g., SSH).
50
+ */
51
+ export interface FindOperations {
52
+ /** Check if path exists */
53
+ exists: (absolutePath: string) => Promise<boolean> | boolean;
54
+ /** Find files matching glob pattern. Returns relative paths. */
55
+ glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
56
+ }
57
+
58
+ export interface FindToolOptions {
59
+ /** Custom operations for find. Default: local filesystem + fd */
60
+ operations?: FindOperations;
61
+ }
62
+
47
63
  async function captureCommandOutput(
48
64
  command: string,
49
65
  args: string[],
@@ -91,7 +107,9 @@ async function captureCommandOutput(
91
107
  return { stdout, stderr, exitCode, aborted: scope.aborted };
92
108
  }
93
109
 
94
- export function createFindTool(session: ToolSession): AgentTool<typeof findSchema> {
110
+ export function createFindTool(session: ToolSession, options?: FindToolOptions): AgentTool<typeof findSchema> {
111
+ const customOps = options?.operations;
112
+
95
113
  return {
96
114
  name: "find",
97
115
  label: "Find",
@@ -117,12 +135,6 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
117
135
  signal?: AbortSignal,
118
136
  ) => {
119
137
  return untilAborted(signal, async () => {
120
- // Ensure fd is available
121
- const fdPath = await ensureTool("fd", true);
122
- if (!fdPath) {
123
- throw new Error("fd is not available and could not be downloaded");
124
- }
125
-
126
138
  const searchPath = resolveToCwd(searchDir || ".", session.cwd);
127
139
  const scopePath = (() => {
128
140
  const relative = path.relative(session.cwd, searchPath).replace(/\\/g, "/");
@@ -133,6 +145,73 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
133
145
  const includeHidden = hidden ?? false;
134
146
  const shouldSortByMtime = sortByMtime ?? false;
135
147
 
148
+ // If custom operations provided with glob, use that instead of fd
149
+ if (customOps?.glob) {
150
+ if (!(await customOps.exists(searchPath))) {
151
+ throw new Error(`Path not found: ${searchPath}`);
152
+ }
153
+
154
+ const results = await customOps.glob(pattern, searchPath, {
155
+ ignore: ["**/node_modules/**", "**/.git/**"],
156
+ limit: effectiveLimit,
157
+ });
158
+
159
+ if (results.length === 0) {
160
+ return {
161
+ content: [{ type: "text", text: "No files found matching pattern" }],
162
+ details: { scopePath, fileCount: 0, files: [], truncated: false },
163
+ };
164
+ }
165
+
166
+ // Relativize paths
167
+ const relativized = results.map((p) => {
168
+ if (p.startsWith(searchPath)) {
169
+ return p.slice(searchPath.length + 1);
170
+ }
171
+ return path.relative(searchPath, p);
172
+ });
173
+
174
+ const resultLimitReached = relativized.length >= effectiveLimit;
175
+ const rawOutput = relativized.join("\n");
176
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
177
+
178
+ let resultOutput = truncation.content;
179
+ const details: FindToolDetails = {
180
+ scopePath,
181
+ fileCount: relativized.length,
182
+ files: relativized,
183
+ truncated: resultLimitReached || truncation.truncated,
184
+ };
185
+ const notices: string[] = [];
186
+
187
+ if (resultLimitReached) {
188
+ notices.push(
189
+ `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
190
+ );
191
+ details.resultLimitReached = effectiveLimit;
192
+ }
193
+
194
+ if (truncation.truncated) {
195
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
196
+ details.truncation = truncation;
197
+ }
198
+
199
+ if (notices.length > 0) {
200
+ resultOutput += `\n\n[${notices.join(". ")}]`;
201
+ }
202
+
203
+ return {
204
+ content: [{ type: "text", text: resultOutput }],
205
+ details: Object.keys(details).length > 0 ? details : undefined,
206
+ };
207
+ }
208
+
209
+ // Default: use fd
210
+ const fdPath = await ensureTool("fd", true);
211
+ if (!fdPath) {
212
+ throw new Error("fd is not available and could not be downloaded");
213
+ }
214
+
136
215
  // Build fd arguments
137
216
  // When pattern contains path separators (e.g. "reports/**"), use --full-path
138
217
  // so fd matches against the full path, not just the filename.
@@ -70,7 +70,29 @@ export interface GrepToolDetails {
70
70
  error?: string;
71
71
  }
72
72
 
73
- export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchema> {
73
+ /**
74
+ * Pluggable operations for the grep tool.
75
+ * Override these to delegate search to remote systems (e.g., SSH).
76
+ */
77
+ export interface GrepOperations {
78
+ /** Check if path is a directory. Throws if path doesn't exist. */
79
+ isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
80
+ /** Read file contents for context lines */
81
+ readFile: (absolutePath: string) => Promise<string> | string;
82
+ }
83
+
84
+ const defaultGrepOperations: GrepOperations = {
85
+ isDirectory: async (p) => (await Bun.file(p).stat()).isDirectory(),
86
+ readFile: (p) => Bun.file(p).text(),
87
+ };
88
+
89
+ export interface GrepToolOptions {
90
+ /** Custom operations for grep. Default: local filesystem + ripgrep */
91
+ operations?: GrepOperations;
92
+ }
93
+
94
+ export function createGrepTool(session: ToolSession, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
95
+ const ops = options?.operations ?? defaultGrepOperations;
74
96
  return {
75
97
  name: "grep",
76
98
  label: "Grep",
@@ -120,14 +142,13 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
120
142
  const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/");
121
143
  return relative.length === 0 ? "." : relative;
122
144
  })();
123
- let searchStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
145
+
146
+ let isDirectory: boolean;
124
147
  try {
125
- searchStat = await Bun.file(searchPath).stat();
148
+ isDirectory = await ops.isDirectory(searchPath);
126
149
  } catch {
127
150
  throw new Error(`Path not found: ${searchPath}`);
128
151
  }
129
-
130
- const isDirectory = searchStat.isDirectory();
131
152
  const contextValue = context && context > 0 ? context : 0;
132
153
  const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
133
154
  const effectiveOutputMode = outputMode ?? "content";
@@ -150,7 +171,7 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
150
171
  if (!linesPromise) {
151
172
  linesPromise = (async () => {
152
173
  try {
153
- const content = await Bun.file(filePath).text();
174
+ const content = await ops.readFile(filePath);
154
175
  return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
155
176
  } catch {
156
177
  return [];
@@ -1,16 +1,16 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask";
2
- export { type BashToolDetails, createBashTool } from "./bash";
2
+ export { type BashOperations, type BashToolDetails, createBashTool } from "./bash";
3
3
  export { type CalculatorToolDetails, createCalculatorTool } from "./calculator";
4
4
  export { createCompleteTool } from "./complete";
5
- export { createEditTool } from "./edit";
5
+ export { createEditTool, type EditToolDetails } from "./edit";
6
6
  // Exa MCP tools (22 tools)
7
7
  export { exaTools } from "./exa/index";
8
8
  export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types";
9
- export { createFindTool, type FindToolDetails } from "./find";
9
+ export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions } from "./find";
10
10
  export { setPreferredImageProvider } from "./gemini-image";
11
11
  export { createGitTool, type GitToolDetails, gitTool } from "./git";
12
- export { createGrepTool, type GrepToolDetails } from "./grep";
13
- export { createLsTool, type LsToolDetails } from "./ls";
12
+ export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions } from "./grep";
13
+ export { createLsTool, type LsOperations, type LsToolDetails, type LsToolOptions } from "./ls";
14
14
  export {
15
15
  createLspTool,
16
16
  type FileDiagnosticsResult,
@@ -29,7 +29,16 @@ export { reportFindingTool, type SubmitReviewDetails } from "./review";
29
29
  export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook";
30
30
  export { createSshTool, type SSHToolDetails } from "./ssh";
31
31
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
32
- export type { TruncationResult } from "./truncate";
32
+ export {
33
+ DEFAULT_MAX_BYTES,
34
+ DEFAULT_MAX_LINES,
35
+ formatSize,
36
+ type TruncationOptions,
37
+ type TruncationResult,
38
+ truncateHead,
39
+ truncateLine,
40
+ truncateTail,
41
+ } from "./truncate";
33
42
  export { createWebFetchTool, type WebFetchToolDetails } from "./web-fetch";
34
43
  export {
35
44
  companyWebSearchTools,
@@ -28,6 +28,22 @@ const lsSchema = Type.Object({
28
28
 
29
29
  const DEFAULT_LIMIT = 500;
30
30
 
31
+ /**
32
+ * Pluggable operations for the ls tool.
33
+ * Override these to delegate directory listing to remote systems (e.g., SSH).
34
+ */
35
+ export interface LsOperations {
36
+ /** Check if path exists and return stats. Returns undefined if not found. */
37
+ stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean; mtimeMs: number } | undefined>;
38
+ /** Read directory entries (names only) */
39
+ readdir: (absolutePath: string) => Promise<string[]>;
40
+ }
41
+
42
+ export interface LsToolOptions {
43
+ /** Custom operations for directory listing. Default: local filesystem via Bun */
44
+ operations?: LsOperations;
45
+ }
46
+
31
47
  export interface LsToolDetails {
32
48
  entries?: string[];
33
49
  dirCount?: number;
@@ -37,7 +53,24 @@ export interface LsToolDetails {
37
53
  entryLimitReached?: number;
38
54
  }
39
55
 
40
- export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
56
+ /** Default operations using Bun APIs */
57
+ const defaultLsOperations: LsOperations = {
58
+ async stat(absolutePath: string) {
59
+ try {
60
+ const s = await Bun.file(absolutePath).stat();
61
+ return { isDirectory: () => s.isDirectory(), mtimeMs: s.mtimeMs };
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ },
66
+ async readdir(absolutePath: string) {
67
+ return Array.fromAsync(new Bun.Glob("*").scan({ cwd: absolutePath, dot: true, onlyFiles: false }));
68
+ },
69
+ };
70
+
71
+ export function createLsTool(session: ToolSession, options?: LsToolOptions): AgentTool<typeof lsSchema> {
72
+ const ops = options?.operations ?? defaultLsOperations;
73
+
41
74
  return {
42
75
  name: "ls",
43
76
  label: "Ls",
@@ -53,10 +86,8 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
53
86
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
54
87
 
55
88
  // Check if path exists and is a directory
56
- let dirStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
57
- try {
58
- dirStat = await Bun.file(dirPath).stat();
59
- } catch {
89
+ const dirStat = await ops.stat(dirPath);
90
+ if (!dirStat) {
60
91
  throw new Error(`Path not found: ${dirPath}`);
61
92
  }
62
93
 
@@ -67,7 +98,7 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
67
98
  // Read directory entries
68
99
  let entries: string[];
69
100
  try {
70
- entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: dirPath, dot: true, onlyFiles: false }));
101
+ entries = await ops.readdir(dirPath);
71
102
  } catch (error) {
72
103
  const message = error instanceof Error ? error.message : String(error);
73
104
  throw new Error(`Cannot read directory: ${message}`);
@@ -93,22 +124,22 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
93
124
  let suffix = "";
94
125
  let age = "";
95
126
 
96
- try {
97
- const entryStat = await Bun.file(fullPath).stat();
98
- if (entryStat.isDirectory()) {
99
- suffix = "/";
100
- dirCount += 1;
101
- } else {
102
- fileCount += 1;
103
- }
104
- // Calculate age from mtime
105
- const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000);
106
- age = formatAge(ageSeconds);
107
- } catch {
127
+ const entryStat = await ops.stat(fullPath);
128
+ if (!entryStat) {
108
129
  // Skip entries we can't stat
109
130
  continue;
110
131
  }
111
132
 
133
+ if (entryStat.isDirectory()) {
134
+ suffix = "/";
135
+ dirCount += 1;
136
+ } else {
137
+ fileCount += 1;
138
+ }
139
+ // Calculate age from mtime
140
+ const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000);
141
+ age = formatAge(ageSeconds);
142
+
112
143
  // Format: "name/ (2d ago)" or "name (just now)"
113
144
  const line = age ? `${entry}${suffix} (${age})` : entry + suffix;
114
145
  results.push(line);
@@ -5,6 +5,7 @@
5
5
  * tool renderers to ensure a unified TUI experience.
6
6
  */
7
7
 
8
+ import { homedir } from "node:os";
8
9
  import type { Theme } from "../../modes/interactive/theme/theme";
9
10
 
10
11
  // =============================================================================
@@ -490,7 +491,7 @@ export function truncateDiffByHunk(
490
491
  // =============================================================================
491
492
 
492
493
  export function shortenPath(filePath: string, homeDir?: string): string {
493
- const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE;
494
+ const home = homeDir ?? homedir();
494
495
  if (home && filePath.startsWith(home)) {
495
496
  return `~${filePath.slice(home.length)}`;
496
497
  }
@@ -197,20 +197,43 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
197
197
  // Note: Does not support --extension CLI flag or extension CLI flags
198
198
  const extensionRunner = session.extensionRunner;
199
199
  if (extensionRunner) {
200
- extensionRunner.initialize({
201
- getModel: () => session.model,
202
- sendMessageHandler: (message, options) => {
203
- session.sendCustomMessage(message, options).catch((e) => {
204
- console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
205
- });
200
+ extensionRunner.initialize(
201
+ // ExtensionActions
202
+ {
203
+ sendMessage: (message, options) => {
204
+ session.sendCustomMessage(message, options).catch((e) => {
205
+ console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
206
+ });
207
+ },
208
+ sendUserMessage: (content, options) => {
209
+ session.sendUserMessage(content, options).catch((e) => {
210
+ console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
211
+ });
212
+ },
213
+ appendEntry: (customType, data) => {
214
+ session.sessionManager.appendCustomEntry(customType, data);
215
+ },
216
+ getActiveTools: () => session.getActiveToolNames(),
217
+ getAllTools: () => session.getAllToolNames(),
218
+ setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
219
+ setModel: async (model) => {
220
+ const key = await session.modelRegistry.getApiKey(model);
221
+ if (!key) return false;
222
+ await session.setModel(model);
223
+ return true;
224
+ },
225
+ getThinkingLevel: () => session.thinkingLevel,
226
+ setThinkingLevel: (level) => session.setThinkingLevel(level),
206
227
  },
207
- appendEntryHandler: (customType, data) => {
208
- session.sessionManager.appendCustomEntry(customType, data);
228
+ // ExtensionContextActions
229
+ {
230
+ getModel: () => session.model,
231
+ isIdle: () => !session.isStreaming,
232
+ abort: () => session.abort(),
233
+ hasPendingMessages: () => session.queuedMessageCount > 0,
234
+ shutdown: () => {},
209
235
  },
210
- getActiveToolsHandler: () => session.getActiveToolNames(),
211
- getAllToolsHandler: () => session.getAllToolNames(),
212
- setActiveToolsHandler: (toolNamesList: string[]) => session.setActiveToolsByName(toolNamesList),
213
- });
236
+ );
214
237
  extensionRunner.onError((err) => {
215
238
  console.error(`Extension error (${err.extensionPath}): ${err.error}`);
216
239
  });
@@ -10,6 +10,7 @@
10
10
 
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { buildBetaHeader, claudeCodeHeaders, claudeCodeVersion } from "@oh-my-pi/pi-ai";
13
14
  import { getConfigDirPaths } from "../../../config";
14
15
  import type { AnthropicAuthConfig, AnthropicOAuthCredential, AuthJson, ModelsJson } from "./types";
15
16
 
@@ -162,46 +163,50 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
162
163
  return null;
163
164
  }
164
165
 
165
- /** Build headers for Anthropic API request */
166
- export function buildAnthropicHeaders(auth: AnthropicAuthConfig): Record<string, string> {
167
- const betas = ["web-search-2025-03-05"];
168
-
169
- if (auth.isOAuth) {
170
- // OAuth requires additional beta headers and stainless telemetry
171
- betas.push("oauth-2025-04-20", "claude-code-20250219", "prompt-caching-2024-07-31");
172
-
173
- return {
174
- "anthropic-version": "2023-06-01",
175
- authorization: `Bearer ${auth.apiKey}`,
176
- accept: "application/json",
177
- "content-type": "application/json",
178
- "anthropic-dangerous-direct-browser-access": "true",
179
- "anthropic-beta": betas.join(","),
180
- "user-agent": "claude-code/2.0.20",
181
- "x-app": "cli",
182
- // Stainless SDK telemetry headers (required for OAuth)
183
- "x-stainless-arch": process.arch,
184
- "x-stainless-lang": "js",
185
- "x-stainless-os": process.platform,
186
- "x-stainless-package-version": "1.0.0",
187
- "x-stainless-retry-count": "0",
188
- "x-stainless-runtime": "bun",
189
- "x-stainless-runtime-version": Bun.version,
190
- };
166
+ function isAnthropicBaseUrl(baseUrl: string): boolean {
167
+ try {
168
+ const url = new URL(baseUrl);
169
+ return url.protocol === "https:" && url.hostname === "api.anthropic.com";
170
+ } catch {
171
+ return false;
191
172
  }
173
+ }
192
174
 
193
- // Standard API key auth
194
- return {
195
- "anthropic-version": "2023-06-01",
196
- "x-api-key": auth.apiKey,
175
+ /** Build headers for Anthropic API request */
176
+ export function buildAnthropicHeaders(auth: AnthropicAuthConfig): Record<string, string> {
177
+ const baseBetas = auth.isOAuth
178
+ ? [
179
+ "claude-code-20250219",
180
+ "oauth-2025-04-20",
181
+ "interleaved-thinking-2025-05-14",
182
+ "fine-grained-tool-streaming-2025-05-14",
183
+ ]
184
+ : ["fine-grained-tool-streaming-2025-05-14"];
185
+ const betaHeader = buildBetaHeader(baseBetas, ["web-search-2025-03-05"]);
186
+
187
+ const headers: Record<string, string> = {
197
188
  accept: "application/json",
198
189
  "content-type": "application/json",
199
- "anthropic-beta": betas.join(","),
190
+ "anthropic-dangerous-direct-browser-access": "true",
191
+ "anthropic-beta": betaHeader,
192
+ "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
193
+ "x-app": "cli",
194
+ "accept-encoding": "gzip, deflate, br, zstd",
195
+ connection: "keep-alive",
196
+ ...claudeCodeHeaders,
200
197
  };
198
+
199
+ if (auth.isOAuth || !isAnthropicBaseUrl(auth.baseUrl)) {
200
+ headers.authorization = `Bearer ${auth.apiKey}`;
201
+ } else {
202
+ headers["x-api-key"] = auth.apiKey;
203
+ }
204
+
205
+ return headers;
201
206
  }
202
207
 
203
208
  /** Build API URL (OAuth requires ?beta=true) */
204
209
  export function buildAnthropicUrl(auth: AnthropicAuthConfig): string {
205
210
  const base = `${auth.baseUrl}/v1/messages`;
206
- return auth.isOAuth ? `${base}?beta=true` : base;
211
+ return `${base}?beta=true`;
207
212
  }
@@ -5,6 +5,7 @@
5
5
  * Returns synthesized answers with citations and source metadata.
6
6
  */
7
7
 
8
+ import { applyClaudeToolPrefix, buildAnthropicSystemBlocks, stripClaudeToolPrefix } from "@oh-my-pi/pi-ai";
8
9
  import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth, getEnv } from "../auth";
9
10
  import type {
10
11
  AnthropicApiResponse,
@@ -18,6 +19,12 @@ import { WebSearchProviderError } from "../types";
18
19
 
19
20
  const DEFAULT_MODEL = "claude-haiku-4-5";
20
21
  const DEFAULT_MAX_TOKENS = 4096;
22
+ const WEB_SEARCH_TOOL_NAME = "web_search";
23
+ const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
24
+
25
+ const applySearchToolPrefix = (name: string, isOAuth: boolean): string => {
26
+ return isOAuth ? applyClaudeToolPrefix(name) : name;
27
+ };
21
28
 
22
29
  export interface AnthropicSearchParams {
23
30
  query: string;
@@ -31,6 +38,21 @@ async function getModel(): Promise<string> {
31
38
  return (await getEnv("ANTHROPIC_SEARCH_MODEL")) ?? DEFAULT_MODEL;
32
39
  }
33
40
 
41
+ function buildSystemBlocks(
42
+ auth: AnthropicAuthConfig,
43
+ model: string,
44
+ systemPrompt?: string,
45
+ ): ReturnType<typeof buildAnthropicSystemBlocks> {
46
+ const includeClaudeCode = !model.startsWith("claude-3-5-haiku");
47
+ const extraInstructions = auth.isOAuth ? ["You are a helpful AI assistant with web search capabilities."] : [];
48
+
49
+ return buildAnthropicSystemBlocks(systemPrompt, {
50
+ includeClaudeCodeInstruction: includeClaudeCode,
51
+ includeCacheControl: auth.isOAuth,
52
+ extraInstructions,
53
+ });
54
+ }
55
+
34
56
  /** Call Anthropic API with web search */
35
57
  async function callWebSearch(
36
58
  auth: AnthropicAuthConfig,
@@ -42,34 +64,21 @@ async function callWebSearch(
42
64
  const url = buildAnthropicUrl(auth);
43
65
  const headers = buildAnthropicHeaders(auth);
44
66
 
45
- // Build system blocks
46
- const systemBlocks: Array<{ type: string; text: string; cache_control?: { type: string } }> = [];
47
-
48
- if (auth.isOAuth) {
49
- // OAuth requires Claude Code identity with cache_control
50
- systemBlocks.push({
51
- type: "text",
52
- text: "You are a helpful AI assistant with web search capabilities.",
53
- cache_control: { type: "ephemeral" },
54
- });
55
- }
56
-
57
- if (systemPrompt) {
58
- systemBlocks.push({
59
- type: "text",
60
- text: systemPrompt,
61
- ...(auth.isOAuth ? { cache_control: { type: "ephemeral" } } : {}),
62
- });
63
- }
67
+ const systemBlocks = buildSystemBlocks(auth, model, systemPrompt);
64
68
 
65
69
  const body: Record<string, unknown> = {
66
70
  model,
67
71
  max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
68
72
  messages: [{ role: "user", content: query }],
69
- tools: [{ type: "web_search_20250305", name: "web_search" }],
73
+ tools: [
74
+ {
75
+ type: WEB_SEARCH_TOOL_TYPE,
76
+ name: applySearchToolPrefix(WEB_SEARCH_TOOL_NAME, auth.isOAuth),
77
+ },
78
+ ],
70
79
  };
71
80
 
72
- if (systemBlocks.length > 0) {
81
+ if (systemBlocks && systemBlocks.length > 0) {
73
82
  body.system = systemBlocks;
74
83
  }
75
84
 
@@ -131,7 +140,11 @@ function parseResponse(response: AnthropicApiResponse): WebSearchResponse {
131
140
  const citations: WebSearchCitation[] = [];
132
141
 
133
142
  for (const block of response.content) {
134
- if (block.type === "server_tool_use" && block.name === "web_search") {
143
+ if (
144
+ block.type === "server_tool_use" &&
145
+ block.name &&
146
+ stripClaudeToolPrefix(block.name) === WEB_SEARCH_TOOL_NAME
147
+ ) {
135
148
  // Intermediate search query
136
149
  if (block.input?.query) {
137
150
  searchQueries.push(block.input.query);