@oh-my-pi/pi-coding-agent 13.5.3 → 13.5.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.5.5] - 2026-03-01
6
+
7
+ ### Added
8
+
9
+ - Added Kagi web search provider (Search API v0) with related searches support and automatic `KAGI_API_KEY` detection
10
+
11
+ ## [13.5.4] - 2026-03-01
12
+ ### Added
13
+
14
+ - Added `authServerUrl` field to `AuthDetectionResult` to capture OAuth server metadata from `Mcp-Auth-Server` headers
15
+ - Added `extractMcpAuthServerUrl()` function to parse and validate `Mcp-Auth-Server` URLs from error messages
16
+ - Added support for `/.well-known/oauth-protected-resource` discovery endpoint to resolve authorization servers
17
+ - Added recursive auth server discovery to follow `authorization_servers` references when discovering OAuth endpoints
18
+
19
+ - Added `omp agents unpack` CLI subcommand to export bundled subagent definitions to `~/.omp/agent/agents` by default, with `--project` support for `./.omp/agents`
20
+ ### Changed
21
+
22
+ - Enhanced `discoverOAuthEndpoints()` to accept optional `authServerUrl` parameter and query both auth server and resource server for OAuth metadata
23
+ - Improved OAuth metadata extraction to handle additional field name variations (`clientId`, `default_client_id`, `public_client_id`)
24
+ - Refactored OAuth endpoint discovery logic into reusable `findEndpoints()` helper for consistent metadata parsing across multiple sources
25
+ - Task subagents now strip inherited `AGENTS.md` context files and the task tool prompt no longer warns against repeating AGENTS guidance, aligning subagent context with explicit task inputs ([#233](https://github.com/can1357/oh-my-pi/issues/233))
26
+
27
+ ### Fixed
28
+
29
+ - Fixed MCP OAuth discovery to honor `Mcp-Auth-Server` metadata and resolve authorization endpoints from the declared auth server, restoring Figma MCP login URLs with `client_id` ([#235](https://github.com/can1357/oh-my-pi/issues/235))
30
+
5
31
  ## [13.5.3] - 2026-03-01
6
32
 
7
33
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.5.3",
4
+ "version": "13.5.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.5.3",
45
- "@oh-my-pi/pi-agent-core": "13.5.3",
46
- "@oh-my-pi/pi-ai": "13.5.3",
47
- "@oh-my-pi/pi-natives": "13.5.3",
48
- "@oh-my-pi/pi-tui": "13.5.3",
49
- "@oh-my-pi/pi-utils": "13.5.3",
44
+ "@oh-my-pi/omp-stats": "13.5.5",
45
+ "@oh-my-pi/pi-agent-core": "13.5.5",
46
+ "@oh-my-pi/pi-ai": "13.5.5",
47
+ "@oh-my-pi/pi-natives": "13.5.5",
48
+ "@oh-my-pi/pi-tui": "13.5.5",
49
+ "@oh-my-pi/pi-utils": "13.5.5",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Agents CLI command handlers.
3
+ *
4
+ * Handles `omp agents unpack` for writing bundled agent definitions to disk.
5
+ */
6
+ import * as fs from "node:fs/promises";
7
+ import * as path from "node:path";
8
+ import { getAgentDir, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { YAML } from "bun";
10
+ import chalk from "chalk";
11
+ import { theme } from "../modes/theme/theme";
12
+ import { loadBundledAgents } from "../task/agents";
13
+ import type { AgentDefinition } from "../task/types";
14
+
15
+ export type AgentsAction = "unpack";
16
+
17
+ export interface AgentsCommandArgs {
18
+ action: AgentsAction;
19
+ flags: {
20
+ force?: boolean;
21
+ json?: boolean;
22
+ dir?: string;
23
+ user?: boolean;
24
+ project?: boolean;
25
+ };
26
+ }
27
+
28
+ interface UnpackResult {
29
+ targetDir: string;
30
+ total: number;
31
+ written: string[];
32
+ skipped: string[];
33
+ }
34
+
35
+ function writeStdout(line: string): void {
36
+ process.stdout.write(`${line}\n`);
37
+ }
38
+
39
+ function resolveTargetDir(flags: AgentsCommandArgs["flags"]): string {
40
+ if (flags.dir && flags.dir.trim().length > 0) {
41
+ return path.resolve(getProjectDir(), flags.dir.trim());
42
+ }
43
+
44
+ if (flags.user && flags.project) {
45
+ throw new Error("Choose either --user or --project, not both.");
46
+ }
47
+
48
+ if (flags.project) {
49
+ return path.resolve(getProjectDir(), ".omp", "agents");
50
+ }
51
+
52
+ return path.join(getAgentDir(), "agents");
53
+ }
54
+
55
+ function toFrontmatter(agent: AgentDefinition): Record<string, unknown> {
56
+ const frontmatter: Record<string, unknown> = {
57
+ name: agent.name,
58
+ description: agent.description,
59
+ };
60
+
61
+ if (agent.tools && agent.tools.length > 0) frontmatter.tools = agent.tools;
62
+ if (agent.spawns !== undefined) frontmatter.spawns = agent.spawns;
63
+ if (agent.model && agent.model.length > 0) frontmatter.model = agent.model;
64
+ if (agent.thinkingLevel) frontmatter["thinking-level"] = agent.thinkingLevel;
65
+ if (agent.output !== undefined) frontmatter.output = agent.output;
66
+ if (agent.blocking) frontmatter.blocking = true;
67
+
68
+ return frontmatter;
69
+ }
70
+
71
+ function serializeAgent(agent: AgentDefinition): string {
72
+ const frontmatter = YAML.stringify(toFrontmatter(agent), null, 2).trimEnd();
73
+ const body = agent.systemPrompt.trim();
74
+ return `---\n${frontmatter}\n---\n\n${body}\n`;
75
+ }
76
+
77
+ async function unpackBundledAgents(flags: AgentsCommandArgs["flags"]): Promise<UnpackResult> {
78
+ const targetDir = resolveTargetDir(flags);
79
+ await fs.mkdir(targetDir, { recursive: true });
80
+
81
+ const bundledAgents = [...loadBundledAgents()].sort((a, b) => a.name.localeCompare(b.name));
82
+ const written: string[] = [];
83
+ const skipped: string[] = [];
84
+
85
+ for (const agent of bundledAgents) {
86
+ const filePath = path.join(targetDir, `${agent.name}.md`);
87
+ if (!flags.force) {
88
+ try {
89
+ await fs.stat(filePath);
90
+ skipped.push(filePath);
91
+ continue;
92
+ } catch (error) {
93
+ if (!isEnoent(error)) throw error;
94
+ }
95
+ }
96
+
97
+ await Bun.write(filePath, serializeAgent(agent));
98
+ written.push(filePath);
99
+ }
100
+
101
+ return {
102
+ targetDir,
103
+ total: bundledAgents.length,
104
+ written,
105
+ skipped,
106
+ };
107
+ }
108
+
109
+ export async function runAgentsCommand(cmd: AgentsCommandArgs): Promise<void> {
110
+ switch (cmd.action) {
111
+ case "unpack": {
112
+ const result = await unpackBundledAgents(cmd.flags);
113
+ if (cmd.flags.json) {
114
+ writeStdout(JSON.stringify(result, null, 2));
115
+ return;
116
+ }
117
+
118
+ writeStdout(chalk.bold(`Bundled agents: ${result.total}`));
119
+ writeStdout(chalk.dim(`Target directory: ${result.targetDir}`));
120
+ writeStdout(chalk.green(`${theme.status.success} Written: ${result.written.length}`));
121
+ if (result.skipped.length > 0) {
122
+ writeStdout(
123
+ chalk.yellow(
124
+ `${theme.status.warning} Skipped existing: ${result.skipped.length} (use --force to overwrite)`,
125
+ ),
126
+ );
127
+ }
128
+
129
+ for (const filePath of result.written) {
130
+ writeStdout(chalk.dim(` + ${filePath}`));
131
+ }
132
+ for (const filePath of result.skipped) {
133
+ writeStdout(chalk.dim(` = ${filePath}`));
134
+ }
135
+ return;
136
+ }
137
+ }
138
+ }
package/src/cli/args.ts CHANGED
@@ -247,7 +247,10 @@ ${chalk.bold("Available Tools (all enabled by default):")}
247
247
  fetch - Fetch and process URLs
248
248
  web_search - Search the web
249
249
  ask - Ask user questions (interactive mode only)
250
- `;
250
+
251
+ ${chalk.bold("Useful Commands:")}
252
+ omp agents unpack - Export bundled subagents to ~/.omp/agent/agents (default)
253
+ omp agents unpack --project - Export bundled subagents to ./.omp/agents`;
251
254
  }
252
255
 
253
256
  export function printHelp(): void {
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ process.title = APP_NAME;
16
16
 
17
17
  const commands: CommandEntry[] = [
18
18
  { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
19
+ { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
19
20
  { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
20
21
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
21
22
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Manage bundled task agents.
3
+ */
4
+ import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli";
5
+ import { type AgentsAction, type AgentsCommandArgs, runAgentsCommand } from "../cli/agents-cli";
6
+ import { initTheme } from "../modes/theme/theme";
7
+
8
+ const ACTIONS: AgentsAction[] = ["unpack"];
9
+
10
+ export default class Agents extends Command {
11
+ static description = "Manage bundled task agents";
12
+
13
+ static args = {
14
+ action: Args.string({
15
+ description: "Agents action",
16
+ required: false,
17
+ options: ACTIONS,
18
+ }),
19
+ };
20
+
21
+ static flags = {
22
+ force: Flags.boolean({ char: "f", description: "Overwrite existing agent files" }),
23
+ json: Flags.boolean({ description: "Output JSON" }),
24
+ dir: Flags.string({ description: "Output directory (overrides --user/--project)" }),
25
+ user: Flags.boolean({ description: "Write to ~/.omp/agent/agents (default)" }),
26
+ project: Flags.boolean({ description: "Write to ./.omp/agents" }),
27
+ };
28
+
29
+ static examples = [
30
+ "# Export bundled agents into user config (default)\n omp agents unpack",
31
+ "# Export bundled agents into project config\n omp agents unpack --project",
32
+ "# Overwrite existing local agent files\n omp agents unpack --project --force",
33
+ "# Export into a custom directory\n omp agents unpack --dir ./tmp/agents --json",
34
+ ];
35
+
36
+ async run(): Promise<void> {
37
+ const { args, flags } = await this.parse(Agents);
38
+ if (!args.action) {
39
+ renderCommandHelp("omp", "agents", Agents);
40
+ return;
41
+ }
42
+
43
+ const cmd: AgentsCommandArgs = {
44
+ action: args.action as AgentsAction,
45
+ flags: {
46
+ force: flags.force,
47
+ json: flags.json,
48
+ dir: flags.dir,
49
+ user: flags.user,
50
+ project: flags.project,
51
+ },
52
+ };
53
+
54
+ await initTheme();
55
+ await runAgentsCommand(cmd);
56
+ }
57
+ }
@@ -757,6 +757,7 @@ export const SETTINGS_SCHEMA = {
757
757
  "anthropic",
758
758
  "gemini",
759
759
  "codex",
760
+ "kagi",
760
761
  "synthetic",
761
762
  ] as const,
762
763
  default: "auto",
@@ -16,9 +16,25 @@ export interface AuthDetectionResult {
16
16
  requiresAuth: boolean;
17
17
  authType?: "oauth" | "apikey" | "unknown";
18
18
  oauth?: OAuthEndpoints;
19
+ authServerUrl?: string;
19
20
  message?: string;
20
21
  }
21
22
 
23
+ function parseMcpAuthServerUrl(errorMessage: string): string | undefined {
24
+ const match = errorMessage.match(/Mcp-Auth-Server:\s*([^;\]\s]+)/i);
25
+ if (!match || !match[1]) return undefined;
26
+
27
+ try {
28
+ return new URL(match[1]).toString();
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ export function extractMcpAuthServerUrl(error: Error): string | undefined {
35
+ return parseMcpAuthServerUrl(error.message);
36
+ }
37
+
22
38
  /**
23
39
  * Detect if an error indicates authentication is required.
24
40
  * Checks for common auth error patterns.
@@ -178,6 +194,8 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
178
194
  return { requiresAuth: false };
179
195
  }
180
196
 
197
+ const authServerUrl = extractMcpAuthServerUrl(error);
198
+
181
199
  // Try to extract OAuth endpoints
182
200
  const oauth = extractOAuthEndpoints(error);
183
201
 
@@ -186,6 +204,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
186
204
  requiresAuth: true,
187
205
  authType: "oauth",
188
206
  oauth,
207
+ authServerUrl,
189
208
  message: "Server requires OAuth authentication. Launching authorization flow...",
190
209
  };
191
210
  }
@@ -201,6 +220,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
201
220
  return {
202
221
  requiresAuth: true,
203
222
  authType: "apikey",
223
+ authServerUrl,
204
224
  message: "Server requires API key authentication.",
205
225
  };
206
226
  }
@@ -209,6 +229,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
209
229
  return {
210
230
  requiresAuth: true,
211
231
  authType: "unknown",
232
+ authServerUrl,
212
233
  message: "Server requires authentication but type could not be determined.",
213
234
  };
214
235
  }
@@ -217,56 +238,110 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
217
238
  * Try to discover OAuth endpoints by querying the server's well-known endpoints.
218
239
  * This is a fallback when error responses don't include OAuth metadata.
219
240
  */
220
- export async function discoverOAuthEndpoints(serverUrl: string): Promise<OAuthEndpoints | null> {
241
+ export async function discoverOAuthEndpoints(
242
+ serverUrl: string,
243
+ authServerUrl?: string,
244
+ ): Promise<OAuthEndpoints | null> {
221
245
  const wellKnownPaths = [
222
246
  "/.well-known/oauth-authorization-server",
223
247
  "/.well-known/openid-configuration",
248
+ "/.well-known/oauth-protected-resource",
224
249
  "/oauth/metadata",
225
250
  "/.mcp/auth",
226
251
  "/authorize", // Some MCP servers expose OAuth config here
227
252
  ];
253
+ const urlsToQuery = [authServerUrl, serverUrl].filter((value): value is string => Boolean(value));
254
+ const visitedAuthServers = new Set<string>();
228
255
 
229
- for (const path of wellKnownPaths) {
230
- try {
231
- const url = new URL(path, serverUrl);
232
- const response = await fetch(url.toString(), {
233
- method: "GET",
234
- headers: { Accept: "application/json" },
235
- });
256
+ const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
257
+ if (metadata.authorization_endpoint && metadata.token_endpoint) {
258
+ const scopesSupported = Array.isArray(metadata.scopes_supported)
259
+ ? metadata.scopes_supported.filter((scope): scope is string => typeof scope === "string").join(" ")
260
+ : undefined;
261
+ return {
262
+ authorizationUrl: String(metadata.authorization_endpoint),
263
+ tokenUrl: String(metadata.token_endpoint),
264
+ clientId:
265
+ typeof metadata.client_id === "string"
266
+ ? metadata.client_id
267
+ : typeof metadata.clientId === "string"
268
+ ? metadata.clientId
269
+ : typeof metadata.default_client_id === "string"
270
+ ? metadata.default_client_id
271
+ : typeof metadata.public_client_id === "string"
272
+ ? metadata.public_client_id
273
+ : undefined,
274
+ scopes:
275
+ scopesSupported ||
276
+ (typeof metadata.scopes === "string"
277
+ ? metadata.scopes
278
+ : typeof metadata.scope === "string"
279
+ ? metadata.scope
280
+ : undefined),
281
+ };
282
+ }
236
283
 
237
- if (response.ok) {
238
- const metadata = (await response.json()) as Record<string, any>;
284
+ if (metadata.oauth || metadata.authorization || metadata.auth) {
285
+ const oauthData = (metadata.oauth || metadata.authorization || metadata.auth) as Record<string, unknown>;
286
+ if (typeof oauthData.authorization_url === "string" && typeof oauthData.token_url === "string") {
287
+ return {
288
+ authorizationUrl: oauthData.authorization_url || String(oauthData.authorizationUrl),
289
+ tokenUrl: oauthData.token_url || String(oauthData.tokenUrl),
290
+ clientId:
291
+ typeof oauthData.client_id === "string"
292
+ ? oauthData.client_id
293
+ : typeof oauthData.clientId === "string"
294
+ ? oauthData.clientId
295
+ : typeof oauthData.default_client_id === "string"
296
+ ? oauthData.default_client_id
297
+ : typeof oauthData.public_client_id === "string"
298
+ ? oauthData.public_client_id
299
+ : undefined,
300
+ scopes:
301
+ typeof oauthData.scopes === "string"
302
+ ? oauthData.scopes
303
+ : typeof oauthData.scope === "string"
304
+ ? oauthData.scope
305
+ : undefined,
306
+ };
307
+ }
308
+ }
239
309
 
240
- // Check for standard OAuth discovery format
241
- if (metadata.authorization_endpoint && metadata.token_endpoint) {
242
- return {
243
- authorizationUrl: metadata.authorization_endpoint,
244
- tokenUrl: metadata.token_endpoint,
245
- clientId:
246
- metadata.client_id || metadata.clientId || metadata.default_client_id || metadata.public_client_id,
247
- scopes: metadata.scopes_supported?.join(" ") || metadata.scopes || metadata.scope,
248
- };
249
- }
310
+ return null;
311
+ };
312
+
313
+ for (const baseUrl of urlsToQuery) {
314
+ visitedAuthServers.add(baseUrl);
315
+ for (const path of wellKnownPaths) {
316
+ try {
317
+ const url = new URL(path, baseUrl);
318
+ const response = await fetch(url.toString(), {
319
+ method: "GET",
320
+ headers: { Accept: "application/json" },
321
+ });
250
322
 
251
- // Check for MCP-specific format
252
- if (metadata.oauth || metadata.authorization || metadata.auth) {
253
- const oauthData = metadata.oauth || metadata.authorization || metadata.auth;
254
- if (oauthData.authorization_url && oauthData.token_url) {
255
- return {
256
- authorizationUrl: oauthData.authorization_url || oauthData.authorizationUrl,
257
- tokenUrl: oauthData.token_url || oauthData.tokenUrl,
258
- clientId:
259
- oauthData.client_id ||
260
- oauthData.clientId ||
261
- oauthData.default_client_id ||
262
- oauthData.public_client_id,
263
- scopes: oauthData.scopes || oauthData.scope,
264
- };
323
+ if (response.ok) {
324
+ const metadata = (await response.json()) as Record<string, unknown>;
325
+ const endpoints = findEndpoints(metadata);
326
+ if (endpoints) return endpoints;
327
+
328
+ if (path === "/.well-known/oauth-protected-resource") {
329
+ const authServers = Array.isArray(metadata.authorization_servers)
330
+ ? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
331
+ : [];
332
+
333
+ for (const discoveredAuthServer of authServers) {
334
+ if (visitedAuthServers.has(discoveredAuthServer)) {
335
+ continue;
336
+ }
337
+ const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
338
+ if (discovered) return discovered;
339
+ }
265
340
  }
266
341
  }
342
+ } catch {
343
+ // Ignore errors, try next path
267
344
  }
268
- } catch {
269
- // Ignore errors, try next path
270
345
  }
271
346
  }
272
347
 
@@ -947,7 +947,7 @@ export class MCPAddWizard extends Container {
947
947
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
948
948
  if (!oauth && this.#state.transport !== "stdio" && this.#state.url) {
949
949
  try {
950
- oauth = await discoverOAuthEndpoints(this.#state.url);
950
+ oauth = await discoverOAuthEndpoints(this.#state.url, authResult.authServerUrl);
951
951
  } catch {
952
952
  // Ignore discovery failures and fallback to manual auth.
953
953
  }
@@ -194,6 +194,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
194
194
  { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" },
195
195
  { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
196
196
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
197
+ { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY" },
197
198
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
198
199
  ],
199
200
  "providers.image": [
@@ -983,19 +983,6 @@ function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
983
983
  return "exhausted";
984
984
  }
985
985
 
986
- function isZeroUsage(limit: UsageLimit): boolean {
987
- const amount = limit.amount;
988
- if (amount.usedFraction !== undefined) return amount.usedFraction <= 0;
989
- if (amount.used !== undefined) return amount.used <= 0;
990
- if (amount.unit === "percent" && amount.used !== undefined) return amount.used <= 0;
991
- if (amount.remainingFraction !== undefined) return amount.remainingFraction >= 1;
992
- return false;
993
- }
994
-
995
- function isZeroUsageGroup(limits: UsageLimit[]): boolean {
996
- return limits.length > 0 && limits.every(limit => isZeroUsage(limit));
997
- }
998
-
999
986
  function formatAggregateAmount(limits: UsageLimit[]): string {
1000
987
  const fractions = limits
1001
988
  .map(limit => resolveFraction(limit))
@@ -1117,13 +1104,6 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
1117
1104
  }
1118
1105
  }
1119
1106
 
1120
- const providerAllZero = isZeroUsageGroup(Array.from(limitGroups.values()).flatMap(group => group.limits));
1121
- if (providerAllZero) {
1122
- const providerTitle = `${resolveStatusIcon("ok", uiTheme)} ${uiTheme.fg("accent", `${providerName} (0%)`)}`;
1123
- lines.push(uiTheme.bold(providerTitle));
1124
- continue;
1125
- }
1126
-
1127
1107
  lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
1128
1108
 
1129
1109
  for (const group of limitGroups.values()) {
@@ -1144,18 +1124,6 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
1144
1124
 
1145
1125
  const status = resolveAggregateStatus(sortedLimits);
1146
1126
  const statusIcon = resolveStatusIcon(status, uiTheme);
1147
- if (isZeroUsageGroup(sortedLimits)) {
1148
- const resetText = resolveResetRange(sortedLimits, nowMs);
1149
- const resetSuffix = resetText ? ` | ${resetText}` : "";
1150
- const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
1151
- lines.push(
1152
- `${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix} ${uiTheme.fg(
1153
- "dim",
1154
- `0%${resetSuffix}`,
1155
- )}`.trim(),
1156
- );
1157
- continue;
1158
- }
1159
1127
 
1160
1128
  const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
1161
1129
  lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
@@ -264,7 +264,7 @@ export class MCPCommandController {
264
264
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
265
265
  if (!oauth && finalConfig.url) {
266
266
  try {
267
- oauth = await discoverOAuthEndpoints(finalConfig.url);
267
+ oauth = await discoverOAuthEndpoints(finalConfig.url, authResult.authServerUrl);
268
268
  } catch {
269
269
  // Ignore discovery error and handle below.
270
270
  }
@@ -574,7 +574,7 @@ export class MCPCommandController {
574
574
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
575
575
 
576
576
  if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
577
- oauth = await discoverOAuthEndpoints(config.url);
577
+ oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl);
578
578
  }
579
579
 
580
580
  if (!oauth) {
@@ -19,7 +19,6 @@ Subagents lack your conversation history. Every decision, file content, and user
19
19
  </parameters>
20
20
 
21
21
  <critical>
22
- - **MUST NOT** include AGENTS.md rules, coding conventions, or style guidelines — subagents already have them.
23
22
  - **MUST NOT** duplicate shared constraints across assignments — put them in `context` once.
24
23
  - **MUST NOT** tell tasks to run project-wide build/test/lint. Parallel agents share the working tree; each task edits, stops. Caller verifies after all complete.
25
24
  - For large payloads (traces, JSON blobs), write to `local://<path>` and pass the path in context.
@@ -47,7 +46,7 @@ Each task: **at most 3–5 files**. Globs in file paths, "update all", or packag
47
46
  ```
48
47
  ## Goal ← one sentence: what the batch accomplishes
49
48
  ## Non-goals ← what tasks must not touch
50
- ## Constraints ← MUST/MUST NOT rules beyond AGENTS.md; session decisions
49
+ ## Constraints ← MUST/MUST NOT rules and session decisions
51
50
  ## API Contract ← exact types/signatures if tasks share an interface (omit if N/A)
52
51
  ## Acceptance ← definition of done; build/lint runs AFTER all tasks complete
53
52
  ```
@@ -62,7 +61,7 @@ Each task: **at most 3–5 files**. Globs in file paths, "update all", or packag
62
61
 
63
62
  <checklist>
64
63
  Before invoking:
65
- - `context` contains only session-specific info not in AGENTS.md
64
+ - `context` contains only session-specific info
66
65
  - Every `assignment` follows the template; no one-liners; edge cases covered
67
66
  - Tasks are truly parallel — you can articulate why none depends on another's output
68
67
  - File paths are explicit; no globs
@@ -38,7 +38,13 @@ import type {
38
38
  Usage,
39
39
  UsageReport,
40
40
  } from "@oh-my-pi/pi-ai";
41
- import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
41
+ import {
42
+ calculateRateLimitBackoffMs,
43
+ isContextOverflow,
44
+ modelsAreEqual,
45
+ parseRateLimitReason,
46
+ supportsXhigh,
47
+ } from "@oh-my-pi/pi-ai";
42
48
  import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
43
49
  import type { AsyncJob, AsyncJobManager } from "../async";
44
50
  import type { Rule } from "../capability/rule";
@@ -3959,7 +3965,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3959
3965
  }
3960
3966
 
3961
3967
  #isUsageLimitErrorMessage(errorMessage: string): boolean {
3962
- return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
3968
+ return /usage.?limit|usage_limit_reached|limit_reached|quota.?exceeded|resource.?exhausted/i.test(errorMessage);
3963
3969
  }
3964
3970
 
3965
3971
  #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
@@ -4004,6 +4010,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4004
4010
  }
4005
4011
  }
4006
4012
 
4013
+ // Smart Fallback if no exact headers found
4007
4014
  return undefined;
4008
4015
  }
4009
4016
 
@@ -4043,7 +4050,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
4043
4050
  let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
4044
4051
 
4045
4052
  if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
4046
- const retryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
4053
+ const retryAfterMs =
4054
+ this.#parseRetryAfterMsFromError(errorMessage) ??
4055
+ calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
4047
4056
  const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
4048
4057
  this.model.provider,
4049
4058
  this.sessionId,
@@ -4054,6 +4063,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
4054
4063
  );
4055
4064
  if (switched) {
4056
4065
  delayMs = 0;
4066
+ } else if (retryAfterMs > delayMs) {
4067
+ // No more accounts to switch to — wait out the backoff
4068
+ delayMs = retryAfterMs;
4057
4069
  }
4058
4070
  }
4059
4071
 
package/src/task/index.ts CHANGED
@@ -691,7 +691,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
691
691
  // Build full prompts with context prepended
692
692
  const tasksWithContext = tasksWithUniqueIds.map(t => renderTemplate(context, t));
693
693
  const availableSkills = [...(this.session.skills ?? [])];
694
- const contextFiles = this.session.contextFiles;
694
+ const contextFiles = this.session.contextFiles?.filter(
695
+ file => path.basename(file.path).toLowerCase() !== "agents.md",
696
+ );
695
697
  const promptTemplates = this.session.promptTemplates;
696
698
 
697
699
  // Initialize progress for all tasks
@@ -34,7 +34,20 @@ export const webSearchSchema = Type.Object({
34
34
  query: Type.String({ description: "Search query" }),
35
35
  provider: Type.Optional(
36
36
  StringEnum(
37
- ["auto", "exa", "brave", "jina", "kimi", "zai", "anthropic", "perplexity", "gemini", "codex", "synthetic"],
37
+ [
38
+ "auto",
39
+ "exa",
40
+ "brave",
41
+ "jina",
42
+ "kimi",
43
+ "zai",
44
+ "anthropic",
45
+ "perplexity",
46
+ "gemini",
47
+ "codex",
48
+ "kagi",
49
+ "synthetic",
50
+ ],
38
51
  {
39
52
  description: "Search provider (default: auto)",
40
53
  },
@@ -64,6 +77,7 @@ export type SearchParams = {
64
77
  | "perplexity"
65
78
  | "gemini"
66
79
  | "codex"
80
+ | "kagi"
67
81
  | "synthetic";
68
82
  recency?: "day" | "week" | "month" | "year";
69
83
  limit?: number;
@@ -5,6 +5,7 @@ import { CodexProvider } from "./providers/codex";
5
5
  import { ExaProvider } from "./providers/exa";
6
6
  import { GeminiProvider } from "./providers/gemini";
7
7
  import { JinaProvider } from "./providers/jina";
8
+ import { KagiProvider } from "./providers/kagi";
8
9
  import { KimiProvider } from "./providers/kimi";
9
10
  import { PerplexityProvider } from "./providers/perplexity";
10
11
  import { SyntheticProvider } from "./providers/synthetic";
@@ -24,6 +25,7 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
24
25
  anthropic: new AnthropicProvider(),
25
26
  gemini: new GeminiProvider(),
26
27
  codex: new CodexProvider(),
28
+ kagi: new KagiProvider(),
27
29
  synthetic: new SyntheticProvider(),
28
30
  } as const;
29
31
 
@@ -37,6 +39,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
37
39
  "codex",
38
40
  "zai",
39
41
  "exa",
42
+ "kagi",
40
43
  "synthetic",
41
44
  ];
42
45
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import {
9
9
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
+ extractRetryDelay,
10
11
  getAntigravityHeaders,
11
12
  getGeminiCliHeaders,
12
13
  refreshGoogleCloudToken,
@@ -23,6 +24,9 @@ const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
23
24
  const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
24
25
  const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
25
26
  const DEFAULT_MODEL = "gemini-2.5-flash";
27
+ const MAX_RETRIES = 3;
28
+ const BASE_DELAY_MS = 1000;
29
+ const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
26
30
 
27
31
  interface GeminiToolParams {
28
32
  google_search?: Record<string, unknown>;
@@ -270,46 +274,83 @@ async function callGeminiSearch(
270
274
  (requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
271
275
  }
272
276
  let response: Response | undefined;
277
+ let rateLimitTimeSpent = 0;
278
+ let lastError: Error | undefined;
279
+
273
280
  for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
274
281
  const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
275
- try {
276
- response = await fetch(url, {
277
- method: "POST",
278
- headers: {
279
- Authorization: `Bearer ${auth.accessToken}`,
280
- "Content-Type": "application/json",
281
- Accept: "text/event-stream",
282
- ...headers,
283
- },
284
- body: JSON.stringify(requestBody),
285
- });
286
- } catch (error) {
287
- if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
288
- continue;
282
+
283
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
284
+ try {
285
+ response = await fetch(url, {
286
+ method: "POST",
287
+ headers: {
288
+ Authorization: `Bearer ${auth.accessToken}`,
289
+ "Content-Type": "application/json",
290
+ Accept: "text/event-stream",
291
+ ...headers,
292
+ },
293
+ body: JSON.stringify(requestBody),
294
+ });
295
+ } catch (error) {
296
+ if (attempt < MAX_RETRIES) {
297
+ await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
298
+ continue;
299
+ }
300
+
301
+ if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
302
+ break;
303
+ }
304
+
305
+ throw error;
289
306
  }
290
- throw error;
291
- }
292
307
 
293
- if (response.ok) {
294
- break;
295
- }
308
+ if (response.ok) {
309
+ break;
310
+ }
296
311
 
297
- const errorText = await response.text();
298
- const isRetryableStatus =
299
- response.status === 429 ||
300
- response.status === 500 ||
301
- response.status === 502 ||
302
- response.status === 503 ||
303
- response.status === 504;
304
- if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
305
- continue;
312
+ const errorText = await response.text();
313
+ const isRetryableStatus =
314
+ response.status === 429 ||
315
+ response.status === 500 ||
316
+ response.status === 502 ||
317
+ response.status === 503 ||
318
+ response.status === 504;
319
+
320
+ if (isRetryableStatus && attempt < MAX_RETRIES) {
321
+ const serverDelay = extractRetryDelay(errorText, response);
322
+ if (response.status === 429) {
323
+ if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) {
324
+ rateLimitTimeSpent += serverDelay;
325
+ await Bun.sleep(serverDelay);
326
+ continue;
327
+ }
328
+ if (!serverDelay) {
329
+ await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
330
+ continue;
331
+ }
332
+ } else {
333
+ await Bun.sleep(serverDelay ?? BASE_DELAY_MS * 2 ** attempt);
334
+ continue;
335
+ }
336
+ }
337
+
338
+ lastError = new SearchProviderError(
339
+ "gemini",
340
+ `Gemini Cloud Code API error (${response.status}): ${errorText}`,
341
+ response.status,
342
+ );
343
+
344
+ if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
345
+ break;
346
+ }
347
+
348
+ throw lastError;
306
349
  }
307
350
 
308
- throw new SearchProviderError(
309
- "gemini",
310
- `Gemini Cloud Code API error (${response.status}): ${errorText}`,
311
- response.status,
312
- );
351
+ if (response?.ok) {
352
+ break;
353
+ }
313
354
  }
314
355
 
315
356
  if (!response) {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Kagi Web Search Provider
3
+ *
4
+ * Calls Kagi's Search API (v0) and maps results into the unified
5
+ * SearchResponse shape used by the web search tool.
6
+ */
7
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
+ import { SearchProviderError } from "../../../web/search/types";
10
+ import { clampNumResults, dateToAgeSeconds } from "../utils";
11
+ import type { SearchParams } from "./base";
12
+ import { SearchProvider } from "./base";
13
+
14
+ const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
15
+ const DEFAULT_NUM_RESULTS = 10;
16
+ const MAX_NUM_RESULTS = 40;
17
+
18
+ interface KagiSearchResult {
19
+ t: 0;
20
+ url: string;
21
+ title: string;
22
+ snippet?: string;
23
+ published?: string;
24
+ thumbnail?: {
25
+ url: string;
26
+ width?: number | null;
27
+ height?: number | null;
28
+ };
29
+ }
30
+
31
+ interface KagiRelatedSearches {
32
+ t: 1;
33
+ list: string[];
34
+ }
35
+
36
+ type KagiSearchObject = KagiSearchResult | KagiRelatedSearches;
37
+
38
+ interface KagiMeta {
39
+ id: string;
40
+ node: string;
41
+ ms: number;
42
+ api_balance?: number;
43
+ }
44
+
45
+ interface KagiSearchResponse {
46
+ meta: KagiMeta;
47
+ data: KagiSearchObject[];
48
+ error?: Array<{ code: number; msg: string; ref?: unknown }>;
49
+ }
50
+
51
+ /** Find KAGI_API_KEY from environment or .env files. */
52
+ export function findApiKey(): string | null {
53
+ return getEnvApiKey("kagi") ?? null;
54
+ }
55
+
56
+ async function callKagiSearch(
57
+ apiKey: string,
58
+ query: string,
59
+ limit: number,
60
+ signal?: AbortSignal,
61
+ ): Promise<KagiSearchResponse> {
62
+ const url = new URL(KAGI_SEARCH_URL);
63
+ url.searchParams.set("q", query);
64
+ url.searchParams.set("limit", String(limit));
65
+
66
+ const response = await fetch(url, {
67
+ headers: {
68
+ Authorization: `Bot ${apiKey}`,
69
+ Accept: "application/json",
70
+ },
71
+ signal,
72
+ });
73
+
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ throw new SearchProviderError("kagi", `Kagi API error (${response.status}): ${errorText}`, response.status);
77
+ }
78
+
79
+ const data = (await response.json()) as KagiSearchResponse;
80
+
81
+ if (data.error && data.error.length > 0) {
82
+ const firstError = data.error[0];
83
+ throw new SearchProviderError("kagi", `Kagi API error: ${firstError.msg}`, firstError.code);
84
+ }
85
+
86
+ return data;
87
+ }
88
+
89
+ /** Execute Kagi web search. */
90
+ export async function searchKagi(params: {
91
+ query: string;
92
+ num_results?: number;
93
+ signal?: AbortSignal;
94
+ }): Promise<SearchResponse> {
95
+ const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
96
+ const apiKey = findApiKey();
97
+ if (!apiKey) {
98
+ throw new Error("KAGI_API_KEY not found. Set it in environment or .env file.");
99
+ }
100
+
101
+ const data = await callKagiSearch(apiKey, params.query, numResults, params.signal);
102
+
103
+ const sources: SearchSource[] = [];
104
+ const relatedQuestions: string[] = [];
105
+
106
+ for (const item of data.data) {
107
+ if (item.t === 0) {
108
+ sources.push({
109
+ title: item.title,
110
+ url: item.url,
111
+ snippet: item.snippet,
112
+ publishedDate: item.published ?? undefined,
113
+ ageSeconds: dateToAgeSeconds(item.published),
114
+ });
115
+ } else if (item.t === 1) {
116
+ relatedQuestions.push(...item.list);
117
+ }
118
+ }
119
+
120
+ return {
121
+ provider: "kagi",
122
+ sources: sources.slice(0, numResults),
123
+ relatedQuestions: relatedQuestions.length > 0 ? relatedQuestions : undefined,
124
+ requestId: data.meta.id,
125
+ };
126
+ }
127
+
128
+ /** Search provider for Kagi web search. */
129
+ export class KagiProvider extends SearchProvider {
130
+ readonly id = "kagi";
131
+ readonly label = "Kagi";
132
+
133
+ isAvailable() {
134
+ try {
135
+ return !!findApiKey();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ search(params: SearchParams): Promise<SearchResponse> {
142
+ return searchKagi({
143
+ query: params.query,
144
+ num_results: params.numSearchResults ?? params.limit,
145
+ signal: params.signal,
146
+ });
147
+ }
148
+ }
@@ -15,6 +15,7 @@ export type SearchProviderId =
15
15
  | "perplexity"
16
16
  | "gemini"
17
17
  | "codex"
18
+ | "kagi"
18
19
  | "synthetic";
19
20
 
20
21
  /** Source returned by search (all providers) */