@gajae-code/coding-agent 0.7.10 → 0.7.11

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 (88) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/types/config/keybindings.d.ts +11 -5
  4. package/dist/types/defaults/gjc-defaults.d.ts +7 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  6. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +3 -2
  7. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +1 -0
  8. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  10. package/dist/types/modes/components/skill-message.d.ts +1 -0
  11. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +4 -0
  13. package/dist/types/modes/interactive-mode.d.ts +1 -0
  14. package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -0
  15. package/dist/types/modes/slash-command-visibility.d.ts +1 -0
  16. package/dist/types/modes/types.d.ts +1 -0
  17. package/dist/types/sdk.d.ts +1 -1
  18. package/dist/types/session/agent-session.d.ts +8 -5
  19. package/dist/types/slash-commands/types.d.ts +2 -0
  20. package/dist/types/utils/cmux-workspace.d.ts +30 -1
  21. package/dist/types/web/search/index.d.ts +3 -1
  22. package/dist/types/web/search/provider.d.ts +9 -0
  23. package/dist/types/web/search/providers/utils.d.ts +42 -13
  24. package/package.json +7 -7
  25. package/src/cli/update-cli.ts +24 -0
  26. package/src/cli/web-search-cli.ts +2 -5
  27. package/src/cli.ts +1 -1
  28. package/src/commands/launch.ts +1 -1
  29. package/src/commands/ralplan.ts +1 -0
  30. package/src/config/keybindings.ts +14 -5
  31. package/src/config/model-profiles.ts +16 -0
  32. package/src/coordinator-mcp/server.ts +9 -3
  33. package/src/defaults/gjc/skills/ralplan/SKILL.md +16 -14
  34. package/src/defaults/gjc-defaults.ts +16 -0
  35. package/src/extensibility/extensions/runner.ts +2 -1
  36. package/src/gjc-runtime/launch-tmux.ts +164 -42
  37. package/src/gjc-runtime/ralplan-runtime.ts +22 -6
  38. package/src/gjc-runtime/restricted-role-agent-bash.ts +1 -0
  39. package/src/gjc-runtime/state-runtime.ts +8 -5
  40. package/src/gjc-runtime/state-writer.ts +5 -5
  41. package/src/internal-urls/docs-index.generated.ts +5 -4
  42. package/src/main.ts +2 -2
  43. package/src/migrate/executor.ts +23 -6
  44. package/src/modes/components/custom-editor.ts +8 -0
  45. package/src/modes/components/model-selector.ts +6 -2
  46. package/src/modes/components/settings-selector.ts +0 -2
  47. package/src/modes/components/skill-message.ts +18 -10
  48. package/src/modes/controllers/command-controller.ts +23 -0
  49. package/src/modes/controllers/input-controller.ts +41 -9
  50. package/src/modes/interactive-mode.ts +16 -7
  51. package/src/modes/prompt-action-autocomplete.ts +23 -1
  52. package/src/modes/slash-command-visibility.ts +9 -0
  53. package/src/modes/types.ts +1 -0
  54. package/src/modes/utils/hotkeys-markdown.ts +5 -2
  55. package/src/modes/utils/ui-helpers.ts +76 -3
  56. package/src/notifications/telegram-daemon.ts +4 -4
  57. package/src/prompts/agents/architect.md +7 -4
  58. package/src/prompts/agents/critic.md +15 -6
  59. package/src/prompts/agents/planner.md +8 -3
  60. package/src/prompts/tools/bash.md +1 -1
  61. package/src/sdk.ts +3 -6
  62. package/src/session/agent-session.ts +60 -17
  63. package/src/slash-commands/builtin-registry.ts +30 -10
  64. package/src/slash-commands/types.ts +2 -0
  65. package/src/tools/bash.ts +24 -8
  66. package/src/utils/cmux-workspace.ts +134 -7
  67. package/src/utils/title-generator.ts +1 -1
  68. package/src/web/kagi.ts +1 -1
  69. package/src/web/parallel.ts +2 -2
  70. package/src/web/search/index.ts +118 -45
  71. package/src/web/search/provider.ts +65 -0
  72. package/src/web/search/providers/anthropic.ts +1 -1
  73. package/src/web/search/providers/brave.ts +1 -1
  74. package/src/web/search/providers/codex.ts +1 -1
  75. package/src/web/search/providers/duckduckgo.ts +1 -1
  76. package/src/web/search/providers/exa.ts +1 -1
  77. package/src/web/search/providers/gemini.ts +7 -3
  78. package/src/web/search/providers/jina.ts +1 -1
  79. package/src/web/search/providers/kimi.ts +6 -1
  80. package/src/web/search/providers/openai-compatible.ts +1 -1
  81. package/src/web/search/providers/parallel.ts +1 -1
  82. package/src/web/search/providers/perplexity.ts +2 -2
  83. package/src/web/search/providers/searxng.ts +1 -1
  84. package/src/web/search/providers/synthetic.ts +1 -1
  85. package/src/web/search/providers/tavily.ts +1 -1
  86. package/src/web/search/providers/utils.ts +67 -17
  87. package/src/web/search/providers/xai.ts +1 -1
  88. package/src/web/search/providers/zai.ts +1 -1
@@ -2,14 +2,25 @@ import { logger } from "@gajae-code/utils";
2
2
 
3
3
  const CMUX_COMMAND = "cmux";
4
4
  const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
5
+ const CMUX_NO_RENAME_ENV = "GJC_NO_CMUX_RENAME";
5
6
  const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
7
+ const CMUX_WORKSPACE_TITLE_PREFIX = "GJC: ";
6
8
  const CMUX_WORKSPACE_RENAME_TIMEOUT_MS = 1500;
9
+ const CMUX_WORKSPACE_LIST_TIMEOUT_MS = 1500;
7
10
 
8
11
  export interface CmuxWorkspaceRenameCommand {
9
12
  command: string;
10
13
  args: string[];
11
14
  }
12
15
 
16
+ /** Current ownership state of a cmux workspace, read back from `cmux workspace list`. */
17
+ export interface CmuxWorkspaceOwnership {
18
+ /** cmux marks a workspace `has_custom_title` once an explicit title is set (by the user or by GJC). */
19
+ hasCustomTitle: boolean;
20
+ /** The workspace's current display title. */
21
+ title: string;
22
+ }
23
+
13
24
  export interface CmuxWorkspaceRenameProcess {
14
25
  exited: Promise<number>;
15
26
  kill(): void;
@@ -24,6 +35,12 @@ export interface CmuxWorkspaceTitleSyncOptions {
24
35
  command: string[],
25
36
  options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
26
37
  ) => CmuxWorkspaceRenameProcess;
38
+ /** Reads the current ownership state of `workspaceId`. Injectable for tests. */
39
+ readOwnership?: (
40
+ cmuxCommand: string,
41
+ workspaceId: string,
42
+ env: NodeJS.ProcessEnv,
43
+ ) => Promise<CmuxWorkspaceOwnership | null>;
27
44
  }
28
45
 
29
46
  function defaultSpawn(
@@ -33,12 +50,24 @@ function defaultSpawn(
33
50
  return Bun.spawn(command, options);
34
51
  }
35
52
 
53
+ function isEnvSet(value: string | undefined): boolean {
54
+ if (!value) return false;
55
+ const normalized = value.trim().toLowerCase();
56
+ return normalized !== "" && normalized !== "0" && normalized !== "false";
57
+ }
58
+
36
59
  export function sanitizeCmuxWorkspaceTitle(title: string | undefined): string | undefined {
37
60
  if (!title) return undefined;
38
61
  const sanitized = title.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
39
62
  return sanitized || undefined;
40
63
  }
41
64
 
65
+ export function formatCmuxWorkspaceTitle(title: string | undefined): string | undefined {
66
+ const sanitized = sanitizeCmuxWorkspaceTitle(title);
67
+ if (!sanitized) return undefined;
68
+ return sanitized.startsWith(CMUX_WORKSPACE_TITLE_PREFIX) ? sanitized : `${CMUX_WORKSPACE_TITLE_PREFIX}${sanitized}`;
69
+ }
70
+
42
71
  export function buildCmuxWorkspaceRenameCommand(
43
72
  sessionName: string | undefined,
44
73
  env: NodeJS.ProcessEnv = process.env,
@@ -46,7 +75,7 @@ export function buildCmuxWorkspaceRenameCommand(
46
75
  const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
47
76
  if (!workspaceId) return null;
48
77
 
49
- const title = sanitizeCmuxWorkspaceTitle(sessionName);
78
+ const title = formatCmuxWorkspaceTitle(sessionName);
50
79
  if (!title) return null;
51
80
 
52
81
  return {
@@ -55,27 +84,125 @@ export function buildCmuxWorkspaceRenameCommand(
55
84
  };
56
85
  }
57
86
 
58
- export function syncCmuxWorkspaceTitle(
87
+ /** Parse `cmux workspace list --json --id-format both` output and return the
88
+ * ownership state of the workspace matching `workspaceId` (by UUID `id` or `ref`). */
89
+ export function parseCmuxWorkspaceOwnership(jsonText: string, workspaceId: string): CmuxWorkspaceOwnership | null {
90
+ const target = workspaceId.trim().toLowerCase();
91
+ if (!target) return null;
92
+
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(jsonText);
96
+ } catch {
97
+ return null;
98
+ }
99
+
100
+ const workspaces = (parsed as { workspaces?: unknown }).workspaces;
101
+ if (!Array.isArray(workspaces)) return null;
102
+
103
+ for (const entry of workspaces) {
104
+ if (!entry || typeof entry !== "object") continue;
105
+ const record = entry as Record<string, unknown>;
106
+ const id = typeof record.id === "string" ? record.id.toLowerCase() : "";
107
+ const ref = typeof record.ref === "string" ? record.ref.toLowerCase() : "";
108
+ if (id !== target && ref !== target) continue;
109
+ return {
110
+ hasCustomTitle: record.has_custom_title === true,
111
+ title: typeof record.title === "string" ? record.title : "",
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Only rename when GJC owns the name:
118
+ * - unknown ownership (read failed) → skip (fail safe, never clobber)
119
+ * - already the desired title → skip (no-op)
120
+ * - workspace still on its default title → rename
121
+ * - any custom title (user- or peer-set) → skip
122
+ * This makes GJC name a fresh workspace once and then leave it alone, so it
123
+ * never overwrites a user-pinned name and multiple sessions sharing one
124
+ * CMUX_WORKSPACE_ID do not thrash the workspace title. */
125
+ export function shouldRenameCmuxWorkspace(ownership: CmuxWorkspaceOwnership | null, desiredTitle: string): boolean {
126
+ if (!ownership) return false;
127
+ if ((sanitizeCmuxWorkspaceTitle(ownership.title) ?? "") === desiredTitle) return false;
128
+ return !ownership.hasCustomTitle;
129
+ }
130
+
131
+ async function defaultReadOwnership(
132
+ cmuxCommand: string,
133
+ workspaceId: string,
134
+ env: NodeJS.ProcessEnv,
135
+ ): Promise<CmuxWorkspaceOwnership | null> {
136
+ try {
137
+ const proc = Bun.spawn([cmuxCommand, "workspace", "list", "--json", "--id-format", "both"], {
138
+ env: { ...env, CMUX_QUIET: "1" },
139
+ stdin: "ignore",
140
+ stdout: "pipe",
141
+ stderr: "ignore",
142
+ });
143
+ const timer = setTimeout(() => {
144
+ try {
145
+ proc.kill();
146
+ } catch {}
147
+ }, CMUX_WORKSPACE_LIST_TIMEOUT_MS);
148
+ timer.unref?.();
149
+ const text = await new Response(proc.stdout).text();
150
+ await proc.exited;
151
+ clearTimeout(timer);
152
+ return parseCmuxWorkspaceOwnership(text, workspaceId);
153
+ } catch (error) {
154
+ logger.debug("cmux workspace list failed", { error: String(error) });
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Best-effort sync of the containing cmux workspace title to the current GJC
161
+ * session name. Ownership-guarded: GJC reads the current workspace title and
162
+ * only renames a workspace that still has its default title, so it never
163
+ * overwrites a name the user pinned or a name a peer session (sharing the same
164
+ * CMUX_WORKSPACE_ID) set. Opt out with GJC_NO_CMUX_RENAME.
165
+ */
166
+ export async function syncCmuxWorkspaceTitle(
59
167
  sessionName: string | undefined,
60
168
  options: CmuxWorkspaceTitleSyncOptions = {},
61
- ): void {
169
+ ): Promise<void> {
170
+ const env = options.env ?? process.env;
171
+ if (isEnvSet(env[CMUX_NO_RENAME_ENV])) return;
172
+
62
173
  const isTty = options.isTty ?? process.stdout.isTTY === true;
63
174
  if (!isTty) return;
64
175
 
65
- const env = options.env ?? process.env;
66
- const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
67
- if (!plan) return;
176
+ const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
177
+ if (!workspaceId) return;
178
+
179
+ const desired = formatCmuxWorkspaceTitle(sessionName);
180
+ if (!desired) return;
68
181
 
69
182
  const which = options.which ?? Bun.which;
70
183
  let resolvedCommand: string | null;
71
184
  try {
72
- resolvedCommand = which(plan.command);
185
+ resolvedCommand = which(CMUX_COMMAND);
73
186
  } catch (error) {
74
187
  logger.debug("cmux workspace rename command lookup failed", { error: String(error) });
75
188
  return;
76
189
  }
77
190
  if (!resolvedCommand) return;
78
191
 
192
+ let ownership: CmuxWorkspaceOwnership | null;
193
+ try {
194
+ const readOwnership = options.readOwnership ?? defaultReadOwnership;
195
+ ownership = await readOwnership(resolvedCommand, workspaceId, env);
196
+ } catch (error) {
197
+ logger.debug("cmux workspace ownership read failed", { error: String(error) });
198
+ return;
199
+ }
200
+
201
+ if (!shouldRenameCmuxWorkspace(ownership, desired)) return;
202
+
203
+ const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
204
+ if (!plan) return;
205
+
79
206
  const spawn = options.spawn ?? defaultSpawn;
80
207
  try {
81
208
  const proc = spawn([resolvedCommand, ...plan.args], {
@@ -219,7 +219,7 @@ export function setTerminalTitle(title: string): void {
219
219
 
220
220
  export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
221
221
  setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
222
- syncCmuxWorkspaceTitle(sessionName);
222
+ void syncCmuxWorkspaceTitle(sessionName);
223
223
  }
224
224
 
225
225
  /**
package/src/web/kagi.ts CHANGED
@@ -147,7 +147,7 @@ export async function searchWithKagi(
147
147
 
148
148
  const response = await fetch(requestUrl, {
149
149
  headers: getAuthHeaders(apiKey),
150
- signal: withHardTimeout(options.signal),
150
+ signal: withHardTimeout(options.signal, "api"),
151
151
  });
152
152
  if (!response.ok) {
153
153
  throw parseKagiErrorResponse(response.status, await response.text());
@@ -306,7 +306,7 @@ export async function searchWithParallel(
306
306
  max_chars_per_result: options.maxCharsPerResult ?? 10_000,
307
307
  },
308
308
  }),
309
- signal: withHardTimeout(options.signal),
309
+ signal: withHardTimeout(options.signal, "api"),
310
310
  });
311
311
  if (!response.ok) {
312
312
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -338,7 +338,7 @@ export async function extractWithParallel(
338
338
  excerpts: options.excerpts ?? true,
339
339
  full_content: options.fullContent ?? false,
340
340
  }),
341
- signal: withHardTimeout(options.signal),
341
+ signal: withHardTimeout(options.signal, "api"),
342
342
  });
343
343
  if (!response.ok) {
344
344
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -16,7 +16,7 @@ import { discoverAuthStorage } from "../../sdk";
16
16
  import type { ToolSession } from "../../tools";
17
17
  import { formatAge } from "../../tools/render-utils";
18
18
  import { throwIfAborted } from "../../tools/tool-errors";
19
- import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
19
+ import { getSearchProviderLabel, prewarmSearchProviders, resolveProviderChain, type SearchProvider } from "./provider";
20
20
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
21
21
  import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
22
22
  import { SearchProviderError } from "./types";
@@ -148,6 +148,22 @@ interface ExecuteSearchOptions {
148
148
  activeModelContext?: ActiveSearchModelContext;
149
149
  }
150
150
 
151
+ /**
152
+ * Delay before the keyless DuckDuckGo hedge fires while a slower primary is
153
+ * still in flight. Chosen to be comfortably above DDG's typical ~1-2s
154
+ * round-trip cost budget yet far below LLM-mediated primary latency, so a
155
+ * failing primary can fall back to an already-completed hedge result instead
156
+ * of paying primary-failure time plus DDG time sequentially.
157
+ */
158
+ const DDG_HEDGE_DELAY_MS = 3_000;
159
+
160
+ let ddgHedgeDelayMs = DDG_HEDGE_DELAY_MS;
161
+
162
+ /** Override the hedge delay (tests). Non-finite/non-positive resets to default. */
163
+ export function setDdgHedgeDelayMs(ms: number | undefined): void {
164
+ ddgHedgeDelayMs = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : DDG_HEDGE_DELAY_MS;
165
+ }
166
+
151
167
  /** Execute web search */
152
168
  async function executeSearch(
153
169
  _toolCallId: string,
@@ -166,52 +182,91 @@ async function executeSearch(
166
182
  activeModelContext,
167
183
  });
168
184
 
185
+ const baseSearchParams = {
186
+ query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
187
+ limit: params.limit,
188
+ recency: params.recency,
189
+ systemPrompt: webSearchSystemPrompt,
190
+ maxOutputTokens: params.max_tokens,
191
+ numSearchResults: params.num_search_results,
192
+ temperature: params.temperature,
193
+ xaiSearchMode: params.xai_search_mode,
194
+ allowedDomains: params.allowed_domains,
195
+ excludedDomains: params.excluded_domains,
196
+ allowedXHandles: params.allowed_x_handles,
197
+ excludedXHandles: params.excluded_x_handles,
198
+ fromDate: params.from_date,
199
+ toDate: params.to_date,
200
+ enableImageUnderstanding: params.enable_image_understanding,
201
+ enableImageSearch: params.enable_image_search,
202
+ enableVideoUnderstanding: params.enable_video_understanding,
203
+ noInlineCitations: params.no_inline_citations,
204
+ authStorage,
205
+ sessionId,
206
+ activeModelContext,
207
+ };
208
+
209
+ // Hedged fallback: when DuckDuckGo (keyless, cheap) is a non-primary member
210
+ // of the chain, start it in the background after a short delay. If the
211
+ // primary succeeds first the hedge is aborted; if the primary fails, the
212
+ // hedge result is typically already available, collapsing the fallback
213
+ // latency from `t(primary failure) + t(ddg)` to `max(t(primary failure), t(ddg))`.
214
+ const ddgIndex = providers.findIndex(p => p.id === "duckduckgo");
215
+ const hedgeCtl = new AbortController();
216
+ const onUpstreamAbort = () => hedgeCtl.abort();
217
+ signal?.addEventListener("abort", onUpstreamAbort, { once: true });
218
+ let hedgePromise: Promise<SearchResponse> | undefined;
219
+ let hedgeTimer: NodeJS.Timeout | undefined;
220
+ if (ddgIndex > 0) {
221
+ const ddg = providers[ddgIndex]!;
222
+ hedgeTimer = setTimeout(() => {
223
+ hedgePromise = ddg.search({ ...baseSearchParams, signal: hedgeCtl.signal });
224
+ // Failures are consumed when (and if) the loop reaches DuckDuckGo;
225
+ // never let a losing hedge become an unhandled rejection.
226
+ hedgePromise.catch(() => {});
227
+ }, ddgHedgeDelayMs);
228
+ }
229
+ const cancelHedge = () => {
230
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
231
+ hedgeCtl.abort();
232
+ signal?.removeEventListener("abort", onUpstreamAbort);
233
+ };
234
+
169
235
  const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
170
236
  let lastProvider = providers[0];
171
- for (const provider of providers) {
172
- lastProvider = provider;
173
- try {
174
- const response = await provider.search({
175
- query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
176
- limit: params.limit,
177
- recency: params.recency,
178
- systemPrompt: webSearchSystemPrompt,
179
- maxOutputTokens: params.max_tokens,
180
- numSearchResults: params.num_search_results,
181
- temperature: params.temperature,
182
- xaiSearchMode: params.xai_search_mode,
183
- allowedDomains: params.allowed_domains,
184
- excludedDomains: params.excluded_domains,
185
- allowedXHandles: params.allowed_x_handles,
186
- excludedXHandles: params.excluded_x_handles,
187
- fromDate: params.from_date,
188
- toDate: params.to_date,
189
- enableImageUnderstanding: params.enable_image_understanding,
190
- enableImageSearch: params.enable_image_search,
191
- enableVideoUnderstanding: params.enable_video_understanding,
192
- noInlineCitations: params.no_inline_citations,
193
- signal,
194
- authStorage,
195
- sessionId,
196
- activeModelContext,
197
- });
198
-
199
- const text = formatForLLM(response);
200
- const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
201
-
202
- return {
203
- content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
204
- details: { response, ...(warning ? { warning } : {}) },
205
- };
206
- } catch (error) {
207
- // Surface user-initiated cancellation immediately so the session sees
208
- // a clean abort instead of a generic "all providers failed" message.
209
- // Without this, an AbortError from `fetch()` is treated as a provider
210
- // failure and the loop falls through to the next provider (or to the
211
- // summary error), masking the cancellation.
212
- throwIfAborted(signal);
213
- failures.push({ provider, error });
237
+ try {
238
+ for (const provider of providers) {
239
+ lastProvider = provider;
240
+ try {
241
+ let response: SearchResponse;
242
+ if (provider.id === "duckduckgo" && hedgePromise) {
243
+ // Reuse the in-flight (often already-settled) hedge request.
244
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
245
+ response = await hedgePromise;
246
+ } else {
247
+ if (provider.id === "duckduckgo" && hedgeTimer !== undefined) clearTimeout(hedgeTimer);
248
+ response = await provider.search({ ...baseSearchParams, signal });
249
+ }
250
+
251
+ const text = formatForLLM(response);
252
+ const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
253
+
254
+ return {
255
+ content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
256
+ details: { response, ...(warning ? { warning } : {}) },
257
+ };
258
+ } catch (error) {
259
+ // Surface user-initiated cancellation immediately so the session sees
260
+ // a clean abort instead of a generic "all providers failed" message.
261
+ // Without this, an AbortError from `fetch()` is treated as a provider
262
+ // failure and the loop falls through to the next provider (or to the
263
+ // summary error), masking the cancellation.
264
+ throwIfAborted(signal);
265
+ failures.push({ provider, error });
266
+ }
214
267
  }
268
+ } finally {
269
+ cancelHedge();
215
270
  }
216
271
 
217
272
  const lastFailure = failures[failures.length - 1];
@@ -273,6 +328,24 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
273
328
  constructor(session: ToolSession) {
274
329
  this.#session = session;
275
330
  this.description = prompt.render(webSearchDescription);
331
+ // Prewarm: resolve the provider chain once in the background so the
332
+ // provider modules are imported and the chain cache is primed before
333
+ // the first user-visible search. Best-effort only.
334
+ const authStorage = session.authStorage;
335
+ if (authStorage) {
336
+ try {
337
+ const activeModelContext = session.model
338
+ ? session.modelRegistry?.getActiveSearchModelContext(session.model)
339
+ : undefined;
340
+ prewarmSearchProviders({
341
+ authStorage,
342
+ sessionId: session.getSessionId?.() ?? undefined,
343
+ activeModelContext,
344
+ });
345
+ } catch {
346
+ // Never let prewarming break tool construction.
347
+ }
348
+ }
276
349
  }
277
350
 
278
351
  async execute(
@@ -334,6 +407,6 @@ export {
334
407
  setPreferredSearchProvider,
335
408
  setSearchFallbackProviders,
336
409
  } from "./provider";
337
- export { setSearchHardTimeoutMs } from "./providers/utils";
410
+ export { applyConfiguredSearchTimeout, setSearchHardTimeoutMs } from "./providers/utils";
338
411
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
339
412
  export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
@@ -141,12 +141,63 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
141
141
  let preferredProvId: SearchProviderId | "auto" = "auto";
142
142
  let fallbackProvIds: SearchProviderId[] = [];
143
143
 
144
+ /**
145
+ * Resolved-chain cache. Chain resolution probes provider availability
146
+ * (credential lookups, potentially an OAuth refresh through the broker) on
147
+ * every web_search call even though the result only changes when the active
148
+ * model, provider preference, or credentials change. Cache the resolved
149
+ * provider-id list per AuthStorage instance for a short TTL.
150
+ *
151
+ * Keyed by AuthStorage identity (WeakMap) so tests and short-lived CLI calls
152
+ * with fresh storage handles never see another handle's chain. The TTL keeps
153
+ * a login/logout mid-session from being masked for more than a minute.
154
+ */
155
+ const CHAIN_CACHE_TTL_MS = 60_000;
156
+ let chainCache = new WeakMap<AuthStorage, Map<string, { ids: SearchProviderId[]; expires: number }>>();
157
+
158
+ /** Drop every cached resolved chain (settings changed, tests). */
159
+ export function clearResolvedChainCache(): void {
160
+ chainCache = new WeakMap();
161
+ }
162
+
163
+ function chainCacheKey(
164
+ preferred: SearchProviderId | "auto" | undefined,
165
+ fallbacks: readonly SearchProviderId[],
166
+ ctx: ActiveSearchModelContext | undefined,
167
+ authStorage: AuthStorage,
168
+ ): string {
169
+ // Include the AuthStorage generation so credential changes (login/logout,
170
+ // key rotation) invalidate cached chains immediately instead of being
171
+ // masked for up to the TTL. Stubs without getGeneration() key as 0.
172
+ const generation = (authStorage as { getGeneration?: () => number }).getGeneration?.() ?? 0;
173
+ return JSON.stringify([
174
+ generation,
175
+ preferred ?? null,
176
+ fallbacks,
177
+ ctx
178
+ ? [ctx.provider, ctx.modelId, ctx.wireModelId ?? null, ctx.api, ctx.baseUrl ?? null, ctx.webSearch ?? null]
179
+ : null,
180
+ ]);
181
+ }
182
+
144
183
  export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
145
184
  preferredProvId = provider;
185
+ clearResolvedChainCache();
146
186
  }
147
187
 
148
188
  export function setSearchFallbackProviders(ids: readonly string[]): void {
149
189
  fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
190
+ clearResolvedChainCache();
191
+ }
192
+
193
+ /**
194
+ * Fire-and-forget warm-up: resolve the provider chain once so the provider
195
+ * modules are imported and the chain cache is primed before the first
196
+ * user-visible search. Errors are intentionally swallowed — prewarming must
197
+ * never surface a failure the real search path would handle itself.
198
+ */
199
+ export function prewarmSearchProviders(options: ResolveProviderChainOptions): void {
200
+ void resolveProviderChain(options).catch(() => {});
150
201
  }
151
202
 
152
203
  export interface SearchProviderPreferenceSource {
@@ -357,6 +408,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
357
408
  activeModelContext,
358
409
  fallbackProviders = fallbackProvIds,
359
410
  } = options;
411
+
412
+ const cacheKey = chainCacheKey(preferredProvider, fallbackProviders, activeModelContext, authStorage);
413
+ const perStorage = chainCache.get(authStorage);
414
+ const cached = perStorage?.get(cacheKey);
415
+ if (cached && cached.expires > Date.now()) {
416
+ const cachedProviders: SearchProvider[] = [];
417
+ for (const id of cached.ids) cachedProviders.push(await getSearchProvider(id));
418
+ return cachedProviders;
419
+ }
420
+
360
421
  const chain: SearchProviderId[] = [];
361
422
 
362
423
  // A forced primary is honored only when it is a user-configurable provider.
@@ -392,6 +453,10 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
392
453
  }
393
454
  appendDeduped(chain, "duckduckgo");
394
455
 
456
+ const storageEntry = chainCache.get(authStorage) ?? new Map<string, { ids: SearchProviderId[]; expires: number }>();
457
+ storageEntry.set(cacheKey, { ids: [...chain], expires: Date.now() + CHAIN_CACHE_TTL_MS });
458
+ chainCache.set(authStorage, storageEntry);
459
+
395
460
  const providers: SearchProvider[] = [];
396
461
  for (const id of chain) providers.push(await getSearchProvider(id));
397
462
  return providers;
@@ -119,7 +119,7 @@ async function callSearch(
119
119
  method: "POST",
120
120
  headers,
121
121
  body: JSON.stringify(body),
122
- signal: withHardTimeout(signal),
122
+ signal: withHardTimeout(signal, "llm"),
123
123
  });
124
124
 
125
125
  if (!response.ok) {
@@ -85,7 +85,7 @@ async function callBraveSearch(
85
85
  Accept: "application/json",
86
86
  "X-Subscription-Token": apiKey,
87
87
  },
88
- signal: withHardTimeout(params.signal),
88
+ signal: withHardTimeout(params.signal, "api"),
89
89
  });
90
90
 
91
91
  if (!response.ok) {
@@ -216,7 +216,7 @@ async function callCodexSearch(
216
216
  method: "POST",
217
217
  headers,
218
218
  body: JSON.stringify(body),
219
- signal: withHardTimeout(options.signal),
219
+ signal: withHardTimeout(options.signal, "llm"),
220
220
  });
221
221
 
222
222
  if (!response.ok) {
@@ -196,7 +196,7 @@ async function fetchAndParse(
196
196
  "Accept-Language": "en-US,en;q=0.9",
197
197
  },
198
198
  body,
199
- signal: withHardTimeout(signal),
199
+ signal: withHardTimeout(signal, "api"),
200
200
  });
201
201
 
202
202
  // DuckDuckGo signals soft blocks with 202 (which is still response.ok).
@@ -115,7 +115,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
115
115
  "x-api-key": apiKey,
116
116
  },
117
117
  body: JSON.stringify(body),
118
- signal: withHardTimeout(params.signal),
118
+ signal: withHardTimeout(params.signal, "api"),
119
119
  });
120
120
 
121
121
  if (!response.ok) {
@@ -29,7 +29,11 @@ const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_
29
29
  const DEFAULT_MODEL = "gemini-2.5-flash";
30
30
  const MAX_RETRIES = 3;
31
31
  const BASE_DELAY_MS = 1000;
32
- const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
32
+ // Retry-delay ceiling for 429/5xx. The web_search chain always terminates in
33
+ // the keyless DuckDuckGo fallback, so a rate-limited Gemini should fail
34
+ // through in seconds instead of parking the chain behind long server-provided
35
+ // retry delays (previously a 5-minute budget).
36
+ const RATE_LIMIT_BUDGET_MS = 30 * 1000;
33
37
 
34
38
  const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const;
35
39
  type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number];
@@ -230,7 +234,7 @@ async function callGeminiSearch(
230
234
  ...headers,
231
235
  },
232
236
  body: JSON.stringify(requestBody),
233
- signal: withHardTimeout(signal),
237
+ signal: withHardTimeout(signal, "llm"),
234
238
  });
235
239
  const urlFor = (attempt: number) =>
236
240
  `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
@@ -466,7 +470,7 @@ async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<
466
470
  method: "POST",
467
471
  headers: { ...(ctx.headers ?? {}), "x-goog-api-key": apiKey, "Content-Type": "application/json" },
468
472
  body: JSON.stringify(body),
469
- signal: withHardTimeout(params.signal),
473
+ signal: withHardTimeout(params.signal, "llm"),
470
474
  });
471
475
  const text = await response.text();
472
476
  if (!response.ok) {
@@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
41
41
  Accept: "application/json",
42
42
  Authorization: `Bearer ${apiKey}`,
43
43
  },
44
- signal: withHardTimeout(signal),
44
+ signal: withHardTimeout(signal, "api"),
45
45
  });
46
46
 
47
47
  if (!response.ok) {
@@ -19,6 +19,11 @@ const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
19
19
  const DEFAULT_NUM_RESULTS = 10;
20
20
  const MAX_NUM_RESULTS = 20;
21
21
  const DEFAULT_TIMEOUT_SECONDS = 30;
22
+ // Local hard ceiling aligned with the upstream `timeout_seconds` request budget
23
+ // (plus transport headroom) instead of the generic 15s "api" class: Kimi
24
+ // legitimately runs search + page crawling up to its advertised 30s window,
25
+ // and aborting earlier would discard valid in-budget responses.
26
+ const KIMI_HARD_TIMEOUT_MS = (DEFAULT_TIMEOUT_SECONDS + 5) * 1000;
22
27
 
23
28
  export interface KimiSearchParams {
24
29
  query: string;
@@ -87,7 +92,7 @@ async function callKimiSearch(
87
92
  enable_page_crawling: params.includeContent,
88
93
  timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
89
94
  }),
90
- signal: withHardTimeout(params.signal),
95
+ signal: withHardTimeout(params.signal, KIMI_HARD_TIMEOUT_MS),
91
96
  });
92
97
 
93
98
  if (!response.ok) {
@@ -134,7 +134,7 @@ export class OpenAICompatibleSearchProvider extends SearchProvider {
134
134
  method: "POST",
135
135
  headers,
136
136
  body: JSON.stringify(payload),
137
- signal: withHardTimeout(params.signal),
137
+ signal: withHardTimeout(params.signal, "llm"),
138
138
  });
139
139
 
140
140
  // Web search is a Responses-API capability: many OpenAI-compatible
@@ -139,7 +139,7 @@ async function searchWithAuthStorage(
139
139
  max_chars_per_result: 10_000,
140
140
  },
141
141
  }),
142
- signal: withHardTimeout(params.signal),
142
+ signal: withHardTimeout(params.signal, "api"),
143
143
  });
144
144
  if (!response.ok) {
145
145
  throw parseParallelErrorResponse(response.status, await response.text());