@gajae-code/coding-agent 0.5.1 → 0.5.3

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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. package/src/web/search/types.ts +47 -22
@@ -4,7 +4,7 @@ import { prompt } from "@gajae-code/utils";
4
4
  import * as z from "zod/v4";
5
5
  import { type AsyncJob, AsyncJobManager, jobElapsedMs, type SubagentRecord } from "../async";
6
6
  import subagentDescription from "../prompts/tools/subagent.md" with { type: "text" };
7
- import type { AgentProgress, AgentSource } from "../task/types";
7
+ import type { AgentProgress, AgentSource, TaskToolDetails } from "../task/types";
8
8
  import { Ellipsis, truncateToWidth } from "../tui";
9
9
  import type { ToolSession } from "./index";
10
10
  import { replaceTabs } from "./render-utils";
@@ -330,12 +330,21 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
330
330
  );
331
331
  const watchedJobIds = runningJobs.map(job => job.id);
332
332
  manager.watchJobs(watchedJobIds);
333
- const progressTimer = onUpdate
334
- ? setInterval(() => {
335
- onUpdate(this.#progressResult(manager, records, true));
336
- }, 500)
337
- : undefined;
338
- onUpdate?.(this.#progressResult(manager, records, true));
333
+ let lastEmittedSignature: string | undefined;
334
+ const emitIfChanged = (force: boolean): void => {
335
+ if (!onUpdate) return;
336
+ const result = this.#progressResult(manager, records, true);
337
+ const signature = subagentAwaitRenderedStateSignature(result.details?.subagents ?? []);
338
+ if (!force && signature === lastEmittedSignature) return;
339
+ lastEmittedSignature = signature;
340
+ onUpdate(result);
341
+ };
342
+ const progressTimer = onUpdate ? setInterval(() => emitIfChanged(false), 500) : undefined;
343
+ // Initial emission so the panel appears immediately; later idle ticks are
344
+ // gated on a value-based rendered-state signature so unchanged progress no
345
+ // longer rebuilds the renderer component or mutates transcript lines above
346
+ // the viewport (the source of the await-panel repaint storms).
347
+ emitIfChanged(true);
339
348
 
340
349
  let timedOut = false;
341
350
  try {
@@ -720,3 +729,134 @@ function previewJobOutput(
720
729
  const preview = truncateToWidth(normalized, width, Ellipsis.Unicode);
721
730
  return { type: source.type, preview, truncated: preview !== normalized };
722
731
  }
732
+
733
+ /**
734
+ * Canonical, value-based rendered-state signature for the `subagent` await panel.
735
+ *
736
+ * Producer-side await gating compares this signature against the last emitted one
737
+ * and only fires `onUpdate` when the *rendered* state actually changed. Unchanged
738
+ * idle ticks therefore stop rebuilding the renderer component and stop mutating
739
+ * transcript lines above the viewport, which is what triggers TUI full-redraw
740
+ * storms (`tui.ts` `firstChanged < viewportTop`).
741
+ *
742
+ * It is deliberately value-based, never object identity: `AsyncJobManager.record-
743
+ * SubagentProgress` stores a `structuredClone` but `getSubagentProgress` returns
744
+ * the retained object by reference, so identity comparison would be both noisy and
745
+ * unsafe.
746
+ *
747
+ * Time-derived fields are intentionally excluded so the panel does not churn while
748
+ * idle: raw durations (`durationMs`), current-tool elapsed (`currentToolStartMs`),
749
+ * and retry countdowns (`retryState.startedAtMs`) are omitted. Idle duration and
750
+ * countdown ticking is sacrificed by design; every real transition still changes
751
+ * the signature.
752
+ */
753
+ export function subagentAwaitRenderedStateSignature(subagents: readonly SubagentSnapshot[]): string {
754
+ return JSON.stringify(subagents.map(canonicalizeSnapshotForSignature));
755
+ }
756
+
757
+ function canonicalizeSnapshotForSignature(snapshot: SubagentSnapshot): unknown {
758
+ return {
759
+ id: snapshot.id,
760
+ jobId: snapshot.jobId,
761
+ status: snapshot.status,
762
+ label: snapshot.label,
763
+ agent: snapshot.agent,
764
+ agentSource: snapshot.agentSource,
765
+ description: snapshot.description ?? null,
766
+ assignment: snapshot.assignment ?? null,
767
+ resultText: snapshot.resultText ?? null,
768
+ errorText: snapshot.errorText ?? null,
769
+ resultPreview: snapshot.resultPreview ?? null,
770
+ outputRef: snapshot.outputRef ?? null,
771
+ truncated: snapshot.truncated ?? false,
772
+ guidance: snapshot.guidance ?? null,
773
+ liveProgressAvailable: snapshot.liveProgressAvailable ?? null,
774
+ effectiveModel: snapshot.effectiveModel ?? null,
775
+ requestedModel: snapshot.requestedModel ?? null,
776
+ modelFellBack: snapshot.modelFellBack ?? false,
777
+ // durationMs intentionally excluded (time-derived; would defeat idle gating).
778
+ progress: snapshot.progress ? canonicalizeProgressForSignature(snapshot.progress) : null,
779
+ };
780
+ }
781
+
782
+ function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
783
+ return {
784
+ id: progress.id,
785
+ agent: progress.agent,
786
+ agentSource: progress.agentSource,
787
+ status: progress.status,
788
+ task: progress.task,
789
+ assignment: progress.assignment ?? null,
790
+ description: progress.description ?? null,
791
+ lastIntent: progress.lastIntent ?? null,
792
+ currentTool: progress.currentTool ?? null,
793
+ currentToolArgs: progress.currentToolArgs ?? null,
794
+ // currentToolStartMs intentionally excluded (only drives elapsed rendering).
795
+ recentTools: progress.recentTools.map(tool => ({ tool: tool.tool, args: tool.args })),
796
+ recentOutput: progress.recentOutput,
797
+ toolCount: progress.toolCount,
798
+ tokens: progress.tokens,
799
+ contextTokens: progress.contextTokens ?? null,
800
+ contextWindow: progress.contextWindow ?? null,
801
+ cost: progress.cost,
802
+ modelOverride: progress.modelOverride ?? null,
803
+ modelSubstitutionWarning: progress.modelSubstitutionWarning ?? null,
804
+ // durationMs intentionally excluded (time-derived).
805
+ extractedToolData: progress.extractedToolData
806
+ ? canonicalizeExtractedToolDataForSignature(progress.extractedToolData)
807
+ : null,
808
+ retryState: progress.retryState
809
+ ? {
810
+ attempt: progress.retryState.attempt,
811
+ maxAttempts: progress.retryState.maxAttempts,
812
+ unbounded: progress.retryState.unbounded ?? false,
813
+ delayMs: progress.retryState.delayMs,
814
+ errorMessage: progress.retryState.errorMessage,
815
+ // startedAtMs intentionally excluded (drives countdown only).
816
+ }
817
+ : null,
818
+ retryFailure: progress.retryFailure ?? null,
819
+ inflightTaskDetails: progress.inflightTaskDetails
820
+ ? canonicalizeTaskDetailsForSignature(progress.inflightTaskDetails)
821
+ : null,
822
+ };
823
+ }
824
+
825
+ /**
826
+ * Nested `task` data (`extractedToolData.task` and `inflightTaskDetails`) is the
827
+ * one place the await signature reaches into a live, ticking structure: nested
828
+ * `AgentProgress` carries the same time-derived fields excluded above, and
829
+ * `TaskToolDetails` adds `totalDurationMs` / per-result `durationMs`. Signing it
830
+ * wholesale would defeat idle gating whenever an awaited subagent is itself inside
831
+ * a live `task` call, so these helpers canonicalize the rendered, non-time subset
832
+ * recursively (mutually recursive with `canonicalizeProgressForSignature`).
833
+ */
834
+ function canonicalizeExtractedToolDataForSignature(data: Record<string, unknown[]>): Record<string, unknown> {
835
+ const out: Record<string, unknown> = {};
836
+ for (const key of Object.keys(data)) {
837
+ // Only the `task` key holds time-ticking `TaskToolDetails`; other handler
838
+ // data (yield/report_finding/generic) is stable and passes through as-is.
839
+ out[key] = key === "task" ? (data[key] as TaskToolDetails[]).map(canonicalizeTaskDetailsForSignature) : data[key];
840
+ }
841
+ return out;
842
+ }
843
+
844
+ function canonicalizeTaskDetailsForSignature(details: TaskToolDetails): unknown {
845
+ // `extractedToolData` is an untyped boundary (`Record<string, unknown[]>`), so
846
+ // guard each field instead of trusting the `TaskToolDetails` cast.
847
+ return {
848
+ // totalDurationMs intentionally excluded (time-derived).
849
+ results: Array.isArray(details.results) ? details.results.map(canonicalizeTaskResultForSignature) : null,
850
+ progress: Array.isArray(details.progress) ? details.progress.map(canonicalizeProgressForSignature) : null,
851
+ async: details.async
852
+ ? { state: details.async.state, jobId: details.async.jobId, type: details.async.type }
853
+ : null,
854
+ };
855
+ }
856
+
857
+ function canonicalizeTaskResultForSignature(result: TaskToolDetails["results"][number]): unknown {
858
+ // Completed results do not tick, but drop `durationMs` so the only time-derived
859
+ // field in the receipt can never reintroduce idle churn.
860
+ const { durationMs: _durationMs, ...rest } = result;
861
+ return rest;
862
+ }
@@ -0,0 +1,39 @@
1
+ import type { AgentTool } from "@gajae-code/agent-core";
2
+ import { isUltragoalAskBlocked, type UltragoalAskBlockDiagnostic } from "../gjc-runtime/ultragoal-guard";
3
+ import { ToolError } from "./tool-errors";
4
+
5
+ const ULTRAGOAL_ASK_GUARD = Symbol.for("gajae-code.ultragoalAskGuard");
6
+
7
+ type GuardedTool = AgentTool & { [ULTRAGOAL_ASK_GUARD]?: true };
8
+
9
+ export function formatUltragoalAskBlockMessage(diagnostic: UltragoalAskBlockDiagnostic): string {
10
+ return [
11
+ diagnostic.message,
12
+ `Ultragoal ask guard blocked ask (source: ${diagnostic.source}; reason: ${diagnostic.reason}).`,
13
+ "Use `gjc ultragoal record-review-blockers` to record the blocker instead of asking the user.",
14
+ ].join("\n");
15
+ }
16
+
17
+ export async function assertUltragoalAskAllowed(cwd: string): Promise<void> {
18
+ const diagnostic = await isUltragoalAskBlocked(cwd);
19
+ if (!diagnostic.active) return;
20
+ throw new ToolError(formatUltragoalAskBlockMessage(diagnostic));
21
+ }
22
+
23
+ export function guardToolForUltragoalAsk<T extends AgentTool>(tool: T, getCwd: () => string): T {
24
+ if (tool.name !== "ask") return tool;
25
+ const candidate = tool as GuardedTool;
26
+ if (candidate[ULTRAGOAL_ASK_GUARD]) return tool;
27
+ const wrapped = new Proxy(tool, {
28
+ get(target, prop, receiver) {
29
+ if (prop === ULTRAGOAL_ASK_GUARD) return true;
30
+ if (prop !== "execute") return Reflect.get(target, prop, receiver);
31
+ return async (...args: unknown[]): Promise<unknown> => {
32
+ await assertUltragoalAskAllowed(getCwd());
33
+ return Reflect.apply(target.execute, target, args);
34
+ };
35
+ },
36
+ }) as T & GuardedTool;
37
+ wrapped[ULTRAGOAL_ASK_GUARD] = true;
38
+ return wrapped as T;
39
+ }
@@ -8,7 +8,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
8
8
  import type { AuthStorage } from "@gajae-code/ai";
9
9
  import { prompt } from "@gajae-code/utils";
10
10
  import * as z from "zod/v4";
11
- import { parseModelString } from "../../config/model-resolver";
12
11
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
13
12
  import type { Theme } from "../../modes/theme/theme";
14
13
  import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
@@ -19,7 +18,7 @@ import { formatAge } from "../../tools/render-utils";
19
18
  import { throwIfAborted } from "../../tools/tool-errors";
20
19
  import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
21
20
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
22
- import type { SearchProviderId, SearchResponse } from "./types";
21
+ import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
23
22
  import { SearchProviderError } from "./types";
24
23
 
25
24
  /** Web search tool parameters schema */
@@ -116,21 +115,11 @@ function formatForLLM(response: SearchResponse): string {
116
115
  return parts.join("\n");
117
116
  }
118
117
 
119
- /** Best-effort active model provider: prefer the resolved Model, fall back to parsing the model string. */
120
- function resolveActiveModelProvider(
121
- modelProvider: string | undefined,
122
- modelString: string | undefined,
123
- ): string | undefined {
124
- if (modelProvider) return modelProvider;
125
- if (modelString) return parseModelString(modelString)?.provider;
126
- return undefined;
127
- }
128
-
129
118
  interface ExecuteSearchOptions {
130
119
  authStorage: AuthStorage;
131
120
  sessionId?: string;
132
121
  signal?: AbortSignal;
133
- activeModelProvider?: string;
122
+ activeModelContext?: ActiveSearchModelContext;
134
123
  }
135
124
 
136
125
  /** Execute web search */
@@ -139,11 +128,17 @@ async function executeSearch(
139
128
  params: SearchQueryParams,
140
129
  options: ExecuteSearchOptions,
141
130
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
142
- const { authStorage, sessionId, signal, activeModelProvider } = options;
131
+ const { authStorage, sessionId, signal, activeModelContext } = options;
143
132
  // Pass `params.provider` straight through: when omitted (the normal model-facing
144
133
  // path) it is `undefined`, so `resolveProviderChain` applies the settings-configured
145
134
  // preferred provider. Coalescing to "auto" here would silently bypass that preference.
146
- const providers = await resolveProviderChain(authStorage, params.provider, activeModelProvider);
135
+ const providers = await resolveProviderChain({
136
+ authStorage,
137
+ sessionId,
138
+ signal,
139
+ preferredProvider: params.provider,
140
+ activeModelContext,
141
+ });
147
142
 
148
143
  const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
149
144
  let lastProvider = providers[0];
@@ -161,6 +156,7 @@ async function executeSearch(
161
156
  signal,
162
157
  authStorage,
163
158
  sessionId,
159
+ activeModelContext,
164
160
  });
165
161
 
166
162
  const text = formatForLLM(response);
@@ -210,14 +206,19 @@ async function executeSearch(
210
206
  */
211
207
  export async function runSearchQuery(
212
208
  params: SearchQueryParams,
213
- options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal; activeModelProvider?: string } = {},
209
+ options: {
210
+ authStorage?: AuthStorage;
211
+ sessionId?: string;
212
+ signal?: AbortSignal;
213
+ activeModelContext?: ActiveSearchModelContext;
214
+ } = {},
214
215
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
215
216
  const authStorage = options.authStorage ?? (await discoverAuthStorage());
216
217
  return executeSearch("cli-web-search", params, {
217
218
  authStorage,
218
219
  sessionId: options.sessionId,
219
220
  signal: options.signal,
220
- activeModelProvider: options.activeModelProvider,
221
+ activeModelContext: options.activeModelContext,
221
222
  });
222
223
  }
223
224
 
@@ -251,11 +252,10 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
251
252
  ): Promise<AgentToolResult<SearchRenderDetails>> {
252
253
  const authStorage = this.#session.authStorage ?? (await discoverAuthStorage());
253
254
  const sessionId = this.#session.getSessionId?.() ?? undefined;
254
- const activeModelProvider = resolveActiveModelProvider(
255
- this.#session.model?.provider,
256
- this.#session.getActiveModelString?.(),
257
- );
258
- return executeSearch(_toolCallId, params, { authStorage, sessionId, signal, activeModelProvider });
255
+ const activeModelContext = this.#session.model
256
+ ? this.#session.modelRegistry?.getActiveSearchModelContext(this.#session.model)
257
+ : undefined;
258
+ return executeSearch(_toolCallId, params, { authStorage, sessionId, signal, activeModelContext });
259
259
  }
260
260
  }
261
261
 
@@ -279,7 +279,7 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
279
279
  authStorage,
280
280
  sessionId,
281
281
  signal,
282
- activeModelProvider: ctx.model?.provider,
282
+ activeModelContext: ctx.model ? ctx.modelRegistry?.getActiveSearchModelContext(ctx.model) : undefined,
283
283
  });
284
284
  },
285
285
 
@@ -296,6 +296,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
296
296
  return [webSearchCustomTool];
297
297
  }
298
298
 
299
- export { getSearchProvider, setPreferredSearchProvider } from "./provider";
299
+ export { getSearchProvider, setPreferredSearchProvider, setSearchFallbackProviders } from "./provider";
300
300
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
301
- export { isSearchProviderPreference } from "./types";
301
+ export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
@@ -10,7 +10,8 @@
10
10
 
11
11
  import type { AuthStorage } from "@gajae-code/ai";
12
12
  import type { SearchProvider } from "./providers/base";
13
- import type { SearchProviderId } from "./types";
13
+ import type { ActiveSearchModelContext, SearchProviderId } from "./types";
14
+ import { isConfigurableSearchProviderId } from "./types";
14
15
 
15
16
  export type { SearchParams } from "./providers/base";
16
17
  export { SearchProvider } from "./providers/base";
@@ -23,36 +24,16 @@ interface ProviderMeta {
23
24
 
24
25
  /** Lazy factories. Each `load()` dynamic-imports its provider module on first call. */
25
26
  const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
26
- exa: {
27
- id: "exa",
28
- label: "Exa",
29
- load: async () => new (await import("./providers/exa")).ExaProvider(),
30
- },
31
- brave: {
32
- id: "brave",
33
- label: "Brave",
34
- load: async () => new (await import("./providers/brave")).BraveProvider(),
35
- },
36
- jina: {
37
- id: "jina",
38
- label: "Jina",
39
- load: async () => new (await import("./providers/jina")).JinaProvider(),
40
- },
27
+ exa: { id: "exa", label: "Exa", load: async () => new (await import("./providers/exa")).ExaProvider() },
28
+ brave: { id: "brave", label: "Brave", load: async () => new (await import("./providers/brave")).BraveProvider() },
29
+ jina: { id: "jina", label: "Jina", load: async () => new (await import("./providers/jina")).JinaProvider() },
41
30
  perplexity: {
42
31
  id: "perplexity",
43
32
  label: "Perplexity",
44
33
  load: async () => new (await import("./providers/perplexity")).PerplexityProvider(),
45
34
  },
46
- kimi: {
47
- id: "kimi",
48
- label: "Kimi",
49
- load: async () => new (await import("./providers/kimi")).KimiProvider(),
50
- },
51
- zai: {
52
- id: "zai",
53
- label: "Z.AI",
54
- load: async () => new (await import("./providers/zai")).ZaiProvider(),
55
- },
35
+ kimi: { id: "kimi", label: "Kimi", load: async () => new (await import("./providers/kimi")).KimiProvider() },
36
+ zai: { id: "zai", label: "Z.AI", load: async () => new (await import("./providers/zai")).ZaiProvider() },
56
37
  anthropic: {
57
38
  id: "anthropic",
58
39
  label: "Anthropic",
@@ -63,11 +44,7 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
63
44
  label: "Gemini",
64
45
  load: async () => new (await import("./providers/gemini")).GeminiProvider(),
65
46
  },
66
- codex: {
67
- id: "codex",
68
- label: "OpenAI",
69
- load: async () => new (await import("./providers/codex")).CodexProvider(),
70
- },
47
+ codex: { id: "codex", label: "OpenAI", load: async () => new (await import("./providers/codex")).CodexProvider() },
71
48
  tavily: {
72
49
  id: "tavily",
73
50
  label: "Tavily",
@@ -78,11 +55,7 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
78
55
  label: "Parallel",
79
56
  load: async () => new (await import("./providers/parallel")).ParallelProvider(),
80
57
  },
81
- kagi: {
82
- id: "kagi",
83
- label: "Kagi",
84
- load: async () => new (await import("./providers/kagi")).KagiProvider(),
85
- },
58
+ kagi: { id: "kagi", label: "Kagi", load: async () => new (await import("./providers/kagi")).KagiProvider() },
86
59
  synthetic: {
87
60
  id: "synthetic",
88
61
  label: "Synthetic",
@@ -98,26 +71,24 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
98
71
  label: "DuckDuckGo",
99
72
  load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
100
73
  },
74
+ "openai-compatible": {
75
+ id: "openai-compatible",
76
+ label: "OpenAI-compatible",
77
+ load: async () => new (await import("./providers/openai-compatible")).OpenAICompatibleSearchProvider(),
78
+ },
101
79
  };
102
80
 
103
81
  const instanceCache = new Map<SearchProviderId, SearchProvider>();
104
82
 
105
- /** Cheap, sync metadata accessor — never triggers a provider load. */
106
83
  export function getSearchProviderLabel(id: SearchProviderId): string {
107
84
  return PROVIDER_META[id]?.label ?? id;
108
85
  }
109
86
 
110
- /**
111
- * Resolve and cache a provider instance. First call for a given id loads the
112
- * underlying module; subsequent calls return the cached singleton.
113
- */
114
87
  export async function getSearchProvider(id: SearchProviderId): Promise<SearchProvider> {
115
88
  const cached = instanceCache.get(id);
116
89
  if (cached) return cached;
117
90
  const meta = PROVIDER_META[id];
118
- if (!meta) {
119
- throw new Error(`Unknown search provider: ${id}`);
120
- }
91
+ if (!meta) throw new Error(`Unknown search provider: ${id}`);
121
92
  const provider = await meta.load();
122
93
  instanceCache.set(id, provider);
123
94
  return provider;
@@ -141,13 +112,6 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
141
112
  "searxng",
142
113
  ];
143
114
 
144
- /**
145
- * Map an active model's provider string to its own native web-search provider.
146
- * Keys are real model provider ids (see packages/ai/src/types.ts KnownProvider);
147
- * a few aliases (gemini/kimi) and API strings (openai-responses) are tolerated
148
- * defensively. Providers absent from this map (custom/unknown) fall through to
149
- * DuckDuckGo.
150
- */
151
115
  const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
152
116
  openai: "codex",
153
117
  "openai-codex": "codex",
@@ -165,54 +129,181 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
165
129
  synthetic: "synthetic",
166
130
  };
167
131
 
168
- /** Preferred provider set via settings (default: auto) */
169
132
  let preferredProvId: SearchProviderId | "auto" = "auto";
133
+ let fallbackProvIds: SearchProviderId[] = [];
170
134
 
171
- /** Set the preferred web search provider from settings */
172
135
  export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
173
136
  preferredProvId = provider;
174
137
  }
175
138
 
176
- /**
177
- * Resolve the ordered provider chain for a search request.
178
- *
179
- * Resolution is active-model-gated, never credential-scanning:
180
- * 1. An explicitly preferred provider (settings) that is available is primary.
181
- * 2. Otherwise the active model's own native search is primary, but only when
182
- * that provider's own credentials are present (its `isAvailable()`).
183
- * 3. DuckDuckGo (keyless) is always appended as the terminal fallback, so a
184
- * missing primary — or a primary runtime failure — still returns results
185
- * with zero configuration. Keyed standalone providers are never
186
- * auto-selected; they are reachable only via explicit selection (step 1).
187
- */
188
- export async function resolveProviderChain(
139
+ export function setSearchFallbackProviders(ids: readonly string[]): void {
140
+ fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
141
+ }
142
+
143
+ export interface ResolveProviderChainOptions {
144
+ authStorage: AuthStorage;
145
+ sessionId?: string;
146
+ signal?: AbortSignal;
147
+ preferredProvider?: SearchProviderId | "auto";
148
+ activeModelContext?: ActiveSearchModelContext;
149
+ fallbackProviders?: readonly SearchProviderId[];
150
+ }
151
+
152
+ async function appendAvailable(
153
+ chain: SearchProviderId[],
154
+ id: SearchProviderId,
155
+ authStorage: AuthStorage,
156
+ ): Promise<void> {
157
+ if (chain.includes(id)) return;
158
+ const provider = await getSearchProvider(id);
159
+ if (await provider.isAvailable(authStorage)) chain.push(id);
160
+ }
161
+
162
+ function appendDeduped(chain: SearchProviderId[], id: SearchProviderId): void {
163
+ if (!chain.includes(id)) chain.push(id);
164
+ }
165
+
166
+ function isAnthropicWire(api: string): boolean {
167
+ return api === "anthropic-messages";
168
+ }
169
+
170
+ function isGoogleWire(api: string): boolean {
171
+ return api === "google-generative-ai" || api === "google-vertex" || api === "google-gemini-cli";
172
+ }
173
+
174
+ function isOpenAICompatWire(api: string): boolean {
175
+ return api === "openai-responses" || api === "openai-completions" || api === "azure-openai-responses";
176
+ }
177
+
178
+ export function looksHostedModelId(modelId: string | undefined): boolean {
179
+ if (!modelId) return false;
180
+ const id = modelId.toLowerCase();
181
+ return /^(gpt-|o\d|o-|chatgpt-|text-|davinci|babbage|curie)/.test(id);
182
+ }
183
+
184
+ function looksOpenAIFamilyModelId(ctx: ActiveSearchModelContext): boolean {
185
+ return looksHostedModelId(ctx.wireModelId) || looksHostedModelId(ctx.modelId);
186
+ }
187
+
188
+ export function isLocalBaseUrl(baseUrl: string | undefined): boolean {
189
+ if (!baseUrl) return false;
190
+ let url: URL;
191
+ try {
192
+ url = new URL(baseUrl);
193
+ } catch {
194
+ return true;
195
+ }
196
+ const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
197
+ if (
198
+ host === "localhost" ||
199
+ host.endsWith(".localhost") ||
200
+ host === "host.docker.internal" ||
201
+ host.endsWith(".local")
202
+ )
203
+ return true;
204
+ const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
205
+ if (v4) {
206
+ const [a, b] = v4.slice(1, 3).map(Number);
207
+ if (
208
+ a === 127 ||
209
+ a === 0 ||
210
+ a === 10 ||
211
+ (a === 172 && b >= 16 && b <= 31) ||
212
+ (a === 192 && b === 168) ||
213
+ (a === 169 && b === 254)
214
+ )
215
+ return true;
216
+ }
217
+ if (host === "::1" || host === "::") return true;
218
+ if (host.startsWith("fc") || host.startsWith("fd")) return true;
219
+ if (host.startsWith("fe8") || host.startsWith("fe9") || host.startsWith("fea") || host.startsWith("feb"))
220
+ return true;
221
+ return false;
222
+ }
223
+
224
+ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
225
+ if (!ctx || ctx.webSearch === "off") return undefined;
226
+ const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
227
+ if (modelId.startsWith("claude-") && isAnthropicWire(ctx.api)) return "anthropic";
228
+ if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api)) return "gemini";
229
+ if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) {
230
+ if (ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl)) return "codex";
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ function canUseDirectProviderMapping(ctx: ActiveSearchModelContext, id: SearchProviderId): boolean {
236
+ if (ctx.webSearch === "off") return false;
237
+ if (id !== "codex") return true;
238
+ if (!isOpenAICompatWire(ctx.api)) return true;
239
+ return ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl);
240
+ }
241
+
242
+ export async function canUseGenericCredentials(
189
243
  authStorage: AuthStorage,
190
- preferredProvider: SearchProviderId | "auto" = preferredProvId,
191
- activeModelProvider?: string,
192
- ): Promise<SearchProvider[]> {
244
+ ctx: ActiveSearchModelContext | undefined,
245
+ sessionId?: string,
246
+ signal?: AbortSignal,
247
+ ): Promise<boolean> {
248
+ if (!ctx) return false;
249
+ const key = await authStorage.getApiKey(ctx.provider, sessionId, {
250
+ baseUrl: ctx.baseUrl,
251
+ modelId: ctx.modelId,
252
+ signal,
253
+ });
254
+ return Boolean(key);
255
+ }
256
+
257
+ export async function shouldTryGenericOpenAICompat(
258
+ authStorage: AuthStorage,
259
+ ctx: ActiveSearchModelContext | undefined,
260
+ sessionId?: string,
261
+ signal?: AbortSignal,
262
+ ): Promise<boolean> {
263
+ if (!ctx || ctx.webSearch === "off" || !isOpenAICompatWire(ctx.api)) return false;
264
+ const autoAllowed =
265
+ ctx.webSearch === "on" ||
266
+ ((ctx.api === "openai-responses" || looksOpenAIFamilyModelId(ctx)) && !isLocalBaseUrl(ctx.baseUrl));
267
+ return autoAllowed && (await canUseGenericCredentials(authStorage, ctx, sessionId, signal));
268
+ }
269
+
270
+ export async function resolveProviderChain(options: ResolveProviderChainOptions): Promise<SearchProvider[]> {
271
+ const {
272
+ authStorage,
273
+ sessionId,
274
+ signal,
275
+ preferredProvider = preferredProvId,
276
+ activeModelContext,
277
+ fallbackProviders = fallbackProvIds,
278
+ } = options;
193
279
  const chain: SearchProviderId[] = [];
194
280
 
195
- if (preferredProvider !== "auto") {
196
- const provider = await getSearchProvider(preferredProvider);
197
- if (await provider.isAvailable(authStorage)) {
198
- chain.push(preferredProvider);
199
- }
200
- } else if (activeModelProvider) {
201
- const nativeId = MODEL_PROVIDER_TO_SEARCH[activeModelProvider.toLowerCase()];
202
- if (nativeId) {
203
- const provider = await getSearchProvider(nativeId);
204
- if (await provider.isAvailable(authStorage)) {
205
- chain.push(nativeId);
206
- }
207
- }
281
+ // A forced primary is honored only when it is a user-configurable provider.
282
+ // The internal `openai-compatible` adapter (and any non-configurable value) is
283
+ // never selectable as a forced primary; such inputs fall through to auto
284
+ // native resolution instead of being injected into the chain.
285
+ if (preferredProvider !== "auto" && isConfigurableSearchProviderId(preferredProvider)) {
286
+ await appendAvailable(chain, preferredProvider, authStorage);
287
+ } else if (activeModelContext) {
288
+ const directId = MODEL_PROVIDER_TO_SEARCH[activeModelContext.provider.toLowerCase()];
289
+ if (directId && canUseDirectProviderMapping(activeModelContext, directId))
290
+ await appendAvailable(chain, directId, authStorage);
291
+ const inferred = inferNativeProviderFromModel(activeModelContext);
292
+ if (inferred) await appendAvailable(chain, inferred, authStorage);
293
+ if (await shouldTryGenericOpenAICompat(authStorage, activeModelContext, sessionId, signal))
294
+ appendDeduped(chain, "openai-compatible");
208
295
  }
209
296
 
210
- // DuckDuckGo is the permissionless terminal fallback (deduped).
211
- if (!chain.includes("duckduckgo")) chain.push("duckduckgo");
297
+ // Configured fallbacks are user-facing only: the internal `openai-compatible`
298
+ // adapter (and any non-configurable id) can never enter the chain through the
299
+ // fallback list, regardless of how `fallbackProviders` was supplied.
300
+ for (const id of fallbackProviders) {
301
+ if (!isConfigurableSearchProviderId(id)) continue;
302
+ await appendAvailable(chain, id, authStorage);
303
+ }
304
+ appendDeduped(chain, "duckduckgo");
212
305
 
213
306
  const providers: SearchProvider[] = [];
214
- for (const id of chain) {
215
- providers.push(await getSearchProvider(id));
216
- }
307
+ for (const id of chain) providers.push(await getSearchProvider(id));
217
308
  return providers;
218
309
  }