@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6

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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. package/src/web/search/providers/perplexity.ts +22 -10
package/src/tools/tts.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Ported from NousResearch/hermes-agent (MIT) — tools/tts_tool.py L167-171, L896-959.
2
2
 
3
3
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
4
+ import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
4
5
  import * as z from "zod/v4";
5
6
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
6
7
  import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
@@ -96,27 +97,46 @@ export const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails> = {
96
97
  const timeoutSignal = AbortSignal.timeout(60_000);
97
98
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
98
99
 
99
- const response = await fetch(`${creds.baseURL}/tts`, {
100
- method: "POST",
101
- headers: {
102
- Authorization: `Bearer ${creds.apiKey}`,
103
- "Content-Type": "application/json",
104
- "User-Agent": ohMyPiXAIUserAgent(),
105
- },
106
- body: JSON.stringify(payload),
107
- signal: combinedSignal,
100
+ const sessionId = ctx.sessionManager.getSessionId();
101
+ const apiKey: ApiKey = ctx.modelRegistry.resolver(creds.provider, {
102
+ sessionId,
103
+ baseUrl: creds.baseURL,
108
104
  });
109
- if (!response.ok) {
110
- const detail = await response.text();
111
- return {
112
- isError: true,
113
- content: [
114
- {
115
- type: "text",
116
- text: `xAI TTS failed (${response.status}): ${detail.slice(0, 300)}`,
117
- },
118
- ],
119
- };
105
+
106
+ let response: Response;
107
+ try {
108
+ response = await withAuth(
109
+ apiKey,
110
+ async key => {
111
+ const resp = await fetch(`${creds.baseURL}/tts`, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${key}`,
115
+ "Content-Type": "application/json",
116
+ "User-Agent": ohMyPiXAIUserAgent(),
117
+ },
118
+ body: JSON.stringify(payload),
119
+ signal: combinedSignal,
120
+ });
121
+ if (!resp.ok) {
122
+ const detail = await resp.text();
123
+ throw new ProviderHttpError(`xAI TTS failed (${resp.status}): ${detail.slice(0, 300)}`, resp.status, {
124
+ headers: resp.headers,
125
+ });
126
+ }
127
+ return resp;
128
+ },
129
+ { signal: combinedSignal },
130
+ );
131
+ } catch (error) {
132
+ const status = (error as { status?: unknown }).status;
133
+ if (error instanceof Error && typeof status === "number") {
134
+ return {
135
+ isError: true,
136
+ content: [{ type: "text", text: error.message }],
137
+ };
138
+ }
139
+ throw error;
120
140
  }
121
141
  const bytes = new Uint8Array(await response.arrayBuffer());
122
142
  await Bun.write(outputPath, bytes);
@@ -125,6 +125,56 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
125
125
  }
126
126
  }
127
127
 
128
+ // PowerShell one-liner that emits the clipboard text verbatim on stdout, or
129
+ // nothing when the clipboard holds no text. `[Console]::Out.Write` avoids the
130
+ // trailing newline Write-Output would add; output encoding is forced to UTF-8
131
+ // so non-ASCII text survives the interop boundary regardless of console
132
+ // codepage.
133
+ const POWERSHELL_TEXT_SCRIPT = `
134
+ $ErrorActionPreference = 'Stop'
135
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
136
+ [Console]::Out.Write([string](Get-Clipboard -Raw))
137
+ `;
138
+
139
+ /**
140
+ * Read clipboard text through Windows PowerShell — native win32 or the WSL
141
+ * host over interop.
142
+ *
143
+ * Same rationale as `readImageViaPowerShell`: under WSL, the WSLg Wayland
144
+ * clipboard only works when `wl-clipboard` happens to be installed in the
145
+ * distro, while `powershell.exe` is always reachable. Forcing UTF-8 output
146
+ * encoding keeps non-ASCII text intact regardless of the console codepage
147
+ * (the legacy win32 `Get-Clipboard` shell-out mangled it), and `Bun.spawn`
148
+ * keeps a cold PowerShell start off the TUI event loop.
149
+ *
150
+ * Returns null when the bridge fails (WSL callers fall through to
151
+ * wl-paste/xclip); an empty string is a successful "no text" read.
152
+ */
153
+ async function readTextViaPowerShell(): Promise<string | null> {
154
+ try {
155
+ const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT], {
156
+ stdout: "pipe",
157
+ stderr: "ignore",
158
+ stdin: "ignore",
159
+ });
160
+ const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
161
+ let stdout = "";
162
+ try {
163
+ stdout = await new Response(proc.stdout).text();
164
+ await proc.exited;
165
+ } catch (err) {
166
+ logger.warn("clipboard: powershell text read failed", { error: String(err) });
167
+ return null;
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ if (proc.exitCode !== 0) return null;
172
+ return stdout.replaceAll("\r\n", "\n");
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
128
178
  /**
129
179
  * Read an image from the system clipboard.
130
180
  *
@@ -165,14 +215,16 @@ export async function readTextFromClipboard(): Promise<string> {
165
215
  return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
166
216
  }
167
217
  if (p === "win32") {
168
- return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', {
169
- encoding: "utf8",
170
- timeout: 2000,
171
- }).toString();
218
+ return (await readTextViaPowerShell()) ?? "";
172
219
  }
173
220
  if (process.env.TERMUX_VERSION) {
174
221
  return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
175
222
  }
223
+ if (isWsl()) {
224
+ const text = await readTextViaPowerShell();
225
+ if (text !== null) return text;
226
+ // Bridge failed — fall through to the wl-paste/xclip paths below.
227
+ }
176
228
  const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
177
229
  const hasX11Display = Boolean(process.env.DISPLAY);
178
230
  if (hasWaylandDisplay) {
@@ -112,11 +112,7 @@ export async function generateCommitMessage(
112
112
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
113
113
  },
114
114
  {
115
- apiKey: registry.resolver(candidate.model.provider, {
116
- sessionId,
117
- baseUrl: candidate.model.baseUrl,
118
- modelId: candidate.model.id,
119
- }),
115
+ apiKey: registry.resolver(candidate.model, sessionId),
120
116
  maxTokens,
121
117
  reasoning: toReasoningEffort(candidate.thinkingLevel),
122
118
  },
@@ -1,4 +1,4 @@
1
- import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
1
+ import { hexToHsv, hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  /**
4
4
  * Derive a stable hue (0-359) from a string using djb2 hash.
@@ -25,23 +25,97 @@ function accentLuminanceCap(surfaceLuminance: number): number {
25
25
  return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05);
26
26
  }
27
27
 
28
+ /** Minimum angular distance in hue degrees from any theme color to avoid visual collision. */
29
+ const MIN_HUE_DISTANCE = 10;
30
+ /** Saturation threshold below which hue is meaningless (near-gray). */
31
+ const MIN_SATURATION_FOR_HUE = 0.1;
32
+
33
+ /** Angular distance between two hue values (0-360). */
34
+ function hueDistance(a: number, b: number): number {
35
+ const d = Math.abs(a - b);
36
+ return Math.min(d, 360 - d);
37
+ }
38
+
39
+ /**
40
+ * Parse hue (0-360) from a hex color string.
41
+ * Returns undefined for near-gray colors where hue is not meaningful.
42
+ */
43
+ function hexToHue(hex: string): number | undefined {
44
+ const hsv = hexToHsv(hex);
45
+ if (hsv.s < MIN_SATURATION_FOR_HUE) return undefined;
46
+ return hsv.h;
47
+ }
48
+
49
+ /**
50
+ * Find a hue at least {@link MIN_HUE_DISTANCE} from all occupied hues,
51
+ * clamped to [lo, hi] to prevent leaving the intended hue band.
52
+ * Returns `target` unchanged if no safe hue exists within bounds.
53
+ */
54
+ function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
55
+ if (occupied.length === 0) return target;
56
+ if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) {
57
+ return target;
58
+ }
59
+ for (let d = 1; d <= hi - lo; d++) {
60
+ for (const dir of [1, -1]) {
61
+ const candidate = Math.max(lo, Math.min(hi, target + d * dir));
62
+ if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) {
63
+ return candidate;
64
+ }
65
+ }
66
+ }
67
+ // fallback: keep the original target if no safe spot exists within the band
68
+ return target;
69
+ }
70
+
71
+ /** Hue range low and high for dark themes (warm: red → yellow → green). */
72
+ const DARK_HUE_START = 0;
73
+ const DARK_HUE_END = 120;
74
+ /** Hue range low and high for light themes (cool: cyan → blue → purple). */
75
+ const LIGHT_HUE_START = 180;
76
+ const LIGHT_HUE_END = 300;
77
+
28
78
  /**
29
- * Derive a stable CSS hex accent color from a session name.
79
+ * Derive a stable CSS hex accent color from a session name and the active theme.
80
+ *
81
+ * Picks a hue from a **dark/light-specific range** so the accent feels natural
82
+ * for the theme type (warm on dark, cool on light). The session name hash
83
+ * determines the exact hue within the range. The result is checked against
84
+ * all theme color hues and shifted if it lands within {@link MIN_HUE_DISTANCE}
85
+ * of an existing theme hue, but is clamped to the hue band so it never
86
+ * drifts into an unrelated part of the spectrum.
30
87
  *
31
88
  * On dark themes (`surfaceLuminance` undefined) the accent is vivid (high
32
89
  * saturation, high lightness). On light themes the lightness is reduced until the
33
90
  * accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the
34
91
  * actual surface it renders on — so it stays legible on near-white *and* mid-light
35
- * backgrounds — while keeping the same per-session hue.
92
+ * backgrounds.
93
+ *
94
+ * @param name — session name for per-session uniqueness.
95
+ * @param themeColorHexes — all theme colors to check collision against.
96
+ * @param surfaceLuminance — undefined on dark themes; WCAG luminance of the
97
+ * status-line background on light themes.
36
98
  */
37
- export function getSessionAccentHex(name: string, surfaceLuminance?: number): string {
38
- const hue = nameToHue(name);
99
+ export function getSessionAccentHex(name: string, themeColorHexes: string[], surfaceLuminance?: number): string {
100
+ // 1. Pick hue range based on theme mode
101
+ const hueStart = surfaceLuminance === undefined ? DARK_HUE_START : LIGHT_HUE_START;
102
+ const hueEnd = surfaceLuminance === undefined ? DARK_HUE_END : LIGHT_HUE_END;
103
+ const range = hueEnd - hueStart;
104
+
105
+ // 2. Session name picks within the range
106
+ let targetHue = hueStart + (nameToHue(name) % range);
107
+
108
+ // 3. Shift away if too close to any theme color — stays within [hueStart, hueEnd]
109
+ const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
110
+ targetHue = findSafeHue(targetHue, themeHues, hueStart, hueEnd);
111
+
112
+ // 4. Lightness/contrast — vivid on dark, bisected for AA on light
39
113
  if (surfaceLuminance === undefined) {
40
- return hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
114
+ return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
41
115
  }
42
116
 
43
117
  const cap = accentLuminanceCap(surfaceLuminance);
44
- const top = hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
118
+ const top = hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
45
119
  if ((relativeLuminance(top) ?? 0) <= cap) return top;
46
120
 
47
121
  // Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it.
@@ -49,13 +123,13 @@ export function getSessionAccentHex(name: string, surfaceLuminance?: number): st
49
123
  let hi = ACCENT_DARK_LIGHTNESS;
50
124
  for (let i = 0; i < 20; i++) {
51
125
  const mid = (lo + hi) / 2;
52
- if ((relativeLuminance(hslToHex(hue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
126
+ if ((relativeLuminance(hslToHex(targetHue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
53
127
  hi = mid;
54
128
  } else {
55
129
  lo = mid;
56
130
  }
57
131
  }
58
- return hslToHex(hue, ACCENT_SATURATION, lo);
132
+ return hslToHex(targetHue, ACCENT_SATURATION, lo);
59
133
  }
60
134
 
61
135
  /**
@@ -258,7 +258,7 @@ export async function generateTitleOnline(
258
258
  tools: [setTitleTool],
259
259
  },
260
260
  {
261
- apiKey: registry.resolver(model.provider, { sessionId, baseUrl: model.baseUrl, modelId: model.id }),
261
+ apiKey: registry.resolver(model, sessionId),
262
262
  maxTokens,
263
263
  disableReasoning: true,
264
264
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
package/src/web/kagi.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * through the shared {@link AuthStorage} broker (Bearer token), and responses
7
7
  * are categorized result buckets rather than the legacy flat object array.
8
8
  */
9
- import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
9
+ import { type AuthStorage, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
10
10
  import { withHardTimeout } from "./search/providers/utils";
11
11
 
12
12
  const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
@@ -173,14 +173,6 @@ export interface KagiSearchResult {
173
173
  answer?: string;
174
174
  }
175
175
 
176
- export async function findKagiApiKey(
177
- authStorage: AuthStorage,
178
- sessionId?: string,
179
- signal?: AbortSignal,
180
- ): Promise<string | null> {
181
- return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null;
182
- }
183
-
184
176
  /**
185
177
  * Compute a YYYY-MM-DD date string `recency` units before now, in UTC.
186
178
  * UTC keeps the recency window deterministic regardless of host timezone and
@@ -247,27 +239,34 @@ export async function searchWithKagi(
247
239
  options: KagiSearchOptions = {},
248
240
  authStorage: AuthStorage,
249
241
  ): Promise<KagiSearchResult> {
250
- const apiKey = await findKagiApiKey(authStorage, options.sessionId, options.signal);
251
- if (!apiKey) {
252
- throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
253
- }
254
-
255
242
  const fetchImpl = options.fetch ?? fetch;
243
+ const body = JSON.stringify(buildRequestBody(query, options));
244
+
245
+ const response = await withAuth(
246
+ authStorage.resolver("kagi", { sessionId: options.sessionId }),
247
+ async apiKey => {
248
+ const res = await fetchImpl(KAGI_SEARCH_URL, {
249
+ method: "POST",
250
+ headers: {
251
+ Authorization: `Bearer ${apiKey}`,
252
+ "Content-Type": "application/json",
253
+ Accept: "application/json",
254
+ },
255
+ body,
256
+ signal: withHardTimeout(options.signal),
257
+ });
258
+
259
+ if (!res.ok) {
260
+ throw parseKagiErrorResponse(res.status, await res.text());
261
+ }
256
262
 
257
- const response = await fetchImpl(KAGI_SEARCH_URL, {
258
- method: "POST",
259
- headers: {
260
- Authorization: `Bearer ${apiKey}`,
261
- "Content-Type": "application/json",
262
- Accept: "application/json",
263
+ return res;
263
264
  },
264
- body: JSON.stringify(buildRequestBody(query, options)),
265
- signal: withHardTimeout(options.signal),
266
- });
267
-
268
- if (!response.ok) {
269
- throw parseKagiErrorResponse(response.status, await response.text());
270
- }
265
+ {
266
+ signal: options.signal,
267
+ missingKeyMessage: "Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.",
268
+ },
269
+ );
271
270
 
272
271
  const payload = (await response.json()) as KagiSearchResponse;
273
272
  if (payload.error && payload.error.length > 0) {
@@ -7,7 +7,7 @@
7
7
  * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
8
8
  */
9
9
  import * as os from "node:os";
10
- import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
10
+ import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
11
11
  import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
12
12
  import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
13
13
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
@@ -287,12 +287,12 @@ async function findCodexAuth(
287
287
  authStorage: AuthStorage,
288
288
  sessionId: string | undefined,
289
289
  signal: AbortSignal | undefined,
290
- ): Promise<{ accessToken: string; accountId: string } | null> {
290
+ ): Promise<{ access: OAuthAccess; accountId: string } | null> {
291
291
  const access = await authStorage.getOAuthAccess("openai-codex", sessionId, { signal });
292
292
  if (!access) return null;
293
293
  const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
294
294
  if (!accountId) return null;
295
- return { accessToken: access.accessToken, accountId };
295
+ return { access, accountId };
296
296
  }
297
297
 
298
298
  /**
@@ -495,8 +495,8 @@ async function callCodexSearch(
495
495
  * `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI rejects.
496
496
  */
497
497
  export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
498
- const auth = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
499
- if (!auth) {
498
+ const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
499
+ if (!seed) {
500
500
  throw new Error(
501
501
  "No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
502
502
  );
@@ -505,42 +505,44 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
505
505
  const configuredModel = getConfiguredModel();
506
506
  const modelCandidates = configuredModel ? [configuredModel] : getDefaultModelCandidates();
507
507
 
508
- let result:
509
- | {
510
- answer: string;
511
- sources: SearchSource[];
512
- model: string;
513
- requestId: string;
514
- usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
515
- }
516
- | undefined;
517
- let lastError: unknown;
518
-
519
- for (let index = 0; index < modelCandidates.length; index += 1) {
520
- const modelId = modelCandidates[index];
521
- if (!modelId) continue;
522
-
523
- try {
524
- result = await callCodexSearch(auth, params.query, {
525
- signal: params.signal,
526
- systemPrompt: params.systemPrompt,
527
- searchContextSize: "high",
528
- modelId,
529
- fetch: params.fetch,
530
- });
531
- break;
532
- } catch (error) {
533
- lastError = error;
534
- const isLastCandidate = index === modelCandidates.length - 1;
535
- if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
536
- throw error;
508
+ const result = await withOAuthAccess(
509
+ params.authStorage,
510
+ "openai-codex",
511
+ async access => {
512
+ // Derive ALL auth material from the access this attempt received —
513
+ // a refreshed/rotated credential carries a different bearer and
514
+ // ChatGPT account id than the seed.
515
+ const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
516
+ if (!accountId) {
517
+ throw new Error("Codex OAuth credential is missing a ChatGPT account id");
537
518
  }
538
- }
539
- }
540
-
541
- if (!result) {
542
- throw lastError ?? new Error("Codex search failed without returning a result");
543
- }
519
+ const auth = { accessToken: access.accessToken, accountId };
520
+
521
+ let lastError: unknown;
522
+ for (let index = 0; index < modelCandidates.length; index += 1) {
523
+ const modelId = modelCandidates[index];
524
+ if (!modelId) continue;
525
+
526
+ try {
527
+ return await callCodexSearch(auth, params.query, {
528
+ signal: params.signal,
529
+ systemPrompt: params.systemPrompt,
530
+ searchContextSize: "high",
531
+ modelId,
532
+ fetch: params.fetch,
533
+ });
534
+ } catch (error) {
535
+ lastError = error;
536
+ const isLastCandidate = index === modelCandidates.length - 1;
537
+ if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
538
+ throw error;
539
+ }
540
+ }
541
+ }
542
+ throw lastError ?? new Error("Codex search failed without returning a result");
543
+ },
544
+ { sessionId: params.sessionId, signal: params.signal, seed: seed.access },
545
+ );
544
546
 
545
547
  let sources = result.sources;
546
548
 
@@ -8,7 +8,7 @@
8
8
  * sibling SQLite store and never POSTs the broker sentinel to a Google token
9
9
  * endpoint.
10
10
  */
11
- import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
11
+ import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
12
12
  import {
13
13
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
14
14
  getAntigravityUserAgent,
@@ -72,25 +72,29 @@ interface GeminiAuth {
72
72
  isAntigravity: boolean;
73
73
  }
74
74
 
75
+ /** First configured Gemini OAuth provider plus its pre-resolved access. */
76
+ interface GeminiAuthSeed {
77
+ provider: GeminiProviderId;
78
+ access: OAuthAccess;
79
+ projectId: string;
80
+ }
81
+
75
82
  /**
76
83
  * Walks the configured Gemini OAuth providers in deterministic order and
77
84
  * returns the first one that yields a usable access token + projectId via
78
85
  * {@link AuthStorage.getOAuthAccess}. AuthStorage handles refresh + broker
79
86
  * routing internally; this helper never touches refresh tokens directly.
87
+ * The resolved access seeds `withOAuthAccess` so the happy path resolves once.
80
88
  */
81
89
  export async function findGeminiAuth(
82
90
  authStorage: AuthStorage,
83
91
  sessionId: string | undefined,
84
92
  signal: AbortSignal | undefined,
85
- ): Promise<GeminiAuth | null> {
93
+ ): Promise<GeminiAuthSeed | null> {
86
94
  for (const provider of GEMINI_PROVIDERS) {
87
95
  const access = await authStorage.getOAuthAccess(provider, sessionId, { signal });
88
96
  if (!access?.accessToken || !access.projectId) continue;
89
- return {
90
- accessToken: access.accessToken,
91
- projectId: access.projectId,
92
- isAntigravity: provider === "google-antigravity",
93
- };
97
+ return { provider, access, projectId: access.projectId };
94
98
  }
95
99
  return null;
96
100
  }
@@ -390,26 +394,42 @@ async function callGeminiSearch(
390
394
  * Executes a web search using Google Gemini with Google Search grounding.
391
395
  */
392
396
  export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
393
- const auth = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
394
- if (!auth) {
397
+ const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
398
+ if (!seed) {
395
399
  throw new Error(
396
400
  "No Gemini OAuth credentials found. Login with 'omp /login google-gemini-cli' or 'omp /login google-antigravity' to enable Gemini web search.",
397
401
  );
398
402
  }
399
403
 
400
- const result = await callGeminiSearch(
401
- auth,
402
- params.query,
403
- params.system_prompt,
404
- params.max_output_tokens,
405
- params.temperature,
406
- {
407
- google_search: params.google_search,
408
- code_execution: params.code_execution,
409
- url_context: params.url_context,
410
- },
411
- params.fetch,
412
- params.signal,
404
+ const isAntigravity = seed.provider === "google-antigravity";
405
+ const result = await withOAuthAccess(
406
+ params.authStorage,
407
+ seed.provider,
408
+ access =>
409
+ // Derive bearer + projectId from the access this attempt received; a
410
+ // re-resolved access may omit projectId, in which case the seed's
411
+ // project is still the right tenant for the credential. The
412
+ // `fetchWithRetry` transport backoff stays INSIDE this attempt — auth
413
+ // retry wraps transport retry.
414
+ callGeminiSearch(
415
+ {
416
+ accessToken: access.accessToken,
417
+ projectId: access.projectId ?? seed.projectId,
418
+ isAntigravity,
419
+ },
420
+ params.query,
421
+ params.system_prompt,
422
+ params.max_output_tokens,
423
+ params.temperature,
424
+ {
425
+ google_search: params.google_search,
426
+ code_execution: params.code_execution,
427
+ url_context: params.url_context,
428
+ },
429
+ params.fetch,
430
+ params.signal,
431
+ ),
432
+ { sessionId: params.sessionId, signal: params.signal, seed: seed.access },
413
433
  );
414
434
 
415
435
  let sources = result.sources;
@@ -8,7 +8,7 @@
8
8
  * - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
9
9
  */
10
10
 
11
- import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
11
+ import { type AuthStorage, type FetchImpl, getEnvApiKey, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
12
12
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
13
13
  import type {
14
14
  PerplexityMessageOutput,
@@ -43,7 +43,7 @@ type PerplexityAuth =
43
43
  }
44
44
  | {
45
45
  type: "oauth";
46
- token: string;
46
+ access: OAuthAccess;
47
47
  }
48
48
  | {
49
49
  type: "cookies";
@@ -302,11 +302,11 @@ function jwtExpiryMs(token: string): number | undefined {
302
302
  }
303
303
  }
304
304
 
305
- async function findOAuthToken(
305
+ async function findOAuthAccess(
306
306
  authStorage: AuthStorage,
307
307
  sessionId: string | undefined,
308
308
  signal: AbortSignal | undefined,
309
- ): Promise<string | null> {
309
+ ): Promise<OAuthAccess | null> {
310
310
  try {
311
311
  // `getOAuthAccess` returns the raw OAuth bearer only — runtime/config
312
312
  // api_key overrides and stored api_key credentials are intentionally
@@ -314,12 +314,12 @@ async function findOAuthToken(
314
314
  // `www.perplexity.ai` session/SSE endpoint.
315
315
  const access = await authStorage.getOAuthAccess("perplexity", sessionId, { signal });
316
316
  const token = access?.accessToken;
317
- if (!token) return null;
317
+ if (!access || !token) return null;
318
318
  // Trust the JWT's own `exp` claim if it has one; otherwise treat as
319
319
  // non-expiring. Perplexity session JWTs commonly omit `exp`.
320
320
  const jwtExpiry = jwtExpiryMs(token);
321
321
  if (jwtExpiry !== undefined && jwtExpiry <= Date.now() + OAUTH_EXPIRY_BUFFER_MS) return null;
322
- return token;
322
+ return access;
323
323
  } catch {
324
324
  return null;
325
325
  }
@@ -339,9 +339,9 @@ async function findPerplexityAuth(
339
339
  const apiKey = findApiKey();
340
340
 
341
341
  // 2. OAuth/session bearer from AuthStorage.
342
- const oauthToken = await findOAuthToken(authStorage, sessionId, signal);
343
- if (oauthToken) {
344
- return { type: "oauth", token: oauthToken };
342
+ const oauthAccess = await findOAuthAccess(authStorage, sessionId, signal);
343
+ if (oauthAccess) {
344
+ return { type: "oauth", access: oauthAccess };
345
345
  }
346
346
 
347
347
  // 3. PERPLEXITY_API_KEY env var
@@ -646,7 +646,19 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
646
646
  const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal);
647
647
 
648
648
  if (auth.type !== "api_key") {
649
- const askResult = await callPerplexityAsk(auth, params);
649
+ // OAuth bearer mode routes the whole authenticated unit (the ask
650
+ // session/SSE request) through the central auth-retry policy so a 401 or
651
+ // usage-limit force-refreshes, then rotates to a sibling credential.
652
+ // Cookie/env/anonymous modes have no rotatable credential — untouched.
653
+ const askResult =
654
+ auth.type === "oauth"
655
+ ? await withOAuthAccess(
656
+ params.authStorage,
657
+ "perplexity",
658
+ access => callPerplexityAsk({ type: "oauth", token: access.accessToken }, params),
659
+ { sessionId: params.sessionId, signal: params.signal, seed: auth.access },
660
+ )
661
+ : await callPerplexityAsk(auth, params);
650
662
  return applySourceLimit(
651
663
  {
652
664
  provider: "perplexity",