@bubblebrain-ai/bubble 0.0.35 → 0.0.36

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 (143) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/categories.d.ts +66 -2
  3. package/dist/agent/categories.js +120 -19
  4. package/dist/agent/discovery-barrier.js +1 -1
  5. package/dist/agent/profiles.d.ts +1 -0
  6. package/dist/agent/profiles.js +25 -11
  7. package/dist/agent/routing-catalog.d.ts +113 -0
  8. package/dist/agent/routing-catalog.js +240 -0
  9. package/dist/agent/workflow/control.d.ts +14 -0
  10. package/dist/agent/workflow/control.js +30 -9
  11. package/dist/agent.d.ts +75 -5
  12. package/dist/agent.js +313 -61
  13. package/dist/approval/controller.js +28 -0
  14. package/dist/approval/tool-helper.js +2 -0
  15. package/dist/approval/types.d.ts +21 -1
  16. package/dist/cli.d.ts +1 -1
  17. package/dist/cli.js +2 -1
  18. package/dist/config.d.ts +4 -0
  19. package/dist/config.js +4 -0
  20. package/dist/context/compact-llm.d.ts +2 -2
  21. package/dist/external-runtime/grok-acp.d.ts +56 -0
  22. package/dist/external-runtime/grok-acp.js +386 -0
  23. package/dist/external-runtime/grok-binary.d.ts +25 -0
  24. package/dist/external-runtime/grok-binary.js +108 -0
  25. package/dist/external-runtime/grok-errors.d.ts +7 -0
  26. package/dist/external-runtime/grok-errors.js +15 -0
  27. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  28. package/dist/external-runtime/grok-input-policy.js +52 -0
  29. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  30. package/dist/external-runtime/grok-ndjson.js +193 -0
  31. package/dist/external-runtime/grok-network.d.ts +15 -0
  32. package/dist/external-runtime/grok-network.js +81 -0
  33. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  34. package/dist/external-runtime/grok-preflight.js +194 -0
  35. package/dist/external-runtime/grok-process.d.ts +26 -0
  36. package/dist/external-runtime/grok-process.js +184 -0
  37. package/dist/external-runtime/grok-profile.d.ts +32 -0
  38. package/dist/external-runtime/grok-profile.js +419 -0
  39. package/dist/external-runtime/grok-provider.d.ts +31 -0
  40. package/dist/external-runtime/grok-provider.js +41 -0
  41. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  42. package/dist/external-runtime/grok-runtime.js +882 -0
  43. package/dist/external-runtime/index.d.ts +13 -0
  44. package/dist/external-runtime/index.js +13 -0
  45. package/dist/external-runtime/session-policy.d.ts +11 -0
  46. package/dist/external-runtime/session-policy.js +30 -0
  47. package/dist/external-runtime/types.d.ts +65 -0
  48. package/dist/external-runtime/types.js +1 -0
  49. package/dist/feishu/agent-host/approval-card.js +21 -0
  50. package/dist/feishu/agent-host/run-driver.js +19 -3
  51. package/dist/feishu/serve.js +1 -0
  52. package/dist/goal/tools.d.ts +6 -3
  53. package/dist/goal/tools.js +7 -19
  54. package/dist/main.js +105 -27
  55. package/dist/memory/db.d.ts +2 -0
  56. package/dist/memory/db.js +10 -0
  57. package/dist/memory/index.d.ts +1 -0
  58. package/dist/memory/index.js +1 -0
  59. package/dist/memory/phase1.js +14 -2
  60. package/dist/memory/phase2.js +21 -2
  61. package/dist/memory/prompts.js +1 -1
  62. package/dist/memory/session-policy.d.ts +25 -0
  63. package/dist/memory/session-policy.js +55 -0
  64. package/dist/memory/storage.d.ts +1 -0
  65. package/dist/memory/storage.js +13 -0
  66. package/dist/model-catalog.d.ts +18 -0
  67. package/dist/model-catalog.js +87 -41
  68. package/dist/model-config.d.ts +3 -1
  69. package/dist/model-config.js +11 -0
  70. package/dist/oauth/grok.d.ts +25 -0
  71. package/dist/oauth/grok.js +218 -0
  72. package/dist/oauth/index.d.ts +1 -0
  73. package/dist/oauth/index.js +1 -0
  74. package/dist/oauth/storage.d.ts +9 -0
  75. package/dist/oauth/storage.js +25 -0
  76. package/dist/orchestrator/default-hooks.js +17 -7
  77. package/dist/prompt/compose.d.ts +3 -0
  78. package/dist/prompt/compose.js +3 -0
  79. package/dist/prompt/delegation.js +22 -3
  80. package/dist/prompt/environment.js +0 -4
  81. package/dist/prompt/reminders.d.ts +0 -1
  82. package/dist/prompt/reminders.js +1 -11
  83. package/dist/prompt/routing.d.ts +14 -0
  84. package/dist/prompt/routing.js +71 -0
  85. package/dist/prompt/runtime.js +1 -1
  86. package/dist/prompt/task-reminders.d.ts +19 -0
  87. package/dist/prompt/task-reminders.js +49 -0
  88. package/dist/provider-ai-sdk.js +2 -2
  89. package/dist/provider-grok.d.ts +26 -0
  90. package/dist/provider-grok.js +59 -0
  91. package/dist/provider-openai-codex.d.ts +17 -1
  92. package/dist/provider-openai-codex.js +101 -29
  93. package/dist/provider-registry.d.ts +72 -0
  94. package/dist/provider-registry.js +336 -59
  95. package/dist/provider-transform.d.ts +1 -1
  96. package/dist/provider-transform.js +28 -5
  97. package/dist/provider.d.ts +3 -0
  98. package/dist/provider.js +14 -0
  99. package/dist/session-types.d.ts +19 -1
  100. package/dist/session.d.ts +1 -0
  101. package/dist/session.js +4 -0
  102. package/dist/slash-commands/commands.js +224 -14
  103. package/dist/slash-commands/types.d.ts +14 -1
  104. package/dist/system-prompt.d.ts +2 -0
  105. package/dist/tools/agent-lifecycle.js +9 -11
  106. package/dist/tools/bash.js +1 -1
  107. package/dist/tools/child-tools.js +2 -0
  108. package/dist/tools/index.d.ts +2 -2
  109. package/dist/tools/index.js +7 -5
  110. package/dist/tools/lsp.js +1 -0
  111. package/dist/tools/memory.d.ts +7 -2
  112. package/dist/tools/memory.js +28 -43
  113. package/dist/tools/server.js +4 -0
  114. package/dist/tools/tool-search.d.ts +1 -1
  115. package/dist/tools/tool-search.js +23 -3
  116. package/dist/tui/model-switch.d.ts +7 -0
  117. package/dist/tui/model-switch.js +15 -5
  118. package/dist/tui-ink/app.d.ts +3 -1
  119. package/dist/tui-ink/app.js +648 -232
  120. package/dist/tui-ink/approval/approval-dialog.js +36 -0
  121. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  122. package/dist/tui-ink/composer-buffer.js +317 -0
  123. package/dist/tui-ink/footer.d.ts +1 -0
  124. package/dist/tui-ink/footer.js +7 -2
  125. package/dist/tui-ink/input-box.d.ts +35 -1
  126. package/dist/tui-ink/input-box.js +215 -122
  127. package/dist/tui-ink/model-picker.d.ts +61 -4
  128. package/dist/tui-ink/model-picker.js +453 -123
  129. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  130. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  131. package/dist/tui-ink/run.d.ts +3 -0
  132. package/dist/tui-ink/run.js +1 -1
  133. package/dist/tui-ink/subagent-view.d.ts +31 -0
  134. package/dist/tui-ink/subagent-view.js +82 -1
  135. package/dist/types.d.ts +5 -5
  136. package/dist/types.js +1 -1
  137. package/dist/variant/thinking-level.d.ts +2 -2
  138. package/dist/variant/thinking-level.js +2 -1
  139. package/dist/variant/variant-resolver.d.ts +9 -0
  140. package/dist/variant/variant-resolver.js +17 -0
  141. package/package.json +2 -1
  142. package/dist/tools/task.d.ts +0 -2
  143. package/dist/tools/task.js +0 -58
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Bubble is a terminal coding agent. It works inside a local project folder: it reads and edits files, runs commands behind configurable approval controls, searches and navigates code with language-server intelligence, browses the web, loads reusable skills, connects MCP tools, fans work out to subagents, and keeps persistent memory across sessions.
4
4
 
5
- It is provider-agnostic. Bring an API key for OpenAI, Anthropic, Google, DeepSeek, Moonshot/Kimi, Zhipu, Z.AI, MiniMax, Groq, Together, Fireworks, a local OpenAI-compatible endpoint, and more — or sign in to ChatGPT with OAuth and drive the Codex models directly.
5
+ It is provider-agnostic. Bring an API key for OpenAI, Anthropic, Google, DeepSeek, Moonshot/Kimi, Zhipu, Z.AI, MiniMax, Groq, Together, Fireworks, a local OpenAI-compatible endpoint, and more; sign in to ChatGPT with OAuth; or sign in with OAuth to use a ChatGPT or Grok subscription directly.
6
6
 
7
7
  ---
8
8
 
@@ -39,10 +39,10 @@ Start Bubble in the current directory:
39
39
  bubble
40
40
  ```
41
41
 
42
- On first launch, connect a model:
42
+ On first launch, connect a provider:
43
43
 
44
44
  - Run `/login` to sign in to ChatGPT (OAuth) and use the Codex models, or
45
- - Run `/provider` to add any other provider with an API key.
45
+ - Run `/provider` to select Grok Subscription or add a provider with an API key.
46
46
 
47
47
  Then just type what you want done. Bubble plans, edits files, and runs commands, asking for approval where the current permission mode requires it.
48
48
 
@@ -64,10 +64,30 @@ Bubble ships with a catalog of built-in providers. Configure them inside the app
64
64
 
65
65
  | How | What it does |
66
66
  | --- | --- |
67
- | `/login` | OAuth sign-in for ChatGPT; unlocks the OpenAI Codex models without an API key. |
68
- | `/provider` | Open a picker to connect, switch, add, or remove a provider. |
67
+ | `/login` | OAuth sign-in for ChatGPT or Grok Subscription; `/login grok` starts the Grok flow directly. |
68
+ | `/provider` | Open a picker to connect, switch, add, or remove a provider, including Grok Subscription. |
69
69
  | `/model` | Pick the active model and reasoning effort. |
70
70
 
71
+ ### Grok Subscription Provider
72
+
73
+ A Grok subscription works as a native model provider, exactly like ChatGPT
74
+ OAuth: sign in once in the browser and the subscription models (`grok-4.5`,
75
+ `grok-composer-2.5-fast`) drive Bubble's own agent loop — Bubble's tools,
76
+ skills, MCP servers, subagents, memory, permission modes, and approval UI all
77
+ apply as usual. `/model` selects the model and reasoning effort.
78
+
79
+ ```bash
80
+ bubble
81
+ # In the TUI: /login grok
82
+ # or choose Grok Subscription from /provider
83
+ ```
84
+
85
+ `/login grok` opens the xAI sign-in page in the default browser. If you have
86
+ already signed in with the official Grok CLI, Bubble imports that login and
87
+ skips the browser. Tokens are stored in `~/.bubble/auth.json` alongside other
88
+ provider credentials and refresh automatically. Use `/logout grok` to remove
89
+ this device's login.
90
+
71
91
  Built-in providers include OpenAI, Anthropic, Google, DeepSeek, Moonshot (CN and international), Kimi for Coding, Zhipu AI, Z.AI, Alibaba DashScope, Doubao (Volcengine Ark), MiniMax, StepFun, Groq, Together AI, Fireworks, and a `local` profile for any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, etc.).
72
92
 
73
93
  For Doubao Seed models on Volcengine Ark, run `/provider --add doubao` and paste your Ark API key. The built-in endpoint is `https://ark.cn-beijing.volces.com/api/v3` and uses Ark's Responses API. The model picker exposes `minimal`, `low`, `medium`, and `high`, defaulting to `high`; `minimal` disables Ark thinking, while the other levels enable it.
@@ -95,7 +115,7 @@ For full control — custom base URLs, self-hosted gateways, extra models, or pi
95
115
 
96
116
  ### Reasoning effort
97
117
 
98
- Models that support it expose a reasoning-effort control: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Set it with `/model <id> --reasoning-effort <level>` in the app, or at launch with `--reasoning` (medium) or `--reasoning-effort <level>`.
118
+ Models that support it expose a reasoning-effort control: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Set it with `/model <id> --reasoning-effort <level>` in the app, or at launch with `--reasoning` (medium) or `--reasoning-effort <level>`.
99
119
 
100
120
  ## Permission modes
101
121
 
@@ -191,7 +211,7 @@ Options (default):
191
211
  -r, --resume Resume a previous session (latest by default)
192
212
  --session <name> Session name to create or resume
193
213
  --reasoning Enable reasoning mode at medium effort
194
- --reasoning-effort <l> Set reasoning effort: off|minimal|low|medium|high|xhigh|max
214
+ --reasoning-effort <l> Set reasoning effort: off|minimal|low|medium|high|xhigh|max|ultra
195
215
  --plan Start in plan mode (read-only investigation; propose before executing)
196
216
  --dangerously-skip-permissions
197
217
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
@@ -1,18 +1,50 @@
1
- import type { ThinkingLevel } from "../types.js";
1
+ import type { ModelTier } from "../model-catalog.js";
2
+ import { type ThinkingLevel } from "../types.js";
2
3
  export interface AgentCategoryConfig {
3
4
  model?: string;
5
+ /**
6
+ * Resolve the model by tier within the parent provider's catalog
7
+ * (docs/model-routing-design.md §3.1). Mutually exclusive with `model`;
8
+ * `model` wins if both are set.
9
+ */
10
+ tier?: ModelTier | "inherit";
4
11
  thinkingLevel?: ThinkingLevel;
5
12
  maxConcurrent?: number;
6
13
  }
7
14
  export type AgentCategoriesConfig = Record<string, AgentCategoryConfig>;
15
+ /**
16
+ * Field-level provenance for the model-determining fields (§3.3): a value is
17
+ * "user" ONLY when the user's config object literally contained that key.
18
+ * thinkingLevel/maxConcurrent overrides carry no routing authority, so a
19
+ * partially-overridden builtin category keeps its builtin tier under the
20
+ * rank guard.
21
+ */
22
+ export interface AgentCategoryProvenance {
23
+ tierSource?: "builtin" | "user";
24
+ modelSource?: "builtin" | "user";
25
+ }
26
+ export interface MergedAgentCategory {
27
+ config: AgentCategoryConfig;
28
+ provenance: AgentCategoryProvenance;
29
+ }
8
30
  export interface ModelRoute {
9
31
  providerId: string;
10
32
  model: string;
11
33
  thinkingLevel: ThinkingLevel;
12
34
  }
35
+ /** Which precedence layer decided the model at the category level. */
36
+ export type CategoryModelSource = "builtin-tier" | "user-category";
37
+ /** Which precedence layer decided the FINAL model (§3.4, chain-tracked). */
38
+ export type RouteModelSource = "inherit" | CategoryModelSource | "profile" | "callsite";
13
39
  export interface ResolvedSubagentRoute extends ModelRoute {
14
40
  category?: string;
15
41
  inherited: boolean;
42
+ /** Set when the category layer decided the model (undefined = inherit). */
43
+ categoryModelSource?: CategoryModelSource;
44
+ /** True iff providerId+model equal the parent's — regardless of effort (§3.4). */
45
+ modelInherited?: boolean;
46
+ /** Assigned while applying the precedence chain, never inferred from final values. */
47
+ modelSource?: RouteModelSource;
16
48
  }
17
49
  export type CategoryResolution = {
18
50
  route: ResolvedSubagentRoute;
@@ -23,10 +55,42 @@ export interface ResolvedModelSelection {
23
55
  providerId: string;
24
56
  model: string | "inherit";
25
57
  }
58
+ /** Catalog entry visible to tier resolution (built by routing-catalog.ts). */
59
+ export interface TierCatalogEntry {
60
+ id: string;
61
+ tier?: ModelTier;
62
+ /** models.json user-authored ordering; wins over everything (§3.2). */
63
+ routingPriority?: number;
64
+ /** Index in the builtin catalog order, when the id is builtin. */
65
+ builtinIndex?: number;
66
+ }
67
+ /** Context for rank-guarded automatic tier routing (§3.2). */
68
+ export interface TierRoutingContext {
69
+ /** Tier of the parent's own model, if annotated. */
70
+ parentTier?: ModelTier;
71
+ /** Same-provider catalog the tier is resolved against. */
72
+ models: TierCatalogEntry[];
73
+ /** agentRouting.autoTier kill switch (default true). */
74
+ autoTier: boolean;
75
+ }
76
+ export declare const TIER_RANK: Record<ModelTier, number>;
26
77
  export declare function builtinAgentCategories(): AgentCategoriesConfig;
27
78
  export declare function mergeAgentCategories(userCategories?: AgentCategoriesConfig): AgentCategoriesConfig;
79
+ /**
80
+ * Field-level merge with provenance (§3.3). The merge itself is identical to
81
+ * mergeAgentCategories; provenance records whether the user literally wrote
82
+ * `tier` / `model`, so builtin tiers flowing through a partial override never
83
+ * masquerade as user intent.
84
+ */
85
+ export declare function mergeAgentCategoriesWithProvenance(userCategories?: AgentCategoriesConfig): Record<string, MergedAgentCategory>;
28
86
  export declare function sanitizeAgentCategories(value: unknown): AgentCategoriesConfig;
29
- export declare function resolveSubagentRoute(category: string | undefined, parent: ModelRoute, categories?: AgentCategoriesConfig): CategoryResolution;
87
+ export declare function resolveSubagentRoute(category: string | undefined, parent: ModelRoute, categories?: AgentCategoriesConfig, tierContext?: TierRoutingContext): CategoryResolution;
88
+ /**
89
+ * Deterministic within-tier ordering (§3.2): user routingPriority, then
90
+ * builtin catalog order, then normalized id — never a remote response's
91
+ * array order.
92
+ */
93
+ export declare function selectTierCandidates(models: TierCatalogEntry[], tier: ModelTier): TierCatalogEntry[];
30
94
  export declare function resolveModelRoute(model: string | undefined, parentProviderId: string): ResolvedModelSelection;
31
95
  export declare function resolveSameProviderModelRoute(model: string | undefined, parentProviderId: string): {
32
96
  model: string | "inherit";
@@ -1,16 +1,9 @@
1
- const THINKING_LEVELS = new Set([
2
- "off",
3
- "minimal",
4
- "low",
5
- "medium",
6
- "high",
7
- "xhigh",
8
- "max",
9
- ]);
1
+ import { THINKING_LEVELS } from "../types.js";
2
+ export const TIER_RANK = { fast: 0, balanced: 1, strong: 2 };
10
3
  const BUILTIN_CATEGORIES = {
11
- quick: { model: "inherit", thinkingLevel: "low", maxConcurrent: 3 },
4
+ quick: { model: "inherit", tier: "fast", thinkingLevel: "low", maxConcurrent: 3 },
5
+ explore: { model: "inherit", tier: "fast", thinkingLevel: "low", maxConcurrent: 3 },
12
6
  deep: { model: "inherit", thinkingLevel: "high", maxConcurrent: 2 },
13
- explore: { model: "inherit", thinkingLevel: "low", maxConcurrent: 3 },
14
7
  review: { model: "inherit", thinkingLevel: "high", maxConcurrent: 2 },
15
8
  frontend: { model: "inherit", thinkingLevel: "high", maxConcurrent: 1 },
16
9
  writing: { model: "inherit", thinkingLevel: "medium", maxConcurrent: 2 },
@@ -28,6 +21,36 @@ export function mergeAgentCategories(userCategories) {
28
21
  }
29
22
  return merged;
30
23
  }
24
+ /**
25
+ * Field-level merge with provenance (§3.3). The merge itself is identical to
26
+ * mergeAgentCategories; provenance records whether the user literally wrote
27
+ * `tier` / `model`, so builtin tiers flowing through a partial override never
28
+ * masquerade as user intent.
29
+ */
30
+ export function mergeAgentCategoriesWithProvenance(userCategories) {
31
+ const sanitizedUser = sanitizeAgentCategories(userCategories);
32
+ const out = {};
33
+ for (const [name, config] of Object.entries(builtinAgentCategories())) {
34
+ out[name] = {
35
+ config: { ...config },
36
+ provenance: {
37
+ tierSource: config.tier !== undefined ? "builtin" : undefined,
38
+ modelSource: config.model !== undefined && config.model !== "inherit" ? "builtin" : undefined,
39
+ },
40
+ };
41
+ }
42
+ for (const [name, config] of Object.entries(sanitizedUser)) {
43
+ const existing = out[name] ?? { config: {}, provenance: {} };
44
+ out[name] = {
45
+ config: { ...existing.config, ...config },
46
+ provenance: {
47
+ tierSource: config.tier !== undefined ? "user" : existing.provenance.tierSource,
48
+ modelSource: config.model !== undefined ? "user" : existing.provenance.modelSource,
49
+ },
50
+ };
51
+ }
52
+ return out;
53
+ }
31
54
  export function sanitizeAgentCategories(value) {
32
55
  if (!value || typeof value !== "object" || Array.isArray(value))
33
56
  return {};
@@ -41,6 +64,9 @@ export function sanitizeAgentCategories(value) {
41
64
  if (typeof record.model === "string" && record.model.trim()) {
42
65
  config.model = record.model.trim();
43
66
  }
67
+ if (isCategoryTier(record.tier)) {
68
+ config.tier = record.tier;
69
+ }
44
70
  if (isThinkingLevel(record.thinkingLevel)) {
45
71
  config.thinkingLevel = record.thinkingLevel;
46
72
  }
@@ -51,27 +77,99 @@ export function sanitizeAgentCategories(value) {
51
77
  }
52
78
  return out;
53
79
  }
54
- export function resolveSubagentRoute(category, parent, categories) {
80
+ export function resolveSubagentRoute(category, parent, categories, tierContext) {
55
81
  const normalizedCategory = normalizeCategoryName(category);
56
82
  if (!normalizedCategory) {
57
83
  return { route: { ...parent, inherited: true } };
58
84
  }
59
- const merged = mergeAgentCategories(categories);
60
- const config = merged[normalizedCategory];
61
- if (!config) {
85
+ const merged = mergeAgentCategoriesWithProvenance(categories);
86
+ const entry = merged[normalizedCategory];
87
+ if (!entry) {
62
88
  return { error: `Unknown subagent category "${normalizedCategory}".` };
63
89
  }
90
+ const { config, provenance } = entry;
91
+ // Explicit model binding wins over tier (config surface contract §3.1).
64
92
  const modelSelection = parseModelSelection(config.model, parent.providerId);
93
+ if (modelSelection.model !== "inherit") {
94
+ return {
95
+ route: {
96
+ category: normalizedCategory,
97
+ providerId: modelSelection.providerId,
98
+ model: modelSelection.model,
99
+ thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
100
+ inherited: false,
101
+ categoryModelSource: provenance.modelSource === "user" ? "user-category" : "builtin-tier",
102
+ },
103
+ };
104
+ }
105
+ // Tier binding: rank-guarded for builtin-sourced tiers, free for
106
+ // user-authored ones (§3.2).
107
+ const tierModel = resolveTierModel(config.tier, provenance.tierSource, tierContext);
108
+ if (tierModel) {
109
+ return {
110
+ route: {
111
+ category: normalizedCategory,
112
+ providerId: parent.providerId,
113
+ model: tierModel,
114
+ thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
115
+ inherited: false,
116
+ categoryModelSource: provenance.tierSource === "user" ? "user-category" : "builtin-tier",
117
+ },
118
+ };
119
+ }
65
120
  return {
66
121
  route: {
67
122
  category: normalizedCategory,
68
- providerId: modelSelection.providerId,
69
- model: modelSelection.model === "inherit" ? parent.model : modelSelection.model,
123
+ providerId: parent.providerId,
124
+ model: parent.model,
70
125
  thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
71
- inherited: modelSelection.model === "inherit" && config.thinkingLevel === undefined,
126
+ inherited: config.thinkingLevel === undefined,
72
127
  },
73
128
  };
74
129
  }
130
+ /**
131
+ * Rank-guarded tier -> model resolution (§3.2). Returns undefined whenever
132
+ * the route must inherit: no tier context wired, kill switch off (builtin
133
+ * tiers only), unknown parent tier, missing target tier, or a target that is
134
+ * not strictly cheaper than the parent.
135
+ */
136
+ function resolveTierModel(tier, tierSource, tierContext) {
137
+ if (!tier || tier === "inherit" || !tierContext)
138
+ return undefined;
139
+ const candidates = selectTierCandidates(tierContext.models, tier);
140
+ if (candidates.length === 0)
141
+ return undefined;
142
+ if (tierSource !== "user") {
143
+ // Builtin-sourced tier: downgrade-only, enforced by rank comparison.
144
+ if (!tierContext.autoTier)
145
+ return undefined;
146
+ if (!tierContext.parentTier)
147
+ return undefined;
148
+ if (TIER_RANK[tier] >= TIER_RANK[tierContext.parentTier])
149
+ return undefined;
150
+ }
151
+ return candidates[0].id;
152
+ }
153
+ /**
154
+ * Deterministic within-tier ordering (§3.2): user routingPriority, then
155
+ * builtin catalog order, then normalized id — never a remote response's
156
+ * array order.
157
+ */
158
+ export function selectTierCandidates(models, tier) {
159
+ return models
160
+ .filter((entry) => entry.tier === tier)
161
+ .sort((a, b) => {
162
+ const priorityA = a.routingPriority ?? Number.POSITIVE_INFINITY;
163
+ const priorityB = b.routingPriority ?? Number.POSITIVE_INFINITY;
164
+ if (priorityA !== priorityB)
165
+ return priorityA - priorityB;
166
+ const builtinA = a.builtinIndex ?? Number.POSITIVE_INFINITY;
167
+ const builtinB = b.builtinIndex ?? Number.POSITIVE_INFINITY;
168
+ if (builtinA !== builtinB)
169
+ return builtinA - builtinB;
170
+ return a.id.localeCompare(b.id);
171
+ });
172
+ }
75
173
  export function resolveModelRoute(model, parentProviderId) {
76
174
  return parseModelSelection(model, parentProviderId);
77
175
  }
@@ -95,7 +193,10 @@ function parseModelSelection(model, parentProviderId) {
95
193
  return { providerId: parentProviderId, model };
96
194
  }
97
195
  function isThinkingLevel(value) {
98
- return typeof value === "string" && THINKING_LEVELS.has(value);
196
+ return typeof value === "string" && THINKING_LEVELS.includes(value);
197
+ }
198
+ function isCategoryTier(value) {
199
+ return value === "fast" || value === "balanced" || value === "strong" || value === "inherit";
99
200
  }
100
201
  function cloneCategories(categories) {
101
202
  return Object.fromEntries(Object.entries(categories).map(([name, config]) => [name, { ...config }]));
@@ -46,7 +46,7 @@ export class DiscoveryBarrier {
46
46
  return undefined;
47
47
  return {
48
48
  content: `Blocked speculative ${target.kind}. The path "${target.path}" has not been discovered in this repository context ` +
49
- "and was not explicitly requested by the user. Run glob/grep/lsp discovery first, then read only returned paths. " +
49
+ "and was not explicitly requested by the user. Run glob/grep discovery first, then read only returned paths. " +
50
50
  "Do not infer from this blocked call whether the path exists.",
51
51
  isError: true,
52
52
  status: "blocked",
@@ -67,3 +67,4 @@ export declare function allowedToolEffectsForMode(mode: AgentProfileMode): Set<s
67
67
  export declare function selectToolsForAgentProfile(tools: ToolRegistryEntry[], profile: AgentProfile, approval?: AgentProfileApproval): ToolRegistryEntry[];
68
68
  export declare function validateAgentProfileTools(tools: ToolRegistryEntry[], profile: AgentProfile, approval?: AgentProfileApproval): AgentProfileDiagnostic[];
69
69
  export declare function mergeUsage(current: SubagentRunResult["usage"], usage: TokenUsage): SubagentRunResult["usage"];
70
+ export declare function canonicalToolName(name: string): string;
@@ -10,14 +10,12 @@ const READONLY_PRESET = [
10
10
  "lsp",
11
11
  "web_search",
12
12
  "web_fetch",
13
- "memory_search",
14
- "memory_read_summary",
13
+ "memory",
15
14
  "skill",
16
15
  "todo_write",
17
16
  ];
18
17
  const SUBAGENT_DENY_TOOLS = new Set([
19
18
  "subagent",
20
- "task",
21
19
  "spawn_agent",
22
20
  "wait_agent",
23
21
  "send_input",
@@ -126,12 +124,12 @@ export function builtinAgentProfiles() {
126
124
  "You are an explorer subagent for codebase reconnaissance.",
127
125
  "Answer the specific question by inspecting the repository directly. Prefer precise file paths and line-level evidence.",
128
126
  "Keep the answer compact and avoid broad refactors or implementation plans unless asked.",
129
- ].join("\n"), ["read", "glob", "grep", "lsp", "memory_search", "memory_read_summary", "skill", "todo_write"]),
127
+ ].join("\n"), ["read", "glob", "grep", "lsp", "memory", "skill", "todo_write"]),
130
128
  roleProfile("worker", "Implementation-planning worker subagent", [
131
129
  "You are a worker subagent. In this Phase 1 runtime you are read-only, so you must not modify files.",
132
130
  "Analyze the assigned implementation slice, identify exact files to change, and return a concrete patch plan or findings.",
133
131
  "If write-capable worker mode is needed, say so explicitly.",
134
- ].join("\n"), ["read", "glob", "grep", "lsp", "memory_search", "memory_read_summary", "skill", "todo_write"]),
132
+ ].join("\n"), ["read", "glob", "grep", "lsp", "memory", "skill", "todo_write"]),
135
133
  toProfile("search"),
136
134
  toProfile("security_investigation"),
137
135
  toProfile("evidence_correlation"),
@@ -167,7 +165,7 @@ export function allowedToolEffectsForMode(mode) {
167
165
  : new Set(["read"]);
168
166
  }
169
167
  export function selectToolsForAgentProfile(tools, profile, approval = profile.approval) {
170
- const explicitInclude = new Set(profile.tools.include ?? []);
168
+ const explicitInclude = new Set((profile.tools.include ?? []).map(canonicalToolName));
171
169
  const selected = requestedToolNames(profile);
172
170
  for (const tool of SUBAGENT_DENY_TOOLS)
173
171
  selected.delete(tool);
@@ -183,13 +181,17 @@ export function selectToolsForAgentProfile(tools, profile, approval = profile.ap
183
181
  continue;
184
182
  if (approval === "disabled" && tool.requiresApproval)
185
183
  continue;
186
- out.push(wrapApprovalFailTool(tool, approval));
184
+ // Explicit include is the profile author pre-unlocking the tool: the
185
+ // child is a fresh Agent with its own (empty) unlock set, so a deferred
186
+ // flag surviving here would re-lock the tool inside the child.
187
+ const entry = tool.deferred ? { ...tool, deferred: false } : tool;
188
+ out.push(wrapApprovalFailTool(entry, approval));
187
189
  }
188
190
  return out;
189
191
  }
190
192
  export function validateAgentProfileTools(tools, profile, approval = profile.approval) {
191
193
  const available = new Map(tools.map((tool) => [tool.name, tool]));
192
- const explicitInclude = new Set(profile.tools.include ?? []);
194
+ const explicitInclude = new Set((profile.tools.include ?? []).map(canonicalToolName));
193
195
  const allowedEffects = allowedToolEffectsForMode(profile.mode);
194
196
  const diagnostics = [];
195
197
  for (const name of requestedToolNames(profile)) {
@@ -256,6 +258,18 @@ function wrapApprovalFailTool(tool, approval) {
256
258
  }),
257
259
  };
258
260
  }
261
+ /**
262
+ * Renamed/merged tools keep working for user-defined profiles written
263
+ * against the old names (memory_search/memory_read_summary merged into
264
+ * "memory", 2026-07-13).
265
+ */
266
+ const LEGACY_TOOL_ALIASES = {
267
+ memory_search: "memory",
268
+ memory_read_summary: "memory",
269
+ };
270
+ export function canonicalToolName(name) {
271
+ return LEGACY_TOOL_ALIASES[name] ?? name;
272
+ }
259
273
  function requestedToolNames(profile) {
260
274
  const selected = new Set();
261
275
  if (profile.tools.preset === "readonly") {
@@ -264,12 +278,12 @@ function requestedToolNames(profile) {
264
278
  }
265
279
  else if (profile.tools.preset === "explicit") {
266
280
  for (const tool of profile.tools.include ?? [])
267
- selected.add(tool);
281
+ selected.add(canonicalToolName(tool));
268
282
  }
269
283
  for (const tool of profile.tools.include ?? [])
270
- selected.add(tool);
284
+ selected.add(canonicalToolName(tool));
271
285
  for (const tool of profile.tools.exclude ?? [])
272
- selected.delete(tool);
286
+ selected.delete(canonicalToolName(tool));
273
287
  return selected;
274
288
  }
275
289
  function loadProfilesFromDir(dir, source, diagnostics) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Routing catalog: the single source of catalog truth for subagent model
3
+ * routing (docs/model-routing-design.md §1).
4
+ *
5
+ * No routing consumer reads `listBuiltinModels` / `getConfigured` directly —
6
+ * neither reflects what is actually runnable. Consumers read a
7
+ * `RoutingSnapshot` through the live accessor created here; the accessor
8
+ * caches by the registry's routing revision and rebuilds lazily, so tier
9
+ * resolution, validation, the detector, and the menu are never stale (§1.5).
10
+ */
11
+ import { type ModelTier } from "../model-catalog.js";
12
+ import type { ProviderRegistry } from "../provider-registry.js";
13
+ import { selectTierCandidates, type AgentCategoriesConfig, type TierRoutingContext } from "./categories.js";
14
+ import type { ThinkingLevel } from "../types.js";
15
+ export interface AgentRoutingConfig {
16
+ /** Automatic builtin-tier downgrade routing (§3.2). */
17
+ autoTier: boolean;
18
+ /** Call-site cross-provider routing; default open, set false to lock (§7.2). */
19
+ allowCrossProvider: boolean;
20
+ }
21
+ export declare const DEFAULT_AGENT_ROUTING: AgentRoutingConfig;
22
+ export declare function sanitizeAgentRouting(value: unknown): AgentRoutingConfig;
23
+ export interface RoutingModelEntry {
24
+ id: string;
25
+ name: string;
26
+ tier?: ModelTier;
27
+ routingPriority?: number;
28
+ /** Index in the builtin catalog order, when the id is builtin (§3.2 tiebreak). */
29
+ builtinIndex?: number;
30
+ source: "builtin" | "dynamic" | "custom";
31
+ }
32
+ export type RoutingMembershipSource = "custom-allowlist" | "complete-discovery" | "fallback-union";
33
+ export interface RoutingSnapshot {
34
+ parent: {
35
+ providerId: string;
36
+ model: string;
37
+ tier?: ModelTier;
38
+ };
39
+ /** Catalog-effective provider id (openai OAuth -> "openai-codex" alias). */
40
+ effectiveProviderId: string;
41
+ membershipSource: RoutingMembershipSource;
42
+ /** Model list for the PARENT provider only (§1.2 scope note). */
43
+ models: RoutingModelEntry[];
44
+ /** True iff membershipSource === "custom-allowlist" — the only catalog
45
+ * that hard-rejects unknown ids (§1.4). */
46
+ authoritative: boolean;
47
+ registryRevision: number;
48
+ /**
49
+ * Earliest expiry of the discovery data this snapshot consumed. TTL expiry
50
+ * is passive — it bumps no revision — so the accessor must rebuild past
51
+ * this instant or it serves a catalog the registry itself no longer would.
52
+ */
53
+ discoveryExpiresAt?: number;
54
+ /** Provider ids with active credentials (getEnabled, §1.1). */
55
+ runnableProviderIds: string[];
56
+ /** Post-merge, post-tier-resolution category bindings (§3). */
57
+ resolvedCategories: Array<{
58
+ name: string;
59
+ model: string | "inherit";
60
+ thinkingLevel?: ThinkingLevel;
61
+ tierSource?: "builtin" | "user";
62
+ }>;
63
+ }
64
+ export type RoutingSnapshotAccessor = (parent: {
65
+ providerId: string;
66
+ model: string;
67
+ }) => RoutingSnapshot;
68
+ /**
69
+ * Membership versus metadata (§1.3): an authoritative source alone decides
70
+ * which ids exist; lower-priority sources only enrich per-field metadata
71
+ * (tier: dynamic ?? custom ?? builtin).
72
+ */
73
+ export declare function buildRoutingSnapshot(registry: ProviderRegistry, parent: {
74
+ providerId: string;
75
+ model: string;
76
+ }, agentCategories: AgentCategoriesConfig, agentRouting: AgentRoutingConfig): RoutingSnapshot;
77
+ /** Tier-resolution context derived from a snapshot (consumed by categories §3.2). */
78
+ export declare function tierContextFromSnapshot(snapshot: RoutingSnapshot, agentRouting: AgentRoutingConfig): TierRoutingContext;
79
+ /**
80
+ * Live accessor (§1.5): caches by (routing revision, parent route) and
81
+ * rebuilds lazily, so a discovery completion or provider mutation is
82
+ * reflected at the very next read. Building is synchronous over cached
83
+ * registry state — microseconds, never I/O.
84
+ */
85
+ export declare function createRoutingSnapshotAccessor(registry: ProviderRegistry, getAgentCategories: () => AgentCategoriesConfig, getAgentRouting: () => AgentRoutingConfig): RoutingSnapshotAccessor;
86
+ /** Tier of a model within a snapshot, when known. */
87
+ export declare function snapshotModelTier(snapshot: RoutingSnapshot, modelId: string): ModelTier | undefined;
88
+ export interface RoutableModelEntry {
89
+ /** Routable provider id — what goes before the colon in provider:model. */
90
+ providerId: string;
91
+ id: string;
92
+ name: string;
93
+ }
94
+ export type RoutableModelIndex = () => RoutableModelEntry[];
95
+ /** Revision-cached index over all runnable providers' catalogs. */
96
+ export declare function createRoutableModelIndex(registry: ProviderRegistry): RoutableModelIndex;
97
+ /** Loose normalization for model-name matching: case/punctuation-insensitive. */
98
+ export declare function normalizeModelToken(value: string): string;
99
+ /**
100
+ * Near-match candidates for a (probably mistyped) model id within one
101
+ * provider's catalog. "suggest" matches normalized prefixes in either
102
+ * direction (soft "did you mean" notes). "truncation" matches only inputs
103
+ * the catalog EXTENDS ("gpt-5.6" -> gpt-5.6-sol/-terra/-luna) — the one
104
+ * direction that is typo evidence. An input extending a catalog id
105
+ * (gpt-5.6-sol-20260701 vs cataloged gpt-5.6-sol) is more likely a newly
106
+ * released variant the local catalog lags on, and must not be treated as a
107
+ * typo by hard-reject paths. Capped, deterministic order.
108
+ */
109
+ export declare function nearModelMatches(input: string, candidates: RoutableModelEntry[], options?: {
110
+ limit?: number;
111
+ mode?: "suggest" | "truncation";
112
+ }): string[];
113
+ export { selectTierCandidates };