@diousk/pi-subagents-fast 0.22.0 → 0.24.0

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - **Routing runtime status in `/agents`.** Open Model routing → Runtime status to inspect effective configuration, credential presence, eligible profiles and the latest retained routing decisions, including fallback reasons and actual model/thinking. The refreshable, read-only view hides API keys and makes no classifier request.
14
+
15
+ ## [0.23.0] - 2026-10-02
16
+
17
+ ### Changed
18
+ - **BREAKING: Jev model profiles require `thinkingLevel`.** Add `minimal`, `low`, `medium`, `high`, `xhigh` or `max` to every `jev.models` entry. Successful routing applies the model and thinking level together; shadow and fallback preserve the original pair. The routing menu requires a level, and invalid or missing levels disable the Jev block with a diagnostic.
19
+
10
20
  ## [0.22.0] - 2026-10-02
11
21
 
12
22
  > **Breaking: requires Pi 1.0.0 or newer.** Update Pi before using this version.
package/README.md CHANGED
@@ -634,7 +634,9 @@ When background agents complete, they notify the main agent. The **join mode** c
634
634
 
635
635
  ## Model routing
636
636
 
637
- Open `/agents → Model routing` to choose a mode and configure a guideline path, models with descriptions, or a masked TypeSafe API key. The menu shows the active mode and source and saves project settings. For machine-wide defaults, edit `~/.pi/agent/subagents.json`; project overrides go in `.pi/subagents.json`. `PI_CODING_AGENT_DIR` changes the global directory along with Pi's other configuration.
637
+ Open `/agents → Model routing` to choose a mode and configure a guideline path, models with descriptions and required thinking levels, or a masked TypeSafe API key. The menu shows the active mode and source and saves project settings. For machine-wide defaults, edit `~/.pi/agent/subagents.json`; project overrides go in `.pi/subagents.json`. `PI_CODING_AGENT_DIR` changes the global directory along with Pi's other configuration.
638
+
639
+ Use `/agents → Model routing → Runtime status` to inspect the effective mode and its project/global source, routing priority and fallback, guideline path, configured model/thinking profiles, current candidate eligibility, classifier availability and credential presence. No API key is displayed and opening the page does not classify a task. Credential presence does not confirm that the service will accept it. The page also shows the latest ten retained agent decisions, including selected versus actual model/thinking, shadow suggestions, confidence, fallback/error reasons and classifier usage. This is an in-memory view, not a durable log: evicted records and previous sessions are not included. Press `r` to refresh, Up/Down to scroll and Esc to return.
638
640
 
639
641
  Set one top-level field to control routing; omitting it means `auto`:
640
642
 
@@ -660,7 +662,7 @@ The default priority is:
660
662
  | 3 | `jev` is configured | Jev chooses a model from your descriptions for a fresh delegated task |
661
663
  | 4 | None of the above | The existing model is used |
662
664
 
663
- **Custom agents:** keep using `~/.pi/agent/agents/<name>.md`, `.pi/agents/<name>.md` or `.agents/agents/<name>.md`. No routing setting is needed. Built-in and disabled agents do not activate priority 1. Agent-file model/thinking pins supply the default choice over `Agent` parameters; a confident Jev choice in `jev` mode can replace the model.
665
+ **Custom agents:** keep using `~/.pi/agent/agents/<name>.md`, `.pi/agents/<name>.md` or `.agents/agents/<name>.md`. No routing setting is needed. Built-in and disabled agents do not activate priority 1. Agent-file model/thinking pins supply the default choice over `Agent` parameters; a confident Jev choice in `jev` mode can replace both model and thinking level.
664
666
 
665
667
  **Custom guideline:** write your routing rules in `~/.pi/agent/agents/custom-route.md`, then configure:
666
668
 
@@ -679,16 +681,16 @@ For example, the Markdown can say “Use anthropic/claude-haiku-4-5 for simple e
679
681
  "jev": {
680
682
  "TYPESAFE_API_KEY": "your-typesafe-key",
681
683
  "models": [
682
- { "model": "anthropic/claude-haiku-4-5", "description": "Simple edits, lookup and concise summaries" },
683
- { "model": "openai-codex/gpt-6.1-sol", "description": "Complex debugging, architecture and concurrency" }
684
+ { "model": "anthropic/claude-haiku-4-5", "description": "Simple edits, lookup and concise summaries", "thinkingLevel": "low" },
685
+ { "model": "openai-codex/gpt-6.1-sol", "description": "Complex debugging, architecture and concurrency", "thinkingLevel": "high" }
684
686
  ]
685
687
  }
686
688
  }
687
689
  ```
688
690
 
689
- `TYPESAFE_API_KEY` is optional when Pi already has TypeSafe credentials or the environment variable is set. A literal key must be a nonempty token without whitespace or control characters and applies only to that classifier request; it is saved in the settings file, masked in the menu and omitted from settings events, prompts and routing records. Without a literal key, Pi's native credential availability check runs before classification. Rejected credentials fall back without retrying. The extension does not change environment variables or register a routing provider. `models` accepts 1–254 unique entries with descriptions of 1–4000 characters. Jev entries use exact `provider/model-id` spelling; fuzzy names remain available for explicit `Agent` parameters.
691
+ `TYPESAFE_API_KEY` is optional when Pi already has TypeSafe credentials or the environment variable is set. A literal key must be a nonempty token without whitespace or control characters and applies only to that classifier request; it is saved in the settings file, masked in the menu and omitted from settings events, prompts and routing records. Without a literal key, Pi's native credential availability check runs before classification. Rejected credentials fall back without retrying. The extension does not change environment variables or register a routing provider. `models` accepts 1–254 unique entries with descriptions of 1–4000 characters. Every entry requires `thinkingLevel`: `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. Existing profiles must add this field; a missing or invalid value disables the whole Jev block and uses the default-priority fallback. Jev entries use exact `provider/model-id` spelling; fuzzy names remain available for explicit `Agent` parameters.
690
692
 
691
- Under `auto`, supplying **either** `model` or `thinking` explicitly skips Jev. An agent-file pin also skips it. In workflows, either `model` or `effort` skips it, and workflow options retain their precedence over agent-file defaults. Inherited models remain eligible for Jev. Under `jev`, these choices supply the fallback model, but do not skip classification. Under `shadow`, they remain the actual choice while Jev records a comparison. Jev changes only the model; Pi still determines the effective thinking level.
693
+ Under `auto`, supplying **either** `model` or `thinking` explicitly skips Jev. An agent-file pin also skips it. In workflows, either `model` or `effort` skips it, and workflow options retain their precedence over agent-file defaults. Inherited models remain eligible for Jev. Under `jev`, these choices supply the fallback model, but do not skip classification. Under `shadow`, they remain the actual choice while Jev records a comparison. A selected Jev profile applies both its model and `thinkingLevel`. Pi clamps the requested thinking level to what that model supports. Shadow and fallback preserve the original model and thinking level.
692
694
 
693
695
  Automatic choices are limited to authenticated models and the current nonempty Pi model scope, regardless of the `scopeModels` setting. When `scopeModels` is enabled, Jev candidates also respect `enabledModels`. Unavailable candidates are excluded and the selected model is checked again after classification, including any changed scope. Jev failure, confidence below 0.6 or a two-second timeout keeps the default-priority model already selected by the main agent, explicit caller or agent definition, otherwise the existing model. This fallback does not make a second Jev request. Cancellation stops startup without launching a fallback. Queued agents read the current mode and classify after dequeue; nested calls share a separate classifier concurrency limit of four and do not take another agent slot.
694
696
 
@@ -696,7 +698,7 @@ Jev applies to fresh tool, nested, workflow, scheduled, RPC and direct-mention s
696
698
 
697
699
  Project `routingMode` and `customGuideline` replace the global values; omission inherits them, and `customGuideline: false` disables the guideline. A project `jev` block replaces the **whole** global block, including credentials; omitted fields inside that block do not inherit. `"jev": false` disables global Jev. An invalid mode or malformed settings file disables routing. An invalid explicit Jev block disables that block instead of restoring global paid routing. Other settings changes preserve these inheritance rules.
698
700
 
699
- Classifier usage is recorded separately as `routingUsage`, including in `shadow`; it does not consume coding context or workflow output budgets. Its reported cost is added once to the agent and ancestor cost totals, and `reportUsage` can return its token usage to the parent. `routing` records the mode, source, reason, applied `model` and confidence; shadow records `suggestedModel` and leaves `model` unset. `fallbackSource` identifies the default source when a suggestion is observed or Jev falls back. Shadow results show “Jev shadow”. Zero catalog prices are marked `unpriced`; the Agent result shows “Jev price unavailable” instead of treating that as proof the classifier is free.
701
+ Classifier usage is recorded separately as `routingUsage`, including in `shadow`; it does not consume coding context or workflow output budgets. Its reported cost is added once to the agent and ancestor cost totals, and `reportUsage` can return its token usage to the parent. `routing` records the mode, source, reason, applied `model`, requested `thinkingLevel` and confidence; shadow records `suggestedModel` and `suggestedThinkingLevel` and leaves both applied fields unset. `fallbackSource` identifies the default source when a suggestion is observed or Jev falls back. Shadow results show “Jev shadow”. Zero catalog prices are marked `unpriced`; the Agent result shows “Jev price unavailable” instead of treating that as proof the classifier is free.
700
702
 
701
703
  ## Model Scope
702
704
 
@@ -731,7 +733,7 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, max foreg
731
733
 
732
734
  **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `10`, max foreground concurrency `0` = unlimited, default max turns unlimited, grace turns `5`, nested depth `2`, join mode `smart`, defaults enabled).
733
735
 
734
- Routing settings: `routingMode` (`auto | shadow | jev | off`, default `auto`), `customGuideline` (`string | false`, default unset) and `jev` (`{ TYPESAFE_API_KEY?: string, models: { model, description }[] } | false`, default unset). See [Model routing](#model-routing) for examples and the whole-block override rule.
736
+ Routing settings: `routingMode` (`auto | shadow | jev | off`, default `auto`), `customGuideline` (`string | false`, default unset) and `jev` (`{ TYPESAFE_API_KEY?: string, models: { model, description, thinkingLevel }[] } | false`, default unset). See [Model routing](#model-routing) for examples and the whole-block override rule.
735
737
 
736
738
  **Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it.
737
739
 
@@ -861,7 +863,7 @@ The four agent-lifecycle events — `subagents:started`, `:completed`, `:failed`
861
863
 
862
864
  `usage` answers the other question — what was billed — and so does include `cacheRead`, because the prefix really is re-read and re-charged on every call. It is a pi `Usage`, the same shape pi puts on `ToolResultEvent` and `AssistantMessage`, so `usage.cost.total` is where a listener already expects the money and anything pi adds to `Usage` arrives without a change here. Neither field derives from the other; `tokens` is a view model, `usage` is the data.
863
865
 
864
- Completed/failed payloads and persisted `subagents:record` entries also carry `routing` (`source`, `code`, `reason`, optional `model`, supplied `description`, `confidence`, `unpriced`, `guidelinePath`, `guidelineHash`) and optional `routingUsage` (classifier-only Pi `Usage`). The guideline hash is SHA-256 of the original file contents. Coding `tokens` excludes classifier tokens; total cost includes the reported classifier cost once. Settings events omit `jev.TYPESAFE_API_KEY`.
866
+ Completed/failed payloads and persisted `subagents:record` entries also carry `routing` (`source`, `code`, `reason`, optional `model`, `thinkingLevel`, `suggestedModel`, `suggestedThinkingLevel`, supplied `description`, `confidence`, `unpriced`, `guidelinePath`, `guidelineHash`) and optional `routingUsage` (classifier-only Pi `Usage`). The guideline hash is SHA-256 of the original file contents. Coding `tokens` excludes classifier tokens; total cost includes the reported classifier cost once. Settings events omit `jev.TYPESAFE_API_KEY`.
865
867
 
866
868
  ## Cross-Extension RPC
867
869
 
@@ -1080,7 +1082,7 @@ src/
1080
1082
  invocation-config.ts # Shared tool-parameter schemas (isolation, join, thinking, ...)
1081
1083
  model-resolver.ts # Model resolution: exact provider/modelId with fuzzy fallback
1082
1084
  model-routing.ts # Source priority and bounded native Jev classification before startup
1083
- routing-config.ts # Minimal Jev model/description and credential config validation
1085
+ routing-config.ts # Jev model/description/thinking-level and credential config validation
1084
1086
  enabled-models.ts # Read pi's enabledModels settings (project over global)
1085
1087
  model-scope.ts # scopeModels allowlist policy, shared by top-level and nested tools
1086
1088
  mention.ts # `@handle message` grammar: suggestion triggers and send parsing
@@ -1117,6 +1119,7 @@ src/
1117
1119
  agent-mention.ts # `@` roster (running, resumable, and startable agents) + popup rows
1118
1120
  schedule-menu.ts # /agents → Scheduled jobs submenu
1119
1121
  model-routing-menu.ts # Guideline/model descriptions and masked API-key editor
1122
+ routing-status.ts # Read-only effective routing configuration and retained decisions
1120
1123
  select-item.ts # Collision-safe ctx.ui.select wrapper (numbered rows)
1121
1124
  workflow-card.ts # Inline workflow card (tool result and session entry)
1122
1125
  workflow-dialog.ts # /agents → Workflows two-pane inspector
@@ -504,11 +504,13 @@ export class AgentManager {
504
504
  });
505
505
  record.routing = { ...routed.decision, guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
506
506
  if (routed.model && policy.mode === "shadow") {
507
- record.routing = { ...record.routing, code: "shadow", model: undefined, suggestedModel: routed.decision.model,
507
+ record.routing = { ...record.routing, code: "shadow", model: undefined, thinkingLevel: undefined, suggestedModel: routed.decision.model,
508
508
  fallbackSource: policy.source, reason: "Jev suggested a model; shadow mode kept the default-priority model" };
509
509
  }
510
510
  else if (routed.model) {
511
511
  options.model = routed.model;
512
+ options.thinkingLevel = routed.thinkingLevel;
513
+ record.invocation = { ...record.invocation, thinking: routed.thinkingLevel, requestedThinking: undefined };
512
514
  }
513
515
  }
514
516
  catch {
package/dist/index.js CHANGED
@@ -2752,7 +2752,7 @@ Terse command-style prompts produce shallow, generic work.
2752
2752
  await showAgentsMenu(ctx);
2753
2753
  }
2754
2754
  else if (choice === "Model routing") {
2755
- const patch = await showRoutingMenu(ctx);
2755
+ const patch = await showRoutingMenu(ctx, () => manager.listAgents());
2756
2756
  if (patch) {
2757
2757
  const toast = saveAndEmitChanged({ ...snapshotSettings(), ...patch }, "Model routing settings updated", (event, payload) => pi.events.emit(event, payload), ctx.cwd);
2758
2758
  ctx.ui.notify(toast.message, toast.level);
@@ -1,4 +1,4 @@
1
- import type { Api, Model, Usage } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, ThinkingLevel, Usage } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { JevConfig, RoutingMode } from "./routing-config.js";
4
4
  import type { AgentConfig } from "./types.js";
@@ -33,6 +33,8 @@ export interface RoutingDecision {
33
33
  code: "baseline" | "explicit" | "off" | "shadow" | "config_unavailable" | "credentials_unavailable" | "guideline_unavailable" | "no_candidates" | "classifier_unavailable" | "cancelled" | "timeout" | "invalid_answer" | "abstained" | "unavailable_choice" | "selected" | "classifier_error";
34
34
  model?: string;
35
35
  suggestedModel?: string;
36
+ thinkingLevel?: ThinkingLevel;
37
+ suggestedThinkingLevel?: ThinkingLevel;
36
38
  description?: string;
37
39
  confidence?: number;
38
40
  unpriced?: boolean;
@@ -42,6 +44,7 @@ export interface RoutingDecision {
42
44
  export declare function loadRoutingPolicy(cwd: string, loadedAgents?: Map<string, AgentConfig>): RoutingPolicy;
43
45
  /** Added in every description mode and refreshed before each main-agent turn. */
44
46
  export declare function routingGuidance(policy: RoutingPolicy): string;
47
+ export declare function eligibleModels(ctx: ExtensionContext): Map<string, Model<Api>>;
45
48
  /** Bounded classifier pool, independent of agent concurrency and nesting. */
46
49
  export declare class ModelRouter {
47
50
  private active;
@@ -49,6 +52,7 @@ export declare class ModelRouter {
49
52
  private acquire;
50
53
  choose(ctx: ExtensionContext, policy: RoutingPolicy, prompt: string, description: string, baseline: Model<Api> | undefined, signal: AbortSignal, onUsage: (usage: Usage) => void): Promise<{
51
54
  model?: Model<Api>;
55
+ thinkingLevel?: ThinkingLevel;
52
56
  decision: RoutingDecision;
53
57
  }>;
54
58
  }
@@ -59,15 +59,15 @@ export function routingGuidance(policy) {
59
59
  ? `Routing source: Custom guideline (${policy.guidelinePath}). Follow it when choosing Agent model/thinking (or workflow model/effort). Pass your default choice explicitly.\n<custom_routing_guideline>\n${policy.guideline}\n</custom_routing_guideline>`
60
60
  : policy.diagnostic ?? "Custom routing guideline is unavailable. Use the existing model.";
61
61
  break;
62
- case "jev": guidance = "Routing: Jev chooses the model for fresh agents when neither model nor thinking is explicitly set. Omit both to use automatic routing. Explicit choices and agent-file pins keep their existing precedence.";
62
+ case "jev": guidance = "Routing: Jev chooses the model and thinking level for fresh agents when neither model nor thinking is explicitly set. Omit both to use automatic routing. Explicit choices and agent-file pins keep their existing precedence.";
63
63
  }
64
64
  if (policy.mode === "jev")
65
- return "Routing mode: jev. Jev gets first choice of model for every fresh delegated task, including explicit model choices and agent-file model pins. Choose an agent and a default model using the guidance below; that choice is the fallback if Jev is unavailable or uncertain. Thinking keeps its existing precedence.\n" + guidance;
65
+ return "Routing mode: jev. Jev gets first choice of model for every fresh delegated task, including explicit model choices and agent-file model pins. Choose an agent and a default model using the guidance below; that choice is the fallback if Jev is unavailable or uncertain. The selected profile supplies both model and thinking level.\n" + guidance;
66
66
  if (policy.mode === "shadow")
67
67
  return "Routing mode: shadow. Jev records a suggestion for each fresh delegated task but never changes the model. Choose using the default guidance below, or keep the existing model when no guidance applies.\n" + guidance;
68
68
  return guidance && policy.source !== "jev" ? guidance + "\nJev is inactive under auto mode while this routing source applies." : guidance;
69
69
  }
70
- function eligibleModels(ctx) {
70
+ export function eligibleModels(ctx) {
71
71
  const scope = ctx.scopedModels;
72
72
  const allowed = scope?.length ? new Set(scope.map(entry => `${entry.model.provider}/${entry.model.id}`)) : undefined;
73
73
  const enabled = isScopeModelsEnabled() ? resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd) : undefined;
@@ -139,7 +139,7 @@ export class ModelRouter {
139
139
  release = await this.acquire(controller.signal);
140
140
  if (!release)
141
141
  return { decision: { ...decision, code: signal.aborted ? "cancelled" : "timeout", reason: signal.aborted ? "Cancelled" : "Jev timed out" } };
142
- const choices = new Map(candidates.map((entry, index) => [`route_${index}`, entry.model]));
142
+ const choices = new Map(candidates.map((entry, index) => [`route_${index}`, entry]));
143
143
  const criteria = { keep_baseline: "Keep the existing model if none of the described models clearly fits the task." };
144
144
  for (const [index, entry] of candidates.entries())
145
145
  criteria[`route_${index}`] = entry.description;
@@ -186,16 +186,19 @@ export class ModelRouter {
186
186
  return { decision: { ...decision, code: "invalid_answer", reason: "Jev returned no usable choice" } };
187
187
  }
188
188
  decision.confidence = answer.confidence;
189
- if (policy.mode === "shadow")
190
- decision.suggestedModel = choices.get(answer.choice);
189
+ const profile = choices.get(answer.choice);
190
+ if (policy.mode === "shadow") {
191
+ decision.suggestedModel = profile?.model;
192
+ decision.suggestedThinkingLevel = profile?.thinkingLevel;
193
+ }
191
194
  if (answer.choice === "keep_baseline" || answer.confidence < 0.6) {
192
195
  return { decision: { ...decision, code: "abstained", reason: "Jev kept the existing model" } };
193
196
  }
194
- const selected = choices.get(answer.choice);
197
+ const selected = profile?.model;
195
198
  const model = selected ? eligibleModels(ctx).get(selected) : undefined;
196
199
  if (!model || signal.aborted)
197
200
  return { decision: { ...decision, code: "unavailable_choice", reason: "Selected model is no longer available in scope" } };
198
- return { model, decision: { ...decision, fallbackSource: undefined, code: "selected", model: selected, description: candidates.find(entry => entry.model === selected)?.description, reason: "Jev selected a configured model" } };
201
+ return { model, thinkingLevel: profile?.thinkingLevel, decision: { ...decision, fallbackSource: undefined, code: "selected", model: selected, thinkingLevel: profile?.thinkingLevel, description: profile?.description, reason: "Jev selected a configured model profile" } };
199
202
  }
200
203
  catch {
201
204
  // Provider errors may contain credentials. Keep diagnostics code-owned.
@@ -1,9 +1,12 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+ export declare const ROUTING_THINKING_LEVELS: readonly ["minimal", "low", "medium", "high", "xhigh", "max"];
1
3
  export type RoutingMode = "auto" | "shadow" | "jev" | "off";
2
4
  export interface JevConfig {
3
5
  TYPESAFE_API_KEY?: string;
4
6
  models: {
5
7
  model: string;
6
8
  description: string;
9
+ thinkingLevel: ThinkingLevel;
7
10
  }[];
8
11
  }
9
12
  /** Validate the whole block; never merge candidate lists or credentials. */
@@ -1,3 +1,4 @@
1
+ export const ROUTING_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
1
2
  /** Validate the whole block; never merge candidate lists or credentials. */
2
3
  export function parseJevConfig(raw) {
3
4
  if (raw === false)
@@ -20,15 +21,18 @@ export function parseJevConfig(raw) {
20
21
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
21
22
  throw new Error("Each Jev model needs model and description");
22
23
  const candidate = entry;
23
- if (Object.keys(candidate).some(key => key !== "model" && key !== "description") ||
24
+ if (Object.keys(candidate).some(key => key !== "model" && key !== "description" && key !== "thinkingLevel") ||
24
25
  typeof candidate.model !== "string" || !/^[^\s/]+\/\S+$/.test(candidate.model) ||
25
26
  typeof candidate.description !== "string" || !candidate.description.trim() || candidate.description.length > 4000) {
26
27
  throw new Error("Each Jev model needs an exact provider/model-id and a description of 1–4000 characters");
27
28
  }
29
+ const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === candidate.thinkingLevel);
30
+ if (!thinkingLevel)
31
+ throw new Error("Each Jev model requires thinkingLevel: minimal, low, medium, high, xhigh or max");
28
32
  if (seen.has(candidate.model))
29
33
  throw new Error("jev.models contains duplicate models");
30
34
  seen.add(candidate.model);
31
- return { model: candidate.model, description: candidate.description.trim() };
35
+ return { model: candidate.model, description: candidate.description.trim(), thinkingLevel };
32
36
  });
33
37
  return { models, ...(typeof value.TYPESAFE_API_KEY === "string" ? { TYPESAFE_API_KEY: value.TYPESAFE_API_KEY } : {}) };
34
38
  }
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type SubagentsSettings } from "../settings.js";
3
+ import type { AgentRecord } from "../types.js";
3
4
  type RoutingSettings = Pick<SubagentsSettings, "routingMode" | "customGuideline" | "jev">;
4
5
  /** Input handles paste/editing, but its unmasked renderer is never called. */
5
6
  export declare function maskedApiKey(ctx: ExtensionCommandContext): Promise<string | undefined>;
6
7
  /** Simple mode, guideline, models and credentials; saves project overrides. */
7
- export declare function showRoutingMenu(ctx: ExtensionCommandContext): Promise<RoutingSettings | undefined>;
8
+ export declare function showRoutingMenu(ctx: ExtensionCommandContext, records?: () => AgentRecord[]): Promise<RoutingSettings | undefined>;
8
9
  export {};
@@ -1,7 +1,8 @@
1
1
  import { Input, Text } from "@earendil-works/pi-tui";
2
2
  import { loadRoutingPolicy } from "../model-routing.js";
3
- import { parseJevConfig } from "../routing-config.js";
3
+ import { parseJevConfig, ROUTING_THINKING_LEVELS } from "../routing-config.js";
4
4
  import { loadSettings, projectRoutingSettings } from "../settings.js";
5
+ import { showRoutingStatus } from "./routing-status.js";
5
6
  /** Input handles paste/editing, but its unmasked renderer is never called. */
6
7
  export async function maskedApiKey(ctx) {
7
8
  return ctx.ui.custom((_tui, _theme, _kb, done) => {
@@ -16,7 +17,7 @@ export async function maskedApiKey(ctx) {
16
17
  });
17
18
  }
18
19
  /** Simple mode, guideline, models and credentials; saves project overrides. */
19
- export async function showRoutingMenu(ctx) {
20
+ export async function showRoutingMenu(ctx, records = () => []) {
20
21
  const settings = loadSettings(ctx.cwd);
21
22
  const local = projectRoutingSettings(ctx.cwd);
22
23
  const policy = loadRoutingPolicy(ctx.cwd);
@@ -28,10 +29,14 @@ export async function showRoutingMenu(ctx) {
28
29
  : policy.mode === "jev" ? `Jev first; fallback: ${labels[policy.fallbackSource ?? "baseline"]}.${policy.jev ? "" : " Configure Jev models and credentials to use Jev."}`
29
30
  : policy.diagnostic ?? inactive;
30
31
  const choice = await ctx.ui.select(`Model routing: ${policy.mode} — ${note || labels[policy.source]}`, [
31
- "Routing mode", "Custom guideline path", "Jev models and descriptions", "Typesafe API key", "Use Pi/environment credentials", "Disable custom guideline", "Disable Jev", "Back",
32
+ "Runtime status", "Routing mode", "Custom guideline path", "Jev models and descriptions", "Typesafe API key", "Use Pi/environment credentials", "Disable custom guideline", "Disable Jev", "Back",
32
33
  ]);
33
34
  if (!choice || choice === "Back")
34
35
  return;
36
+ if (choice === "Runtime status") {
37
+ await showRoutingStatus(ctx, records);
38
+ return;
39
+ }
35
40
  if (choice === "Routing mode") {
36
41
  const modes = [
37
42
  { mode: "auto", label: "auto — default priority: agents, guideline, Jev, existing model" },
@@ -73,7 +78,7 @@ export async function showRoutingMenu(ctx) {
73
78
  }
74
79
  }
75
80
  for (;;) {
76
- const options = [...models.map((entry, index) => `${index + 1}. ${entry.model}`), "Add model", "Save", "Cancel"];
81
+ const options = [...models.map((entry, index) => `${index + 1}. ${entry.model} (${entry.thinkingLevel})`), "Add model", "Save", "Cancel"];
77
82
  const action = await ctx.ui.select("Jev models — describe which tasks each model should handle", options);
78
83
  if (!action || action === "Cancel")
79
84
  return;
@@ -103,7 +108,11 @@ export async function showRoutingMenu(ctx) {
103
108
  const description = await ctx.ui.editor("Tasks this model is suitable for", current?.description ?? "");
104
109
  if (!description?.trim())
105
110
  continue;
106
- const entry = { model: model.trim(), description: description.trim() };
111
+ const selectedLevel = await ctx.ui.select(`Thinking level (required${current ? `; current: ${current.thinkingLevel}` : ""})`, [...ROUTING_THINKING_LEVELS]);
112
+ const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === selectedLevel);
113
+ if (!thinkingLevel)
114
+ continue;
115
+ const entry = { model: model.trim(), description: description.trim(), thinkingLevel };
107
116
  if (current)
108
117
  models[index] = entry;
109
118
  else
@@ -0,0 +1,5 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentRecord } from "../types.js";
3
+ /** Read current configuration and retained decisions without making a paid classifier request. */
4
+ export declare function routingStatusText(ctx: ExtensionCommandContext, records: readonly AgentRecord[]): Promise<string>;
5
+ export declare function showRoutingStatus(ctx: ExtensionCommandContext, records: () => AgentRecord[]): Promise<void>;
@@ -0,0 +1,113 @@
1
+ import { matchesKey, Text } from "@earendil-works/pi-tui";
2
+ import { eligibleModels, loadRoutingPolicy } from "../model-routing.js";
3
+ import { isScopeModelsEnabled } from "../model-scope.js";
4
+ import { loadRoutingSettings, projectRoutingSettings } from "../settings.js";
5
+ /** Read current configuration and retained decisions without making a paid classifier request. */
6
+ export async function routingStatusText(ctx, records) {
7
+ const { settings, guidelineFile } = loadRoutingSettings(ctx.cwd);
8
+ const local = projectRoutingSettings(ctx.cwd);
9
+ const policy = loadRoutingPolicy(ctx.cwd);
10
+ const config = settings.jev;
11
+ const apiKey = config ? config.TYPESAFE_API_KEY : undefined;
12
+ const available = eligibleModels(ctx);
13
+ const labels = { agents: "Custom agents", guideline: "Custom guideline", jev: "Jev", baseline: "Existing model" };
14
+ const lines = [
15
+ `Directory: ${ctx.cwd}`,
16
+ `Mode: ${policy.mode} (${local.routingMode !== undefined ? "project" : settings.routingMode !== undefined ? "global" : "default"})`,
17
+ `Routing source: ${labels[policy.source]}`,
18
+ `Fallback source: ${labels[policy.fallbackSource ?? (policy.source === "jev" ? "baseline" : policy.source)]}`,
19
+ `Parent model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "Pi default"}`,
20
+ `Custom agents: ${policy.agents?.map(agent => agent.name).join(", ") || "none active in this policy"}`,
21
+ `Guideline: ${guidelineFile ?? "not configured"}${policy.source !== "guideline" && policy.fallbackSource !== "guideline" ? " (inactive)" : ""}`,
22
+ `Jev config: ${config ? "valid" : config === false ? "disabled or invalid; check subagents.json" : "not configured"} (${local.jev !== undefined ? "project" : settings.jev !== undefined ? "global" : "default"})`,
23
+ `Scope: Pi scoped models ${ctx.scopedModels?.length ? "active" : "unrestricted"}; enabledModels filter ${isScopeModelsEnabled() ? "on" : "off"}`,
24
+ ];
25
+ if (policy.diagnostic)
26
+ lines.push(`Diagnostic: ${policy.diagnostic}`);
27
+ const classifier = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
28
+ lines.push(`Jev classifier: ${classifier ? "available" : "unavailable in Pi registry"}`);
29
+ if (apiKey) {
30
+ lines.push("Credentials: configured key present (acceptance checked on next route)");
31
+ }
32
+ else {
33
+ const controller = new AbortController();
34
+ let timer;
35
+ try {
36
+ const availableCredentials = await Promise.race([
37
+ ctx.modelRegistry.getAvailableOfType("classifier", "typesafe", { signal: controller.signal }),
38
+ new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(undefined); }, 2000); }),
39
+ ]);
40
+ lines.push(`Credentials: ${availableCredentials === undefined ? "check timed out" : availableCredentials.some(model => model.id === "jev-latest") ? "Pi/environment credentials available (acceptance checked on next route)" : "missing Pi/environment credentials"}`);
41
+ }
42
+ catch {
43
+ lines.push("Credentials: availability check failed");
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ lines.push("", "Configured profiles:");
50
+ if (config) {
51
+ for (const profile of config.models) {
52
+ lines.push(`${profile.model} | thinking: ${profile.thinkingLevel} | ${available.has(profile.model) ? "eligible" : "unavailable or excluded by scope"}`, ` ${profile.description}`);
53
+ }
54
+ }
55
+ else
56
+ lines.push("None. Every profile requires model, description and thinkingLevel.");
57
+ lines.push("", "Recent retained agents (latest 10; includes nested/workflow agents):");
58
+ const recent = [...records].filter(record => record.routing).sort((a, b) => b.startedAt - a.startedAt).slice(0, 10);
59
+ if (!recent.length)
60
+ lines.push("No routing decisions yet. Start a fresh subagent; resumed agents do not reroute.");
61
+ for (const record of recent) {
62
+ const route = record.routing;
63
+ lines.push(`${new Date(record.startedAt).toISOString()} | ${record.id} | ${record.status}`, ` Mode: ${route.mode} | Source: ${route.source} | Result: ${route.code}`, ` ${route.reason}`);
64
+ if (route.model)
65
+ lines.push(` Selected: ${route.model} | requested thinking: ${route.thinkingLevel ?? "inherited"}`);
66
+ if (route.suggestedModel)
67
+ lines.push(` Shadow suggestion: ${route.suggestedModel} | thinking: ${route.suggestedThinkingLevel ?? "inherited"}`);
68
+ if (record.invocation?.modelId)
69
+ lines.push(` Actual: ${record.invocation.modelId} | effective thinking: ${record.invocation.thinking ?? "unknown"}`);
70
+ if (route.confidence !== undefined)
71
+ lines.push(` Confidence: ${(route.confidence * 100).toFixed(1)}%`);
72
+ if (record.routingUsage)
73
+ lines.push(` Classifier tokens: ${record.routingUsage.totalTokens} | ${route.unpriced ? "price unavailable" : `reported cost: $${record.routingUsage.cost.total}`}`);
74
+ }
75
+ lines.push("", "Snapshot only. Refresh to reload settings and retained decisions. Opening this page does not classify a task.");
76
+ const text = lines.join("\n");
77
+ return apiKey ? text.replaceAll(apiKey, "[redacted]") : text;
78
+ }
79
+ export async function showRoutingStatus(ctx, records) {
80
+ let refresh = true;
81
+ while (refresh) {
82
+ const content = await routingStatusText(ctx, records());
83
+ refresh = await ctx.ui.custom((tui, _theme, _kb, done) => {
84
+ let offset = 0;
85
+ let maxOffset = 0;
86
+ return {
87
+ render(width) {
88
+ const rows = new Text(content, 0, 0).render(width);
89
+ const height = Math.max(1, tui.terminal.rows - 6);
90
+ maxOffset = Math.max(0, rows.length - height);
91
+ offset = Math.min(offset, maxOffset);
92
+ return [...new Text("Routing runtime status | Up/Down: scroll | r: refresh | Esc: back", 0, 0).render(width), ...rows.slice(offset, offset + height)];
93
+ },
94
+ invalidate() { },
95
+ handleInput(data) {
96
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
97
+ done(false);
98
+ return;
99
+ }
100
+ if (matchesKey(data, "r")) {
101
+ done(true);
102
+ return;
103
+ }
104
+ if (matchesKey(data, "up"))
105
+ offset = Math.max(0, offset - 1);
106
+ if (matchesKey(data, "down"))
107
+ offset = Math.min(maxOffset, offset + 1);
108
+ tui.requestRender();
109
+ },
110
+ };
111
+ });
112
+ }
113
+ }
package/docs/rpc.md CHANGED
@@ -55,9 +55,9 @@ Four things that are not obvious from the tables:
55
55
 
56
56
  ### Model routing
57
57
 
58
- RPC and the manager registry follow the [same routingMode setting](../README.md#model-routing) as the tools. Under `auto` (default), enabled custom agents and a configured guideline take priority; with Jev active, a fresh spawn omitting both `model` and `thinkingLevel` is classified before worktree/session creation. Providing either field skips classification only under `auto`. Under `jev`, Jev chooses first, including over explicit or agent-file models; missing credentials, invalid configuration, uncertainty or errors keep the default-priority choice. Under `shadow`, the same check records a suggestion but preserves that choice. Under `off`, routing guidance and Jev are disabled. `null` means omitted. No mode creates an extra main-agent turn to interpret descriptions or Markdown. `awaitStartup` includes classification and its two-second deadline, while cancellation stops startup.
58
+ RPC and the manager registry follow the [same routingMode setting](../README.md#model-routing) as the tools. Under `auto` (default), enabled custom agents and a configured guideline take priority; with Jev active, a fresh spawn omitting both `model` and `thinkingLevel` is classified before worktree/session creation. Providing either field skips classification only under `auto`. A selected Jev profile applies its model and required `thinkingLevel` together; Pi clamps the level to model support. Shadow and fallback preserve both original values. Under `jev`, Jev chooses first, including over explicit or agent-file models; missing credentials, invalid configuration, uncertainty or errors keep the default-priority choice. Under `shadow`, the same check records a suggestion but preserves that choice. Under `off`, routing guidance and Jev are disabled. `null` means omitted. No mode creates an extra main-agent turn to interpret descriptions or Markdown. `awaitStartup` includes classification and its two-second deadline, while cancellation stops startup.
59
59
 
60
- Completion events expose the credential-free `routing` decision and optional classifier-only `routingUsage`. `routing.mode` identifies the mode, `model` an applied Jev choice, `suggestedModel` an observed shadow choice, and `fallbackSource` the preserved default source. Classifier tokens are separate from coding token totals; reported classifier cost, including shadow requests, is included in total cost once. `routing.unpriced` means the catalog does not provide a price. Settings events omit literal TypeSafe keys.
60
+ Completion events expose the credential-free `routing` decision and optional classifier-only `routingUsage`. `routing.mode` identifies the mode, `model` and `thinkingLevel` an applied Jev profile, `suggestedModel` and `suggestedThinkingLevel` an observed shadow profile, and `fallbackSource` the preserved default source. Classifier tokens are separate from coding token totals; reported classifier cost, including shadow requests, is included in total cost once. `routing.unpriced` means the catalog does not provide a price. Settings events omit literal TypeSafe keys.
61
61
 
62
62
  ### Names that look right and are not
63
63
 
package/docs/workflows.md CHANGED
@@ -308,7 +308,7 @@ A run's concurrency limit is its own, independent of the session's `maxConcurren
308
308
 
309
309
  ### Settings and the CLI flag
310
310
 
311
- Workflow children follow [Model routing](../README.md#model-routing). With `routingMode: "auto"` (default), custom agents come first, then a main-agent Markdown guideline, then optional Jev, then the existing model. The main agent receives the guideline before writing the script and can express its choice through `agent(prompt, { model, effort })`. Either explicit option skips Jev under `auto`; workflow options retain their precedence over agent-file defaults. Under `jev`, Jev chooses first and those defaults remain the fallback on uncertainty, missing credentials or errors. Under `shadow`, Jev records a suggestion without changing the model; under `off`, routing guidance and Jev are disabled. An inherited parent model is eligible for Jev. Resuming a child does not classify again in any mode.
311
+ Workflow children follow [Model routing](../README.md#model-routing). With `routingMode: "auto"` (default), custom agents come first, then a main-agent Markdown guideline, then optional Jev, then the existing model. The main agent receives the guideline before writing the script and can express its choice through `agent(prompt, { model, effort })`. Either explicit option skips Jev under `auto`; workflow options retain their precedence over agent-file defaults. A selected Jev profile applies its model and required `thinkingLevel` together; Pi clamps the level to model support. Shadow and fallback preserve both original values. Under `jev`, Jev chooses first and those defaults remain the fallback on uncertainty, missing credentials or errors. Under `shadow`, Jev records a suggestion without changing the model; under `off`, routing guidance and Jev are disabled. An inherited parent model is eligible for Jev. Resuming a child does not classify again in any mode.
312
312
 
313
313
  Jev classification happens once at child startup through Pi's native API, with a separate concurrency limit of four. Uncertainty, errors or a two-second timeout keep the existing model; stopping the workflow cancels classification without launching another child. Classifier tokens do not affect `budget.spent()` or coding context. Reported classifier cost rolls into child/ancestor cost totals once; classifier usage and the routing decision remain separately available on the agent record. Catalog-zero classifier prices mean unavailable pricing.
314
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diousk/pi-subagents-fast",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "A pi extension that brings Claude Code-like sub-agents and workflow orchestration to pi — parallel execution, live widget, fleet view, custom agent types, mid-run steering, dynamic workflows, Claude Code compatibility, look and feel.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -742,10 +742,12 @@ export class AgentManager {
742
742
  });
743
743
  record.routing = { ...routed.decision, guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
744
744
  if (routed.model && policy.mode === "shadow") {
745
- record.routing = { ...record.routing, code: "shadow", model: undefined, suggestedModel: routed.decision.model,
745
+ record.routing = { ...record.routing, code: "shadow", model: undefined, thinkingLevel: undefined, suggestedModel: routed.decision.model,
746
746
  fallbackSource: policy.source, reason: "Jev suggested a model; shadow mode kept the default-priority model" };
747
747
  } else if (routed.model) {
748
748
  options.model = routed.model;
749
+ options.thinkingLevel = routed.thinkingLevel;
750
+ record.invocation = { ...record.invocation, thinking: routed.thinkingLevel, requestedThinking: undefined };
749
751
  }
750
752
  } catch {
751
753
  record.routing.code = "classifier_error";
package/src/index.ts CHANGED
@@ -2994,7 +2994,7 @@ Terse command-style prompts produce shallow, generic work.
2994
2994
  await showSettings(ctx);
2995
2995
  await showAgentsMenu(ctx);
2996
2996
  } else if (choice === "Model routing") {
2997
- const patch = await showRoutingMenu(ctx);
2997
+ const patch = await showRoutingMenu(ctx, () => manager.listAgents());
2998
2998
  if (patch) {
2999
2999
  const toast = saveAndEmitChanged({ ...snapshotSettings(), ...patch }, "Model routing settings updated", (event, payload) => pi.events.emit(event, payload), ctx.cwd);
3000
3000
  ctx.ui.notify(toast.message, toast.level);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, statSync } from "node:fs";
3
- import type { Api, ClassifierResult, Model, Usage } from "@earendil-works/pi-ai";
3
+ import type { Api, ClassifierResult, Model, ThinkingLevel, Usage } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { loadCustomAgents } from "./custom-agents.js";
6
6
  import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
@@ -40,6 +40,8 @@ export interface RoutingDecision {
40
40
  "cancelled" | "timeout" | "invalid_answer" | "abstained" | "unavailable_choice" | "selected" | "classifier_error";
41
41
  model?: string;
42
42
  suggestedModel?: string;
43
+ thinkingLevel?: ThinkingLevel;
44
+ suggestedThinkingLevel?: ThinkingLevel;
43
45
  description?: string;
44
46
  confidence?: number;
45
47
  unpriced?: boolean;
@@ -93,14 +95,14 @@ export function routingGuidance(policy: RoutingPolicy): string {
93
95
  ? `Routing source: Custom guideline (${policy.guidelinePath}). Follow it when choosing Agent model/thinking (or workflow model/effort). Pass your default choice explicitly.\n<custom_routing_guideline>\n${policy.guideline}\n</custom_routing_guideline>`
94
96
  : policy.diagnostic ?? "Custom routing guideline is unavailable. Use the existing model.";
95
97
  break;
96
- case "jev": guidance = "Routing: Jev chooses the model for fresh agents when neither model nor thinking is explicitly set. Omit both to use automatic routing. Explicit choices and agent-file pins keep their existing precedence.";
98
+ case "jev": guidance = "Routing: Jev chooses the model and thinking level for fresh agents when neither model nor thinking is explicitly set. Omit both to use automatic routing. Explicit choices and agent-file pins keep their existing precedence.";
97
99
  }
98
- if (policy.mode === "jev") return "Routing mode: jev. Jev gets first choice of model for every fresh delegated task, including explicit model choices and agent-file model pins. Choose an agent and a default model using the guidance below; that choice is the fallback if Jev is unavailable or uncertain. Thinking keeps its existing precedence.\n" + guidance;
100
+ if (policy.mode === "jev") return "Routing mode: jev. Jev gets first choice of model for every fresh delegated task, including explicit model choices and agent-file model pins. Choose an agent and a default model using the guidance below; that choice is the fallback if Jev is unavailable or uncertain. The selected profile supplies both model and thinking level.\n" + guidance;
99
101
  if (policy.mode === "shadow") return "Routing mode: shadow. Jev records a suggestion for each fresh delegated task but never changes the model. Choose using the default guidance below, or keep the existing model when no guidance applies.\n" + guidance;
100
102
  return guidance && policy.source !== "jev" ? guidance + "\nJev is inactive under auto mode while this routing source applies." : guidance;
101
103
  }
102
104
 
103
- function eligibleModels(ctx: ExtensionContext): Map<string, Model<Api>> {
105
+ export function eligibleModels(ctx: ExtensionContext): Map<string, Model<Api>> {
104
106
  const scope = ctx.scopedModels;
105
107
  const allowed = scope?.length ? new Set(scope.map(entry => `${entry.model.provider}/${entry.model.id}`)) : undefined;
106
108
  const enabled = isScopeModelsEnabled() ? resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd) : undefined;
@@ -148,7 +150,7 @@ export class ModelRouter {
148
150
  baseline: Model<Api> | undefined,
149
151
  signal: AbortSignal,
150
152
  onUsage: (usage: Usage) => void,
151
- ): Promise<{ model?: Model<Api>; decision: RoutingDecision }> {
153
+ ): Promise<{ model?: Model<Api>; thinkingLevel?: ThinkingLevel; decision: RoutingDecision }> {
152
154
  const decision: RoutingDecision = { mode: policy.mode, source: "jev", fallbackSource: policy.fallbackSource ?? (policy.source === "jev" ? "baseline" : policy.source), code: "baseline", reason: "Using the default-priority model" };
153
155
  const config = policy.jev;
154
156
  if (policy.mode === "off" || (policy.mode === "auto" && policy.source !== "jev")) return { decision: { ...decision, source: policy.source } };
@@ -169,7 +171,7 @@ export class ModelRouter {
169
171
  try {
170
172
  release = await this.acquire(controller.signal);
171
173
  if (!release) return { decision: { ...decision, code: signal.aborted ? "cancelled" : "timeout", reason: signal.aborted ? "Cancelled" : "Jev timed out" } };
172
- const choices = new Map(candidates.map((entry, index) => [`route_${index}`, entry.model]));
174
+ const choices = new Map(candidates.map((entry, index) => [`route_${index}`, entry]));
173
175
  const criteria: Record<string, string> = { keep_baseline: "Keep the existing model if none of the described models clearly fits the task." };
174
176
  for (const [index, entry] of candidates.entries()) criteria[`route_${index}`] = entry.description;
175
177
  const cancelled = new Promise<undefined>(resolve => {
@@ -208,14 +210,18 @@ export class ModelRouter {
208
210
  return { decision: { ...decision, code: "invalid_answer", reason: "Jev returned no usable choice" } };
209
211
  }
210
212
  decision.confidence = answer.confidence;
211
- if (policy.mode === "shadow") decision.suggestedModel = choices.get(answer.choice);
213
+ const profile = choices.get(answer.choice);
214
+ if (policy.mode === "shadow") {
215
+ decision.suggestedModel = profile?.model;
216
+ decision.suggestedThinkingLevel = profile?.thinkingLevel;
217
+ }
212
218
  if (answer.choice === "keep_baseline" || answer.confidence < 0.6) {
213
219
  return { decision: { ...decision, code: "abstained", reason: "Jev kept the existing model" } };
214
220
  }
215
- const selected = choices.get(answer.choice);
221
+ const selected = profile?.model;
216
222
  const model = selected ? eligibleModels(ctx).get(selected) : undefined;
217
223
  if (!model || signal.aborted) return { decision: { ...decision, code: "unavailable_choice", reason: "Selected model is no longer available in scope" } };
218
- return { model, decision: { ...decision, fallbackSource: undefined, code: "selected", model: selected, description: candidates.find(entry => entry.model === selected)?.description, reason: "Jev selected a configured model" } };
224
+ return { model, thinkingLevel: profile?.thinkingLevel, decision: { ...decision, fallbackSource: undefined, code: "selected", model: selected, thinkingLevel: profile?.thinkingLevel, description: profile?.description, reason: "Jev selected a configured model profile" } };
219
225
  } catch {
220
226
  // Provider errors may contain credentials. Keep diagnostics code-owned.
221
227
  return { decision: { ...decision, code: "classifier_error", reason: "Jev could not choose a model; using the existing model" } };
@@ -1,8 +1,12 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+
3
+ export const ROUTING_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
4
+
1
5
  export type RoutingMode = "auto" | "shadow" | "jev" | "off";
2
6
 
3
7
  export interface JevConfig {
4
8
  TYPESAFE_API_KEY?: string;
5
- models: { model: string; description: string }[];
9
+ models: { model: string; description: string; thinkingLevel: ThinkingLevel }[];
6
10
  }
7
11
 
8
12
  /** Validate the whole block; never merge candidate lists or credentials. */
@@ -24,14 +28,16 @@ export function parseJevConfig(raw: unknown): JevConfig | false {
24
28
  const models = value.models.map((entry: unknown) => {
25
29
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error("Each Jev model needs model and description");
26
30
  const candidate = entry as Record<string, unknown>;
27
- if (Object.keys(candidate).some(key => key !== "model" && key !== "description") ||
31
+ if (Object.keys(candidate).some(key => key !== "model" && key !== "description" && key !== "thinkingLevel") ||
28
32
  typeof candidate.model !== "string" || !/^[^\s/]+\/\S+$/.test(candidate.model) ||
29
33
  typeof candidate.description !== "string" || !candidate.description.trim() || candidate.description.length > 4000) {
30
34
  throw new Error("Each Jev model needs an exact provider/model-id and a description of 1–4000 characters");
31
35
  }
36
+ const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === candidate.thinkingLevel);
37
+ if (!thinkingLevel) throw new Error("Each Jev model requires thinkingLevel: minimal, low, medium, high, xhigh or max");
32
38
  if (seen.has(candidate.model)) throw new Error("jev.models contains duplicate models");
33
39
  seen.add(candidate.model);
34
- return { model: candidate.model, description: candidate.description.trim() };
40
+ return { model: candidate.model, description: candidate.description.trim(), thinkingLevel };
35
41
  });
36
42
  return { models, ...(typeof value.TYPESAFE_API_KEY === "string" ? { TYPESAFE_API_KEY: value.TYPESAFE_API_KEY } : {}) };
37
43
  }
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Text } from "@earendil-works/pi-tui";
3
3
  import { loadRoutingPolicy } from "../model-routing.js";
4
- import { parseJevConfig, type RoutingMode } from "../routing-config.js";
4
+ import { parseJevConfig, ROUTING_THINKING_LEVELS, type RoutingMode } from "../routing-config.js";
5
5
  import { loadSettings, projectRoutingSettings, type SubagentsSettings } from "../settings.js";
6
+ import type { AgentRecord } from "../types.js";
7
+ import { showRoutingStatus } from "./routing-status.js";
6
8
 
7
9
  type RoutingSettings = Pick<SubagentsSettings, "routingMode" | "customGuideline" | "jev">;
8
10
 
@@ -21,7 +23,7 @@ export async function maskedApiKey(ctx: ExtensionCommandContext): Promise<string
21
23
  }
22
24
 
23
25
  /** Simple mode, guideline, models and credentials; saves project overrides. */
24
- export async function showRoutingMenu(ctx: ExtensionCommandContext): Promise<RoutingSettings | undefined> {
26
+ export async function showRoutingMenu(ctx: ExtensionCommandContext, records: () => AgentRecord[] = () => []): Promise<RoutingSettings | undefined> {
25
27
  const settings = loadSettings(ctx.cwd);
26
28
  const local = projectRoutingSettings(ctx.cwd);
27
29
  const policy = loadRoutingPolicy(ctx.cwd);
@@ -33,9 +35,13 @@ export async function showRoutingMenu(ctx: ExtensionCommandContext): Promise<Rou
33
35
  : policy.mode === "jev" ? `Jev first; fallback: ${labels[policy.fallbackSource ?? "baseline"]}.${policy.jev ? "" : " Configure Jev models and credentials to use Jev."}`
34
36
  : policy.diagnostic ?? inactive;
35
37
  const choice = await ctx.ui.select(`Model routing: ${policy.mode} — ${note || labels[policy.source]}`, [
36
- "Routing mode", "Custom guideline path", "Jev models and descriptions", "Typesafe API key", "Use Pi/environment credentials", "Disable custom guideline", "Disable Jev", "Back",
38
+ "Runtime status", "Routing mode", "Custom guideline path", "Jev models and descriptions", "Typesafe API key", "Use Pi/environment credentials", "Disable custom guideline", "Disable Jev", "Back",
37
39
  ]);
38
40
  if (!choice || choice === "Back") return;
41
+ if (choice === "Runtime status") {
42
+ await showRoutingStatus(ctx, records);
43
+ return;
44
+ }
39
45
  if (choice === "Routing mode") {
40
46
  const modes: { mode: RoutingMode; label: string }[] = [
41
47
  { mode: "auto", label: "auto — default priority: agents, guideline, Jev, existing model" },
@@ -65,7 +71,7 @@ export async function showRoutingMenu(ctx: ExtensionCommandContext): Promise<Rou
65
71
  catch (err) { ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev settings", "warning"); return; }
66
72
  }
67
73
  for (;;) {
68
- const options = [...models.map((entry, index) => `${index + 1}. ${entry.model}`), "Add model", "Save", "Cancel"];
74
+ const options = [...models.map((entry, index) => `${index + 1}. ${entry.model} (${entry.thinkingLevel})`), "Add model", "Save", "Cancel"];
69
75
  const action = await ctx.ui.select("Jev models — describe which tasks each model should handle", options);
70
76
  if (!action || action === "Cancel") return;
71
77
  if (action === "Save") {
@@ -83,7 +89,10 @@ export async function showRoutingMenu(ctx: ExtensionCommandContext): Promise<Rou
83
89
  if (!model) continue;
84
90
  const description = await ctx.ui.editor("Tasks this model is suitable for", current?.description ?? "");
85
91
  if (!description?.trim()) continue;
86
- const entry = { model: model.trim(), description: description.trim() };
92
+ const selectedLevel = await ctx.ui.select(`Thinking level (required${current ? `; current: ${current.thinkingLevel}` : ""})`, [...ROUTING_THINKING_LEVELS]);
93
+ const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === selectedLevel);
94
+ if (!thinkingLevel) continue;
95
+ const entry = { model: model.trim(), description: description.trim(), thinkingLevel };
87
96
  if (current) models[index] = entry;
88
97
  else models.push(entry);
89
98
  }
@@ -0,0 +1,97 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, Text } from "@earendil-works/pi-tui";
3
+ import { eligibleModels, loadRoutingPolicy } from "../model-routing.js";
4
+ import { isScopeModelsEnabled } from "../model-scope.js";
5
+ import { loadRoutingSettings, projectRoutingSettings } from "../settings.js";
6
+ import type { AgentRecord } from "../types.js";
7
+
8
+ /** Read current configuration and retained decisions without making a paid classifier request. */
9
+ export async function routingStatusText(ctx: ExtensionCommandContext, records: readonly AgentRecord[]): Promise<string> {
10
+ const { settings, guidelineFile } = loadRoutingSettings(ctx.cwd);
11
+ const local = projectRoutingSettings(ctx.cwd);
12
+ const policy = loadRoutingPolicy(ctx.cwd);
13
+ const config = settings.jev;
14
+ const apiKey = config ? config.TYPESAFE_API_KEY : undefined;
15
+ const available = eligibleModels(ctx);
16
+ const labels = { agents: "Custom agents", guideline: "Custom guideline", jev: "Jev", baseline: "Existing model" };
17
+ const lines = [
18
+ `Directory: ${ctx.cwd}`,
19
+ `Mode: ${policy.mode} (${local.routingMode !== undefined ? "project" : settings.routingMode !== undefined ? "global" : "default"})`,
20
+ `Routing source: ${labels[policy.source]}`,
21
+ `Fallback source: ${labels[policy.fallbackSource ?? (policy.source === "jev" ? "baseline" : policy.source)]}`,
22
+ `Parent model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "Pi default"}`,
23
+ `Custom agents: ${policy.agents?.map(agent => agent.name).join(", ") || "none active in this policy"}`,
24
+ `Guideline: ${guidelineFile ?? "not configured"}${policy.source !== "guideline" && policy.fallbackSource !== "guideline" ? " (inactive)" : ""}`,
25
+ `Jev config: ${config ? "valid" : config === false ? "disabled or invalid; check subagents.json" : "not configured"} (${local.jev !== undefined ? "project" : settings.jev !== undefined ? "global" : "default"})`,
26
+ `Scope: Pi scoped models ${ctx.scopedModels?.length ? "active" : "unrestricted"}; enabledModels filter ${isScopeModelsEnabled() ? "on" : "off"}`,
27
+ ];
28
+ if (policy.diagnostic) lines.push(`Diagnostic: ${policy.diagnostic}`);
29
+ const classifier = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
30
+ lines.push(`Jev classifier: ${classifier ? "available" : "unavailable in Pi registry"}`);
31
+ if (apiKey) {
32
+ lines.push("Credentials: configured key present (acceptance checked on next route)");
33
+ } else {
34
+ const controller = new AbortController();
35
+ let timer: ReturnType<typeof setTimeout> | undefined;
36
+ try {
37
+ const availableCredentials = await Promise.race([
38
+ ctx.modelRegistry.getAvailableOfType("classifier", "typesafe", { signal: controller.signal }),
39
+ new Promise<undefined>(resolve => { timer = setTimeout(() => { controller.abort(); resolve(undefined); }, 2000); }),
40
+ ]);
41
+ lines.push(`Credentials: ${availableCredentials === undefined ? "check timed out" : availableCredentials.some(model => model.id === "jev-latest") ? "Pi/environment credentials available (acceptance checked on next route)" : "missing Pi/environment credentials"}`);
42
+ } catch {
43
+ lines.push("Credentials: availability check failed");
44
+ } finally { clearTimeout(timer); }
45
+ }
46
+ lines.push("", "Configured profiles:");
47
+ if (config) {
48
+ for (const profile of config.models) {
49
+ lines.push(`${profile.model} | thinking: ${profile.thinkingLevel} | ${available.has(profile.model) ? "eligible" : "unavailable or excluded by scope"}`, ` ${profile.description}`);
50
+ }
51
+ } else lines.push("None. Every profile requires model, description and thinkingLevel.");
52
+ lines.push("", "Recent retained agents (latest 10; includes nested/workflow agents):");
53
+ const recent = [...records].filter(record => record.routing).sort((a, b) => b.startedAt - a.startedAt).slice(0, 10);
54
+ if (!recent.length) lines.push("No routing decisions yet. Start a fresh subagent; resumed agents do not reroute.");
55
+ for (const record of recent) {
56
+ const route = record.routing!;
57
+ lines.push(`${new Date(record.startedAt).toISOString()} | ${record.id} | ${record.status}`,
58
+ ` Mode: ${route.mode} | Source: ${route.source} | Result: ${route.code}`,
59
+ ` ${route.reason}`);
60
+ if (route.model) lines.push(` Selected: ${route.model} | requested thinking: ${route.thinkingLevel ?? "inherited"}`);
61
+ if (route.suggestedModel) lines.push(` Shadow suggestion: ${route.suggestedModel} | thinking: ${route.suggestedThinkingLevel ?? "inherited"}`);
62
+ if (record.invocation?.modelId) lines.push(` Actual: ${record.invocation.modelId} | effective thinking: ${record.invocation.thinking ?? "unknown"}`);
63
+ if (route.confidence !== undefined) lines.push(` Confidence: ${(route.confidence * 100).toFixed(1)}%`);
64
+ if (record.routingUsage) lines.push(` Classifier tokens: ${record.routingUsage.totalTokens} | ${route.unpriced ? "price unavailable" : `reported cost: $${record.routingUsage.cost.total}`}`);
65
+ }
66
+ lines.push("", "Snapshot only. Refresh to reload settings and retained decisions. Opening this page does not classify a task.");
67
+ const text = lines.join("\n");
68
+ return apiKey ? text.replaceAll(apiKey, "[redacted]") : text;
69
+ }
70
+
71
+ export async function showRoutingStatus(ctx: ExtensionCommandContext, records: () => AgentRecord[]): Promise<void> {
72
+ let refresh = true;
73
+ while (refresh) {
74
+ const content = await routingStatusText(ctx, records());
75
+ refresh = await ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
76
+ let offset = 0;
77
+ let maxOffset = 0;
78
+ return {
79
+ render(width: number) {
80
+ const rows = new Text(content, 0, 0).render(width);
81
+ const height = Math.max(1, tui.terminal.rows - 6);
82
+ maxOffset = Math.max(0, rows.length - height);
83
+ offset = Math.min(offset, maxOffset);
84
+ return [...new Text("Routing runtime status | Up/Down: scroll | r: refresh | Esc: back", 0, 0).render(width), ...rows.slice(offset, offset + height)];
85
+ },
86
+ invalidate() {},
87
+ handleInput(data: string) {
88
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) { done(false); return; }
89
+ if (matchesKey(data, "r")) { done(true); return; }
90
+ if (matchesKey(data, "up")) offset = Math.max(0, offset - 1);
91
+ if (matchesKey(data, "down")) offset = Math.min(maxOffset, offset + 1);
92
+ tui.requestRender();
93
+ },
94
+ };
95
+ });
96
+ }
97
+ }