@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.9

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 (90) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/cli.js +4850 -4774
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +27 -0
  5. package/dist/types/config/settings-schema.d.ts +34 -3
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  7. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  9. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  10. package/dist/types/lsp/client.d.ts +2 -0
  11. package/dist/types/mcp/manager.d.ts +6 -2
  12. package/dist/types/mcp/render.d.ts +2 -2
  13. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  14. package/dist/types/mcp/types.d.ts +6 -0
  15. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  16. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  17. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  18. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  19. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  20. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  21. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  22. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  23. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  24. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +8 -1
  26. package/dist/types/session/session-loader.d.ts +6 -4
  27. package/dist/types/task/types.d.ts +14 -0
  28. package/dist/types/tools/hub/jobs.d.ts +1 -1
  29. package/dist/types/tools/index.d.ts +3 -0
  30. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  31. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  32. package/dist/types/web/search/types.d.ts +1 -1
  33. package/package.json +13 -12
  34. package/scripts/legacy-pi-virtual-module.ts +1 -1
  35. package/src/cli/grievances-cli.ts +2 -2
  36. package/src/cli/usage-cli.ts +1 -1
  37. package/src/config/__tests__/model-registry.test.ts +147 -0
  38. package/src/config/model-registry.ts +40 -0
  39. package/src/config/model-resolver.ts +0 -1
  40. package/src/config/settings-schema.ts +41 -3
  41. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  42. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  43. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  44. package/src/internal-urls/registry-helpers.ts +40 -0
  45. package/src/lsp/client.ts +23 -0
  46. package/src/lsp/clients/biome-client.ts +3 -4
  47. package/src/lsp/index.ts +25 -7
  48. package/src/main.ts +1 -0
  49. package/src/mcp/manager.ts +39 -8
  50. package/src/mcp/render.ts +94 -35
  51. package/src/mcp/tool-bridge.ts +107 -11
  52. package/src/mcp/types.ts +7 -0
  53. package/src/modes/components/agent-hub.ts +26 -9
  54. package/src/modes/components/oauth-selector.ts +24 -7
  55. package/src/modes/components/settings-defs.ts +5 -2
  56. package/src/modes/components/settings-selector.ts +9 -5
  57. package/src/modes/components/tool-execution.test.ts +63 -2
  58. package/src/modes/controllers/command-controller.ts +14 -7
  59. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  60. package/src/modes/interactive-mode.ts +3 -0
  61. package/src/modes/rpc/rpc-client.ts +94 -3
  62. package/src/modes/rpc/rpc-frame.ts +164 -4
  63. package/src/modes/rpc/rpc-messages.ts +127 -0
  64. package/src/modes/rpc/rpc-mode.ts +79 -7
  65. package/src/modes/rpc/rpc-types.ts +34 -2
  66. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  67. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  68. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  69. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  70. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  71. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  72. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  73. package/src/prompts/goals/guided-goal-system.md +24 -3
  74. package/src/prompts/tools/task.md +12 -2
  75. package/src/sdk.ts +45 -3
  76. package/src/session/agent-session.ts +63 -32
  77. package/src/session/session-context.test.ts +224 -1
  78. package/src/session/session-context.ts +41 -2
  79. package/src/session/session-loader.ts +10 -5
  80. package/src/slash-commands/helpers/usage-report.ts +3 -1
  81. package/src/task/executor.ts +3 -0
  82. package/src/task/index.ts +52 -8
  83. package/src/task/structured-subagent.ts +3 -1
  84. package/src/task/types.ts +16 -0
  85. package/src/tools/hub/index.ts +1 -1
  86. package/src/tools/hub/jobs.ts +67 -8
  87. package/src/tools/index.ts +3 -0
  88. package/src/tools/report-tool-issue.ts +79 -28
  89. package/src/web/search/providers/firecrawl.ts +46 -13
  90. package/src/web/search/types.ts +5 -1
@@ -12,6 +12,7 @@ import { settings } from "../../config/settings";
12
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import { shimmerEnabled, shimmerText } from "../../modes/theme/shimmer";
14
14
  import type { Theme } from "../../modes/theme/theme";
15
+ import { USER_INTERRUPT_LABEL } from "../../session/messages";
15
16
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../../tui";
16
17
  import type { ToolSession } from "..";
17
18
  import {
@@ -304,26 +305,38 @@ export function nothingToWaitForResult(session: ToolSession): AgentToolResult<Co
304
305
  }
305
306
 
306
307
  /** `cancel`: kill the named jobs; returns immediately with outcomes + snapshots. */
307
- export function executeCancel(
308
+ export async function executeCancel(
308
309
  session: ToolSession,
309
310
  manager: AsyncJobManager,
310
311
  ownerId: string | undefined,
311
312
  ids: string[],
312
- ): AgentToolResult<CoordinationDetails> {
313
+ ): Promise<AgentToolResult<CoordinationDetails>> {
313
314
  const ownerFilter = ownerId ? { ownerId } : undefined;
314
315
  const cancelOutcomes: CancelOutcome[] = [];
315
316
  for (const id of ids) {
316
317
  const existing = manager.getJob(id);
317
318
  if (!existing || (ownerId && existing.ownerId !== ownerId)) {
318
- cancelOutcomes.push({ id, status: "not_found", message: `Background job not found: ${id}` });
319
+ // No job by this id (or it belongs to another agent): a budget-aborted
320
+ // keep-alive subagent lives on as a jobless registration long after its
321
+ // job row is reaped, so let cancel reach the agent registration too.
322
+ cancelOutcomes.push(await cancelAgentRegistration(session, ownerId, id));
319
323
  continue;
320
324
  }
321
325
  if (existing.status !== "running") {
322
- cancelOutcomes.push({
323
- id,
324
- status: "already_completed",
325
- message: `Background job ${id} is already ${existing.status}.`,
326
- });
326
+ // The job row settled but may still be inside the retention window.
327
+ // The agent registration behind it (job id == agent id for task
328
+ // spawns) can outlive the row as an idle/parked zombie — try the
329
+ // registration kill before reporting the row as already done.
330
+ const regOutcome = await cancelAgentRegistration(session, ownerId, id);
331
+ cancelOutcomes.push(
332
+ regOutcome.status === "cancelled"
333
+ ? regOutcome
334
+ : {
335
+ id,
336
+ status: "already_completed",
337
+ message: `Background job ${id} is already ${existing.status}.`,
338
+ },
339
+ );
327
340
  continue;
328
341
  }
329
342
  const cancelled = manager.cancel(id, ownerFilter);
@@ -336,6 +349,52 @@ export function executeCancel(
336
349
  return buildJobResult(session, manager, "cancel", visibleJobs(manager, ids, ownerId), cancelOutcomes);
337
350
  }
338
351
 
352
+ /**
353
+ * Kill a non-job-backed agent registration named by `id`: abort any in-flight
354
+ * turn, then release it from the lifecycle (dispose session + unregister). This
355
+ * is the only kill path for a keep-alive subagent that was budget-aborted, went
356
+ * `idle`/`parked`, and outlived its job row — otherwise it is unstoppable short
357
+ * of a broker restart (issue #6315). Scoped to the caller's own descendants so
358
+ * cross-agent kills stay impossible; a bare test/SDK caller (no owner id) may
359
+ * target any sub. Never touches Main, the caller, or advisor transcripts.
360
+ */
361
+ async function cancelAgentRegistration(
362
+ session: ToolSession,
363
+ ownerId: string | undefined,
364
+ id: string,
365
+ ): Promise<CancelOutcome> {
366
+ const registry = session.agentRegistry;
367
+ const ref = registry?.get(id);
368
+ if (ref?.kind !== "sub") {
369
+ return { id, status: "not_found", message: `Background job not found: ${id}` };
370
+ }
371
+ if (id === ownerId) {
372
+ return { id, status: "not_found", message: `Cannot cancel yourself (${id}).` };
373
+ }
374
+ if (ownerId && ref.parentId !== ownerId) {
375
+ return { id, status: "not_found", message: `Agent ${id} was not spawned by you and cannot be cancelled.` };
376
+ }
377
+ const lifecycle = session.agentLifecycle?.();
378
+ try {
379
+ if (ref.status === "running" && ref.session) {
380
+ await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
381
+ }
382
+ if (lifecycle) {
383
+ await lifecycle.release(id);
384
+ } else {
385
+ await ref.session?.dispose();
386
+ registry?.unregister(id);
387
+ }
388
+ } catch (error) {
389
+ return {
390
+ id,
391
+ status: "already_completed",
392
+ message: `Agent ${id} could not be fully cancelled: ${error instanceof Error ? error.message : String(error)}.`,
393
+ };
394
+ }
395
+ return { id, status: "cancelled", message: `Cancelled agent ${id} (killed session, dropped registration).` };
396
+ }
397
+
339
398
  /** `jobs`: read-only snapshot of every job plus the jobless running-agent roster. */
340
399
  export function executeJobsSnapshot(
341
400
  session: ToolSession,
@@ -20,6 +20,7 @@ import { LspTool } from "../lsp";
20
20
  import type { MCPManager } from "../mcp";
21
21
  import type { MnemopiSessionState } from "../mnemopi/state";
22
22
  import type { PlanModeState } from "../plan-mode/state";
23
+ import type { AgentLifecycleManager } from "../registry/agent-lifecycle";
23
24
  import type { AgentRegistry } from "../registry/agent-registry";
24
25
  import type { ArtifactManager } from "../session/artifacts";
25
26
  import type { ClientBridge } from "../session/client-bridge";
@@ -238,6 +239,8 @@ export interface ToolSession {
238
239
  xdevRegistry?: XdevRegistry;
239
240
  /** Agent registry for IRC routing across live sessions. */
240
241
  agentRegistry?: AgentRegistry;
242
+ /** Idle→parked→revive lifecycle owner; lets the hub kill a non-job-backed agent registration. Default: AgentLifecycleManager.global(). */
243
+ agentLifecycle?: () => AgentLifecycleManager;
241
244
  /** Get artifacts directory for artifact:// URLs */
242
245
  getArtifactsDir?: () => string | null;
243
246
  /** Get the ArtifactManager backing this session (shared across parent + subagents). */
@@ -5,16 +5,21 @@
5
5
  * `xd://report_issue`, and the system prompt tells the model to write
6
6
  * `<tool>: <concise description>` there when auto-QA is enabled.
7
7
  *
8
- * Enabled by default; gated behind PI_AUTO_QA=1 / `dev.autoqa` so a user who
9
- * flips the setting off short-circuits injection entirely.
8
+ * Enabled by default (`dev.autoqa` defaults to true); `PI_AUTO_QA=0` or an
9
+ * explicit `dev.autoqa: false` short-circuits injection entirely. When the
10
+ * user is only enabled by default (never configured `dev.autoqa` themselves),
11
+ * a persisted `dev.autoqaConsent: "denied"` also disables injection so a "No"
12
+ * in the consent dialog fully turns the feature off.
10
13
  * Records grievances to a local SQLite database; never throws from the device
11
14
  * dispatch path.
12
15
  *
13
- * Before the first record lands, the user's consent is checked. If they've
14
- * never been asked (`dev.autoqaConsent === "unset"`) the process-global
15
- * consent handler — wired by `InteractiveMode` to a Yes/No popup — is invoked
16
- * exactly once and the decision is persisted. Subsequent calls (including from
17
- * subagents) read the cached decision without prompting.
16
+ * Nothing is written until consent resolves. If the user has never been asked
17
+ * (`dev.autoqaConsent === "unset"`) the process-global consent handler —
18
+ * wired by `InteractiveMode` to a Yes/No popup — is invoked exactly once and
19
+ * the decision is persisted; a denial (or dismissal) drops the pending report
20
+ * without touching the database. Subsequent calls (including from subagents)
21
+ * read the cached decision without prompting. `PI_AUTO_QA_PUSH=1` bypasses
22
+ * the dialog for headless environments.
18
23
  *
19
24
  * When the user grants consent, push is automatically active against the
20
25
  * bundled endpoint (`dev.autoqaPush.endpoint`, default `qa.omp.sh`). Each
@@ -24,11 +29,13 @@
24
29
  * the network and never throws.
25
30
  */
26
31
  import { Database } from "bun:sqlite";
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
27
34
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
28
35
  import type { FetchImpl } from "@oh-my-pi/pi-ai";
29
36
  import type { Component } from "@oh-my-pi/pi-tui";
30
37
  import { Text } from "@oh-my-pi/pi-tui";
31
- import { $env, $flag, getAutoQaDbDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
38
+ import { $env, $flag, getAutoQaDbPath, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
32
39
  import type { Settings } from "..";
33
40
  import type { Theme } from "../modes/theme/theme";
34
41
  import { renderStatusLine, truncateToWidth } from "../tui";
@@ -88,8 +95,23 @@ function parseReportIssueBody(text: string): { tool: string; report: string } {
88
95
  throw new ToolError(`Invalid report format. ${reportIssueDeviceUsage()}`);
89
96
  }
90
97
 
98
+ /**
99
+ * Whether Auto-QA is active for this session.
100
+ *
101
+ * Precedence: `PI_AUTO_QA` env flag > explicit `dev.autoqa` setting >
102
+ * default-on unless the user previously denied consent. The denial veto only
103
+ * applies to the default: explicitly configuring `dev.autoqa: true` re-enables
104
+ * injection (recording still no-ops until consent is granted).
105
+ */
91
106
  export function isAutoQaEnabled(settings?: Settings): boolean {
92
- return $flag("PI_AUTO_QA", !!settings?.get("dev.autoqa"));
107
+ let fallback = false;
108
+ if (settings) {
109
+ const enabled = !!settings.get("dev.autoqa");
110
+ fallback = settings.isConfigured("dev.autoqa")
111
+ ? enabled
112
+ : enabled && settings.get("dev.autoqaConsent") !== "denied";
113
+ }
114
+ return $flag("PI_AUTO_QA", fallback);
93
115
  }
94
116
 
95
117
  // ───────────────────────────────────────────────────────────────────────────
@@ -233,15 +255,18 @@ let cachedDb: Database | null = null;
233
255
 
234
256
  /**
235
257
  * Open (or return the cached handle for) the auto-QA SQLite database at
236
- * `~/.omp/agent/autoqa.db`, creating the schema lazily. Returns `null` when
237
- * the agent data dir cannot be resolved.
258
+ * `~/.omp/autoqa.db` (XDG: `$XDG_DATA_HOME/omp/autoqa.db`), creating the
259
+ * schema lazily. Returns `null` when the path cannot be resolved or opened.
238
260
  */
239
261
  export function openAutoQaDb(): Database | null {
240
262
  if (cachedDb) return cachedDb;
241
- const dir = getAutoQaDbDir();
242
- if (!dir) return null;
263
+ const dbPath = getAutoQaDbPath();
264
+ if (!dbPath) return null;
243
265
  try {
244
- const db = new Database(`${dir}/autoqa.db`, { create: true });
266
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
267
+ const db = new Database(dbPath, { create: true });
268
+ // Install the busy handler BEFORE any lock-taking statement. See #2421.
269
+ db.run("PRAGMA busy_timeout = 5000");
245
270
  db.exec(`
246
271
  CREATE TABLE IF NOT EXISTS grievances (
247
272
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -252,6 +277,17 @@ export function openAutoQaDb(): Database | null {
252
277
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
253
278
  pushed INTEGER NOT NULL DEFAULT 0
254
279
  );
280
+ `);
281
+ // Legacy DBs (May 2026) predate `created_at`. ALTER TABLE only accepts
282
+ // constant defaults, so add it empty and backfill before the index below.
283
+ const hasCreatedAt = db.prepare("SELECT 1 FROM pragma_table_info('grievances') WHERE name = 'created_at'").get();
284
+ if (!hasCreatedAt) {
285
+ db.exec(`
286
+ ALTER TABLE grievances ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
287
+ UPDATE grievances SET created_at = CURRENT_TIMESTAMP WHERE created_at = '';
288
+ `);
289
+ }
290
+ db.exec(`
255
291
  CREATE INDEX IF NOT EXISTS grievances_pushed_created_at_idx
256
292
  ON grievances (pushed, created_at, id);
257
293
  `);
@@ -471,23 +507,38 @@ export async function flushGrievances(
471
507
  }
472
508
  }
473
509
 
474
- /** Record a grievance row and trigger the background consent/flush pipeline. */
475
- async function recordToolIssue(session: ToolSession, tool: string, report: string): Promise<void> {
510
+ /**
511
+ * Most recently scheduled record pipeline. Never rejects (the pipeline
512
+ * swallows its own errors); retained so tests can await the fire-and-forget
513
+ * work deterministically via {@link __awaitAutoQaRecordPipelineForTests}.
514
+ */
515
+ let lastRecordPipeline: Promise<void> = Promise.resolve();
516
+
517
+ /** Test-only: await the last consent → insert → flush pipeline. */
518
+ export function __awaitAutoQaRecordPipelineForTests(): Promise<void> {
519
+ return lastRecordPipeline;
520
+ }
521
+
522
+ /**
523
+ * Queue a grievance for recording. The consent → insert → flush pipeline is
524
+ * fire-and-forget: nothing is written until the user grants consent (or
525
+ * `PI_AUTO_QA_PUSH=1` forces headless recording), and the device result
526
+ * returns immediately so the model never waits on the dialog or the network.
527
+ */
528
+ function recordToolIssue(session: ToolSession, tool: string, report: string): void {
476
529
  const canonicalTool = tool.startsWith("proxy_") ? tool.slice("proxy_".length) : tool;
477
- const db = openAutoQaDb();
478
- if (!db) return;
479
- db.prepare("INSERT INTO grievances (model, version, tool, report) VALUES (?, ?, ?, ?)").run(
480
- session.getActiveModelString?.() ?? "unknown",
481
- VERSION,
482
- canonicalTool,
483
- report,
484
- );
485
- void (async () => {
530
+ const model = session.getActiveModelString?.() ?? "unknown";
531
+ lastRecordPipeline = (async () => {
486
532
  try {
487
- await resolveAutoQaConsent(session.settings);
533
+ if (!$flag("PI_AUTO_QA_PUSH") && !(await resolveAutoQaConsent(session.settings))) return;
534
+ const db = openAutoQaDb();
535
+ if (!db) return;
536
+ db.prepare(
537
+ "INSERT INTO grievances (model, version, tool, report, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
538
+ ).run(model, VERSION, canonicalTool, report);
488
539
  await flushGrievances(db, session.settings);
489
540
  } catch (error) {
490
- logger.debug("autoqa post-insert pipeline failed", { error: String(error) });
541
+ logger.debug("autoqa consent pipeline failed", { error: String(error) });
491
542
  }
492
543
  })();
493
544
  }
@@ -504,7 +555,7 @@ export async function dispatchReportIssueDevice(
504
555
  try {
505
556
  if (isAutoQaEnabled(session.settings)) {
506
557
  const { tool, report } = parseReportIssueBody(text);
507
- await recordToolIssue(session, tool, report);
558
+ recordToolIssue(session, tool, report);
508
559
  }
509
560
  } catch (error) {
510
561
  if (error instanceof ToolError) throw error;
@@ -4,7 +4,14 @@
4
4
  * Calls Firecrawl's search API and maps web results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
7
+ import {
8
+ type AuthStorage,
9
+ type FetchImpl,
10
+ getEnvApiKey,
11
+ resolveApiKeyOnce,
12
+ seedApiKeyResolver,
13
+ withAuth,
14
+ } from "@oh-my-pi/pi-ai";
8
15
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
16
  import { SearchProviderError } from "../../../web/search/types";
10
17
  import { clampNumResults } from "../utils";
@@ -66,13 +73,19 @@ function buildRequestBody(params: FirecrawlSearchParams): Record<string, unknown
66
73
  return body;
67
74
  }
68
75
 
69
- async function callFirecrawlSearch(apiKey: string, params: FirecrawlSearchParams): Promise<FirecrawlSearchResponse> {
76
+ async function callFirecrawlSearch(
77
+ apiKey: string | undefined,
78
+ params: FirecrawlSearchParams,
79
+ ): Promise<FirecrawlSearchResponse> {
80
+ const headers: Record<string, string> = {
81
+ "Content-Type": "application/json",
82
+ };
83
+ if (apiKey) {
84
+ headers.Authorization = `Bearer ${apiKey}`;
85
+ }
70
86
  const response = await (params.fetch ?? fetch)(FIRECRAWL_SEARCH_URL, {
71
87
  method: "POST",
72
- headers: {
73
- "Content-Type": "application/json",
74
- Authorization: `Bearer ${apiKey}`,
75
- },
88
+ headers,
76
89
  body: JSON.stringify(buildRequestBody(params)),
77
90
  signal: withHardTimeout(params.signal),
78
91
  });
@@ -100,16 +113,24 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
100
113
  signal: params.signal,
101
114
  fetch: params.fetch,
102
115
  };
103
- const keyOrResolver: ApiKey = params.authStorage.resolver("firecrawl", {
116
+ const keyResolver = params.authStorage.resolver("firecrawl", {
104
117
  sessionId: params.sessionId,
105
118
  });
106
119
  const numResults = clampNumResults(firecrawlParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
107
120
 
108
- const data = await withAuth(keyOrResolver, key => callFirecrawlSearch(key, firecrawlParams), {
109
- signal: params.signal,
110
- missingKeyMessage:
111
- 'Firecrawl credentials not found. Set FIRECRAWL_API_KEY or configure an API key for provider "firecrawl".',
112
- });
121
+ const resolvedKey = await resolveApiKeyOnce(keyResolver, params.signal);
122
+ let data: FirecrawlSearchResponse;
123
+ if (resolvedKey) {
124
+ // Reuse the preflight credential for the initial authenticated attempt.
125
+ const seededResolver = seedApiKeyResolver(resolvedKey, keyResolver);
126
+ data = await withAuth(seededResolver, key => callFirecrawlSearch(key, firecrawlParams), {
127
+ signal: params.signal,
128
+ });
129
+ } else {
130
+ // Keyless mode — omit Authorization header
131
+ data = await callFirecrawlSearch(undefined, firecrawlParams);
132
+ }
133
+
113
134
  const sources: SearchSource[] = [];
114
135
 
115
136
  for (const result of data.data?.web ?? []) {
@@ -125,7 +146,7 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
125
146
  provider: "firecrawl",
126
147
  sources: sources.slice(0, numResults),
127
148
  requestId: data.id ?? undefined,
128
- authMode: "api_key",
149
+ authMode: resolvedKey ? "api_key" : "keyless",
129
150
  };
130
151
  }
131
152
 
@@ -134,10 +155,22 @@ export class FirecrawlProvider extends SearchProvider {
134
155
  readonly id = "firecrawl";
135
156
  readonly label = "Firecrawl";
136
157
 
158
+ /**
159
+ * Auto-chain admission: requires a credential so an unconfigured Firecrawl
160
+ * doesn't displace other providers that the user has set up with API keys.
161
+ */
137
162
  isAvailable(authStorage: AuthStorage): boolean {
138
163
  return authStorage.hasAuth("firecrawl") || !!getEnvApiKey("firecrawl");
139
164
  }
140
165
 
166
+ /**
167
+ * Firecrawl supports keyless mode, so an explicit user selection
168
+ * (`webSearch: firecrawl`) works without any credential configured.
169
+ */
170
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
171
+ return true;
172
+ }
173
+
141
174
  search(params: SearchParams): Promise<SearchResponse> {
142
175
  return searchFirecrawl(params);
143
176
  }
@@ -37,7 +37,11 @@ export const SEARCH_PROVIDER_OPTIONS = [
37
37
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
38
38
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
39
39
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
40
- { value: "firecrawl", label: "Firecrawl", description: "Requires FIRECRAWL_API_KEY" },
40
+ {
41
+ value: "firecrawl",
42
+ label: "Firecrawl",
43
+ description: "Uses Firecrawl API when FIRECRAWL_API_KEY is set; falls back to keyless mode",
44
+ },
41
45
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
42
46
  {
43
47
  value: "kimi",