@diousk/pi-subagents-fast 0.21.0 → 0.23.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.
@@ -0,0 +1,116 @@
1
+ import { Input, Text } from "@earendil-works/pi-tui";
2
+ import { loadRoutingPolicy } from "../model-routing.js";
3
+ import { parseJevConfig, ROUTING_THINKING_LEVELS } from "../routing-config.js";
4
+ import { loadSettings, projectRoutingSettings } from "../settings.js";
5
+ /** Input handles paste/editing, but its unmasked renderer is never called. */
6
+ export async function maskedApiKey(ctx) {
7
+ return ctx.ui.custom((_tui, _theme, _kb, done) => {
8
+ const input = new Input();
9
+ input.onSubmit = value => done(value.trim());
10
+ input.onEscape = () => done(undefined);
11
+ return {
12
+ render: (width) => new Text(`Typesafe API key: ${"*".repeat(input.getValue().length)}\nEnter to save; Esc to cancel`, 0, 0).render(width),
13
+ invalidate: () => { },
14
+ handleInput: (data) => input.handleInput(data),
15
+ };
16
+ });
17
+ }
18
+ /** Simple mode, guideline, models and credentials; saves project overrides. */
19
+ export async function showRoutingMenu(ctx) {
20
+ const settings = loadSettings(ctx.cwd);
21
+ const local = projectRoutingSettings(ctx.cwd);
22
+ const policy = loadRoutingPolicy(ctx.cwd);
23
+ const labels = { agents: "Custom agents", guideline: "Custom guideline", jev: "Jev", baseline: "Existing model" };
24
+ const inactive = policy.source === "agents" ? "Guideline and Jev are inactive while custom agents exist."
25
+ : policy.source === "guideline" ? "Jev is inactive while a custom guideline is configured." : "";
26
+ const note = policy.mode === "off" ? "No routing guidance or Jev requests."
27
+ : policy.mode === "shadow" ? `Observe Jev; actual choice: ${labels[policy.source]}. Jev calls may incur charges.`
28
+ : policy.mode === "jev" ? `Jev first; fallback: ${labels[policy.fallbackSource ?? "baseline"]}.${policy.jev ? "" : " Configure Jev models and credentials to use Jev."}`
29
+ : policy.diagnostic ?? inactive;
30
+ 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
+ ]);
33
+ if (!choice || choice === "Back")
34
+ return;
35
+ if (choice === "Routing mode") {
36
+ const modes = [
37
+ { mode: "auto", label: "auto — default priority: agents, guideline, Jev, existing model" },
38
+ { mode: "shadow", label: "shadow — record Jev suggestions; keep the default model (may incur charges)" },
39
+ { mode: "jev", label: "jev — Jev first; default-priority fallback on uncertainty or failure" },
40
+ { mode: "off", label: "off — no routing guidance or Jev requests" },
41
+ ];
42
+ const selected = await ctx.ui.select(`Routing mode (current: ${policy.mode})`, modes.map(entry => entry.label));
43
+ const mode = modes.find(entry => entry.label === selected)?.mode;
44
+ return mode ? { routingMode: mode } : undefined;
45
+ }
46
+ if (choice === "Disable custom guideline")
47
+ return { customGuideline: false };
48
+ if (choice === "Disable Jev")
49
+ return { jev: false };
50
+ if (choice === "Custom guideline path") {
51
+ const path = await ctx.ui.input("Routing guideline Markdown path", typeof settings.customGuideline === "string" ? settings.customGuideline : "~/.pi/agent/agents/custom-route.md");
52
+ return path?.trim() ? { customGuideline: path.trim() } : undefined;
53
+ }
54
+ const models = settings.jev ? settings.jev.models.map(entry => ({ ...entry })) : [];
55
+ // Creating a project block must not silently copy a global credential.
56
+ const localKey = local.jev ? local.jev.TYPESAFE_API_KEY : undefined;
57
+ if (choice === "Use Pi/environment credentials")
58
+ return models.length ? { jev: { models } } : undefined;
59
+ if (choice === "Typesafe API key") {
60
+ if (!models.length) {
61
+ ctx.ui.notify("Configure Jev models first.", "info");
62
+ return;
63
+ }
64
+ const key = await maskedApiKey(ctx);
65
+ if (!key)
66
+ return;
67
+ try {
68
+ return { jev: parseJevConfig({ models, TYPESAFE_API_KEY: key }) };
69
+ }
70
+ catch (err) {
71
+ ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev settings", "warning");
72
+ return;
73
+ }
74
+ }
75
+ for (;;) {
76
+ const options = [...models.map((entry, index) => `${index + 1}. ${entry.model} (${entry.thinkingLevel})`), "Add model", "Save", "Cancel"];
77
+ const action = await ctx.ui.select("Jev models — describe which tasks each model should handle", options);
78
+ if (!action || action === "Cancel")
79
+ return;
80
+ if (action === "Save") {
81
+ try {
82
+ return { jev: parseJevConfig({ models, ...(localKey ? { TYPESAFE_API_KEY: localKey } : {}) }) };
83
+ }
84
+ catch (err) {
85
+ ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev models", "warning");
86
+ continue;
87
+ }
88
+ }
89
+ const index = options.indexOf(action);
90
+ const current = action === "Add model" ? undefined : models[index];
91
+ if (current) {
92
+ const edit = await ctx.ui.select(current.model, ["Edit", "Remove", "Back"]);
93
+ if (edit === "Remove") {
94
+ models.splice(index, 1);
95
+ continue;
96
+ }
97
+ if (edit !== "Edit")
98
+ continue;
99
+ }
100
+ const model = await ctx.ui.input("Exact provider/model-id", current?.model);
101
+ if (!model)
102
+ continue;
103
+ const description = await ctx.ui.editor("Tasks this model is suitable for", current?.description ?? "");
104
+ if (!description?.trim())
105
+ continue;
106
+ const selectedLevel = await ctx.ui.select(`Thinking level (required${current ? `; current: ${current.thinkingLevel}` : ""})`, [...ROUTING_THINKING_LEVELS]);
107
+ const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === selectedLevel);
108
+ if (!thinkingLevel)
109
+ continue;
110
+ const entry = { model: model.trim(), description: description.trim(), thinkingLevel };
111
+ if (current)
112
+ models[index] = entry;
113
+ else
114
+ models.push(entry);
115
+ }
116
+ }
@@ -260,6 +260,8 @@ export function createWorkflowHost(deps) {
260
260
  try {
261
261
  const { record } = await manager.spawnAndWait(pi, ctx, dispatch.type, request.prompt, {
262
262
  description: request.label,
263
+ agentConfig: config,
264
+ routing: { modelExplicit: modelInput !== undefined, thinkingExplicit: request.effort !== undefined || config?.thinking !== undefined, entrypoint: "workflow" },
263
265
  // The stamp is what keeps this child out of the session's
264
266
  // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
265
267
  // bounds how many of its agents run at once, and counting them
package/docs/rpc.md CHANGED
@@ -34,6 +34,7 @@ For the channel list, the reply envelope, the per-channel snippets and the event
34
34
 
35
35
  | Field | Why it is taken away |
36
36
  |---|---|
37
+ | `routing`, `agentConfig` | Private routing provenance and selected agent definition; callers cannot turn an explicit model into an inherited default or inject a Jev policy |
37
38
  | `parentAgentId` | Ownership. A forged parent hides your agent under someone else's nested tools |
38
39
  | `workflowId` | A forged value would hide an RPC-spawned agent inside someone else's workflow — and take it out of the concurrency pool with it |
39
40
  | `depth`, `maxSubagentDepth` | The nesting cap is inherited, not declared |
@@ -52,6 +53,12 @@ Four things that are not obvious from the tables:
52
53
  - **`structuredOutput` is documented "set only by the workflow host"** (`src/agent-manager.ts:231-234`) and is also not stripped.
53
54
  - **`signal` and the `on*` callbacks are function values.** They work only because the bus is in-process. A caller that genuinely serializes its payload cannot use them, and they arrive as `undefined` rather than failing.
54
55
 
56
+ ### Model routing
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`. 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
+
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
+
55
62
  ### Names that look right and are not
56
63
 
57
64
  One of these already shipped as a bug in this project's own README example, so it is worth reading the table even if you are sure.
package/docs/workflows.md CHANGED
@@ -308,6 +308,10 @@ 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. 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
+
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
+
311
315
  `workflowsEnabled` is **on**; leaving it unset means *auto*, which is on unless another extension already offers a `Workflow` or `SubagentWorkflow` tool, in which case this one stands down for the session. Setting it explicitly pins it. See [Persistent settings](../README.md#persistent-settings).
312
316
 
313
317
  `pi --subagents-workflow-file=<path>` runs a workflow at startup, including headless under `pi -p`. Use the `=` form — the bare `--flag value` spelling swallows the next argument. See [CLI flags](../README.md#cli-flags).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diousk/pi-subagents-fast",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -24,9 +24,9 @@
24
24
  "autonomous"
25
25
  ],
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-ai": ">=0.99.1",
28
- "@earendil-works/pi-coding-agent": ">=0.99.1",
29
- "@earendil-works/pi-tui": ">=0.99.1"
27
+ "@earendil-works/pi-ai": ">=1.0.0",
28
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
29
+ "@earendil-works/pi-tui": ">=1.0.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "@sinclair/typebox": "^0.34.49",
@@ -50,9 +50,9 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@biomejs/biome": "^2.4.14",
53
- "@earendil-works/pi-ai": "0.99.1",
54
- "@earendil-works/pi-coding-agent": "0.99.1",
55
- "@earendil-works/pi-tui": "0.99.1",
53
+ "@earendil-works/pi-ai": "1.0.0",
54
+ "@earendil-works/pi-coding-agent": "1.0.0",
55
+ "@earendil-works/pi-tui": "1.0.0",
56
56
  "@types/node": "^25.5.0",
57
57
  "@vitest/coverage-istanbul": "^4.1.10",
58
58
  "typescript": "^6.0.0",
@@ -19,10 +19,12 @@ import { statSync } from "node:fs";
19
19
  import { isAbsolute } from "node:path";
20
20
  import type { Model } from "@earendil-works/pi-ai";
21
21
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
- import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
22
+ import { resolveDefaultModel, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
23
+ import { getAgentConfig } from "./agent-types.js";
23
24
  import { assignHandle, handleBase } from "./mention.js";
24
25
  import { describeModel } from "./model-resolver.js";
25
- import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, SubagentType, ThinkingLevel } from "./types.js";
26
+ import { loadRoutingPolicy, ModelRouter, type RoutingInput } from "./model-routing.js";
27
+ import type { AgentConfig, AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, SubagentType, ThinkingLevel } from "./types.js";
26
28
  import { addUsage, type LifetimeUsage } from "./usage.js";
27
29
  import type { CompiledSchema } from "./workflow/json-schema.js";
28
30
  import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js";
@@ -167,6 +169,10 @@ interface SpawnArgs {
167
169
  }
168
170
 
169
171
  interface SpawnOptions {
172
+ /** Internal only; stripped at the programmatic/RPC boundary. */
173
+ routing?: RoutingInput;
174
+ /** Branch-local definition selected by a trusted invocation resolver. */
175
+ agentConfig?: AgentConfig;
170
176
  description: string;
171
177
  /**
172
178
  * Optional memorable name for this instance, becoming a second handle
@@ -361,6 +367,7 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
361
367
  }
362
368
 
363
369
  export class AgentManager {
370
+ private router = new ModelRouter();
364
371
  private agents = new Map<string, AgentRecord>();
365
372
  private cleanupInterval: ReturnType<typeof setInterval>;
366
373
  private onComplete?: OnAgentComplete;
@@ -550,7 +557,7 @@ export class AgentManager {
550
557
  record.alias = assignHandle(handleBase(options.name), this.takenHandles());
551
558
  }
552
559
 
553
- const args: SpawnArgs = { pi, ctx, type, prompt, options };
560
+ const args: SpawnArgs = { pi, ctx, type, prompt, options: { ...options, agentConfig: options.agentConfig ?? getAgentConfig(type) } };
554
561
 
555
562
  const pool = this.poolFor(record);
556
563
  if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
@@ -696,12 +703,67 @@ export class AgentManager {
696
703
  if (pool === "background") this.runningBackground--;
697
704
  else if (pool === "foreground") this.runningForeground--;
698
705
  };
706
+ const wasQueued = record.startGate !== undefined;
699
707
  record.status = "running";
700
708
  record.startedAt = Date.now();
701
709
  record.startGate = undefined;
702
710
  if (pool === "background") this.runningBackground++;
703
711
  else if (pool === "foreground") this.runningForeground++;
704
712
 
713
+ const config = options.agentConfig;
714
+ const provenance = options.routing;
715
+ const explicit = provenance
716
+ ? provenance.modelExplicit || provenance.thinkingExplicit
717
+ : options.model != null || options.thinkingLevel != null;
718
+ const currentPolicy = wasQueued || !provenance?.policy ? loadRoutingPolicy(options.configCwd ?? ctx.cwd) : undefined;
719
+ const policy = currentPolicy ?? provenance!.policy!;
720
+ record.routing = { mode: policy.mode, source: policy.source, fallbackSource: policy.fallbackSource,
721
+ code: policy.mode === "off" ? "off" : policy.diagnostic ? "guideline_unavailable" : "baseline",
722
+ reason: policy.mode === "off" ? "Model routing and routing guidance are off" : policy.diagnostic ?? "Using the existing model", guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
723
+ if (policy.diagnostic) console.warn(`[pi-subagents] ${policy.diagnostic}`);
724
+ const route = policy.mode === "jev" || policy.mode === "shadow" ||
725
+ (policy.mode === "auto" && !explicit && !config?.model && !config?.thinking && policy.source === "jev");
726
+ if (!options.resumeSessionFile && provenance?.entrypoint !== "internal" && route) {
727
+ const stop = () => this.abort(id);
728
+ options.signal?.addEventListener("abort", stop, { once: true });
729
+ if (options.signal?.aborted) stop();
730
+ try {
731
+ const routed = await this.router.choose(ctx, policy, prompt, options.description ?? type,
732
+ options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, config?.model),
733
+ record.abortController!.signal, usage => {
734
+ record.routingUsage = usage;
735
+ // Classifier tokens stay separate from coding/context/output budgets.
736
+ const delta = { input: usage.input, output: usage.output, cacheWrite: usage.cacheWrite, cacheRead: usage.cacheRead, cost: usage.cost.total };
737
+ this.onUsage?.(record, delta);
738
+ for (let current: AgentRecord | undefined = record; current; ) {
739
+ current.lifetimeUsage.cost = (current.lifetimeUsage.cost ?? 0) + usage.cost.total;
740
+ current = current.parentAgentId ? this.agents.get(current.parentAgentId) : undefined;
741
+ }
742
+ });
743
+ record.routing = { ...routed.decision, guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
744
+ if (routed.model && policy.mode === "shadow") {
745
+ record.routing = { ...record.routing, code: "shadow", model: undefined, thinkingLevel: undefined, suggestedModel: routed.decision.model,
746
+ fallbackSource: policy.source, reason: "Jev suggested a model; shadow mode kept the default-priority model" };
747
+ } else if (routed.model) {
748
+ options.model = routed.model;
749
+ options.thinkingLevel = routed.thinkingLevel;
750
+ record.invocation = { ...record.invocation, thinking: routed.thinkingLevel, requestedThinking: undefined };
751
+ }
752
+ } catch {
753
+ record.routing.code = "classifier_error";
754
+ record.routing.reason = "Jev could not choose a model; using the existing model";
755
+ } finally {
756
+ options.signal?.removeEventListener("abort", stop);
757
+ }
758
+ if (record.status !== "running" || record.abortController!.signal.aborted) {
759
+ this.settleRun(record, true, pool);
760
+ return;
761
+ }
762
+ } else if (policy.mode === "auto" && (explicit || config?.model || config?.thinking)) {
763
+ record.routing.code = "explicit";
764
+ record.routing.reason = "Explicit model or thinking; automatic routing skipped";
765
+ }
766
+
705
767
  // Worktree isolation: try to create a temporary git worktree. Strict —
706
768
  // fail loud if not possible (no silent fallback to main tree). Done BEFORE
707
769
  // the run is kicked off so a failure doesn't leave a half-running agent.
@@ -761,6 +823,7 @@ export class AgentManager {
761
823
  const promise = runAgent(ctx, type, prompt, {
762
824
  pi,
763
825
  agentId: id,
826
+ agentConfig: options.agentConfig,
764
827
  model: options.model,
765
828
  maxTurns: options.maxTurns,
766
829
  isolated: options.isolated,
@@ -1556,6 +1619,7 @@ export class AgentManager {
1556
1619
  */
1557
1620
  async dispose(pi?: ExtensionAPI): Promise<void> {
1558
1621
  clearInterval(this.cleanupInterval);
1622
+ this.abortAll();
1559
1623
  // Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
1560
1624
  // rather than left awaiting a gate nothing will ever resolve.
1561
1625
  this.dequeue(() => true);
@@ -28,7 +28,7 @@ import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager
28
28
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
29
29
  import { preloadSkills } from "./skill-loader.js";
30
30
  import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
31
- import type { ServiceTier, SubagentType, ThinkingLevel } from "./types.js";
31
+ import type { AgentConfig, ServiceTier, SubagentType, ThinkingLevel } from "./types.js";
32
32
  import type { LifetimeUsage } from "./usage.js";
33
33
  import type { CompiledSchema } from "./workflow/json-schema.js";
34
34
 
@@ -436,6 +436,8 @@ export interface ToolActivity {
436
436
  }
437
437
 
438
438
  export interface RunOptions {
439
+ /** Snapshot of the selected definition for this branch. */
440
+ agentConfig?: AgentConfig;
439
441
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
440
442
  pi: ExtensionAPI;
441
443
  /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
@@ -654,8 +656,8 @@ export async function runAgent(
654
656
  prompt: string,
655
657
  options: RunOptions,
656
658
  ): Promise<RunResult> {
657
- const config = getConfig(type);
658
- const agentConfig = getAgentConfig(type);
659
+ const agentConfig = options.agentConfig ?? getAgentConfig(type);
660
+ const config = agentConfig ?? getConfig(type);
659
661
 
660
662
  // Resolve working directory: worktree override > parent cwd
661
663
  const effectiveCwd = options.cwd ?? ctx.cwd;
@@ -688,7 +690,7 @@ export async function runAgent(
688
690
  }
689
691
  }
690
692
 
691
- let toolNames = getToolNamesForType(type);
693
+ let toolNames = options.agentConfig ? options.agentConfig.builtinToolNames ?? [...BUILTIN_TOOL_NAMES] : getToolNamesForType(type);
692
694
 
693
695
  // Persistent memory: detect write capability and branch accordingly.
694
696
  // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync } from "node:fs";
6
- import { basename, join } from "node:path";
6
+ import { basename, join, resolve } from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
+ import { loadRoutingSettings } from "./settings.js";
9
10
  import type { AgentConfig, IsolationMode, MemoryScope, ServiceTier, ThinkingLevel } from "./types.js";
10
11
 
11
12
  /**
@@ -47,9 +48,10 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
47
48
  const projectDir = join(cwd, ".pi", "agents");
48
49
 
49
50
  const agents = new Map<string, AgentConfig>();
50
- loadFromDir(globalDir, agents, "global", strict); // lowest priority
51
- loadFromDir(workspaceProjectDir, agents, "project", strict); // shared workspace
52
- loadFromDir(projectDir, agents, "project", strict); // highest priority (overwrites)
51
+ const excluded = loadRoutingSettings(cwd).guidelineFile;
52
+ loadFromDir(globalDir, agents, "global", strict, excluded);
53
+ loadFromDir(workspaceProjectDir, agents, "project", strict, excluded);
54
+ loadFromDir(projectDir, agents, "project", strict, excluded);
53
55
 
54
56
  warnedLastLoad = warnedThisLoad;
55
57
  warnedThisLoad = new Set();
@@ -57,7 +59,7 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
57
59
  }
58
60
 
59
61
  /** Load agent configs from a directory into the map. */
60
- function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean): void {
62
+ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean, excluded?: string): void {
61
63
  if (!existsSync(dir)) return;
62
64
 
63
65
  let files: string[];
@@ -68,6 +70,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
68
70
  }
69
71
 
70
72
  for (const file of files) {
73
+ if (file.toLowerCase() === "custom-route.md" || resolve(dir, file) === excluded) continue;
71
74
  const filenameType = basename(file, ".md");
72
75
 
73
76
  const path = join(dir, file);
package/src/index.ts CHANGED
@@ -29,12 +29,13 @@ import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from ".
29
29
  import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
30
30
  import { runMentionClone } from "./mention-clone.js";
31
31
  import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
32
+ import { loadRoutingPolicy, routingGuidance } from "./model-routing.js";
32
33
  import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
33
34
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
34
35
  import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
35
36
  import { SubagentScheduler } from "./schedule.js";
36
37
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
37
- import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
38
+ import { applyAndEmitLoaded, loadSettings, projectRoutingSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
38
39
  import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
39
40
  import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
40
41
  import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
@@ -57,6 +58,7 @@ import {
57
58
  type UICtx,
58
59
  } from "./ui/agent-widget.js";
59
60
  import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
61
+ import { showRoutingMenu } from "./ui/model-routing-menu.js";
60
62
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
61
63
  import { selectItem } from "./ui/select-item.js";
62
64
  import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
@@ -396,6 +398,7 @@ export default function (pi: ExtensionAPI) {
396
398
  const reloadCustomAgents = (strict = false) => {
397
399
  const userAgents = loadCustomAgents(process.cwd(), strict);
398
400
  registerAgents(userAgents);
401
+ return userAgents;
399
402
  };
400
403
 
401
404
  // Initial load — the only strict one. A bad edit mid-session must not kill the
@@ -561,6 +564,8 @@ export default function (pi: ExtensionAPI) {
561
564
  durationMs,
562
565
  tokens,
563
566
  usage,
567
+ routing: record.routing,
568
+ routingUsage: record.routingUsage,
564
569
  };
565
570
  }
566
571
 
@@ -586,6 +591,7 @@ export default function (pi: ExtensionAPI) {
586
591
  id: record.id, type: record.type, description: record.description,
587
592
  status: record.status, result: record.result, error: record.error,
588
593
  startedAt: record.startedAt, completedAt: record.completedAt,
594
+ routing: record.routing, routingUsage: record.routingUsage,
589
595
  });
590
596
 
591
597
  // Skip notification if result was already consumed via get_subagent_result
@@ -697,6 +703,8 @@ export default function (pi: ExtensionAPI) {
697
703
 
698
704
  const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
699
705
  const safeOptions = { ...(options ?? {}) };
706
+ delete safeOptions.routing;
707
+ delete safeOptions.agentConfig;
700
708
  delete safeOptions.parentAgentId;
701
709
  // Internal too: a forged value would hide an RPC-spawned agent inside
702
710
  // someone else's workflow, and take it out of the concurrency pool with it.
@@ -1364,6 +1372,16 @@ export default function (pi: ExtensionAPI) {
1364
1372
  widget.onTurnStart();
1365
1373
  });
1366
1374
 
1375
+ pi.on("before_agent_start", (event, ctx) => {
1376
+ const guidance = routingGuidance(loadRoutingPolicy(ctx.cwd));
1377
+ const description = agentToolDescription + (guidance ? "\n\n" + guidance : "");
1378
+ if (agentTool.description !== description) {
1379
+ agentTool.description = description;
1380
+ pi.registerTool(agentTool);
1381
+ }
1382
+ if (guidance) return { systemPrompt: event.systemPrompt + "\n\n" + guidance };
1383
+ });
1384
+
1367
1385
  /** Build the full type list text dynamically from available agents only. */
1368
1386
  const buildTypeListText = () => {
1369
1387
  const available = getAvailableTypes();
@@ -1578,10 +1596,11 @@ Terse command-style prompts produce shallow, generic work.
1578
1596
  // Held rather than registered inline: the mention clone reuses this exact
1579
1597
  // definition, so the agent it starts is an ordinary top-level spawn instead
1580
1598
  // of a second implementation that has to be kept in step with this one.
1599
+ const initialRoutingGuidance = routingGuidance(loadRoutingPolicy(process.cwd()));
1581
1600
  const agentTool = defineTool({
1582
1601
  name: SUBAGENT_TOOL_NAMES.AGENT,
1583
1602
  label: "Agent",
1584
- description: agentToolDescription,
1603
+ description: agentToolDescription + (initialRoutingGuidance ? "\n\n" + initialRoutingGuidance : ""),
1585
1604
  promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
1586
1605
  promptGuidelines: [
1587
1606
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
@@ -1769,7 +1788,8 @@ Terse command-style prompts produce shallow, generic work.
1769
1788
  widget.setUICtx(ctx.ui as UICtx);
1770
1789
 
1771
1790
  // Reload custom agents so new project/global .md files are picked up without restart
1772
- reloadCustomAgents();
1791
+ const userAgents = reloadCustomAgents();
1792
+ const routingPolicy = loadRoutingPolicy(ctx.cwd, userAgents);
1773
1793
 
1774
1794
  const rawType = params.subagent_type as SubagentType;
1775
1795
  // Single decision point for dispatch (#183): unknown, disabled and
@@ -1916,6 +1936,10 @@ Terse command-style prompts produce shallow, generic work.
1916
1936
  const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1917
1937
  const recModeLabel = getPromptModeLabel(type);
1918
1938
  const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1939
+ if (rec.routing?.source === "jev" && (rec.routingUsage || rec.routing.unpriced !== undefined)) {
1940
+ recTags.push(rec.routing.mode === "shadow" ? "Jev shadow" : "Jev");
1941
+ if (rec.routing.unpriced) recTags.push("Jev price unavailable");
1942
+ }
1919
1943
  return {
1920
1944
  displayName: getDisplayName(type),
1921
1945
  description: rec.description,
@@ -1952,7 +1976,7 @@ Terse command-style prompts produce shallow, generic work.
1952
1976
  subagent_type: requestedType,
1953
1977
  prompt: params.prompt as string,
1954
1978
  model: params.model as string | undefined,
1955
- thinking: thinking,
1979
+ thinking: params.thinking as typeof thinking,
1956
1980
  max_turns: effectiveMaxTurns,
1957
1981
  isolated: isolated,
1958
1982
  isolation: isolation,
@@ -2057,6 +2081,8 @@ Terse command-style prompts produce shallow, generic work.
2057
2081
  description: params.description,
2058
2082
  name: params.name as string | undefined,
2059
2083
  model,
2084
+ agentConfig: customConfig,
2085
+ routing: { policy: routingPolicy, modelExplicit: !!resolvedConfig.modelInput, thinkingExplicit: thinking !== undefined, entrypoint: "agent" },
2060
2086
  maxTurns: effectiveMaxTurns,
2061
2087
  isolated,
2062
2088
  inheritContext,
@@ -2211,6 +2237,8 @@ Terse command-style prompts produce shallow, generic work.
2211
2237
  description: params.description,
2212
2238
  name: params.name as string | undefined,
2213
2239
  model,
2240
+ agentConfig: customConfig,
2241
+ routing: { policy: routingPolicy, modelExplicit: !!resolvedConfig.modelInput, thinkingExplicit: thinking !== undefined, entrypoint: "agent" },
2214
2242
  maxTurns: effectiveMaxTurns,
2215
2243
  isolated,
2216
2244
  inheritContext,
@@ -2933,6 +2961,7 @@ Terse command-style prompts produce shallow, generic work.
2933
2961
  // Actions
2934
2962
  options.push("Create new agent");
2935
2963
  options.push("Settings");
2964
+ options.push("Model routing");
2936
2965
 
2937
2966
  const noAgentsMsg = allNames.length === 0 && agents.length === 0
2938
2967
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
@@ -2964,6 +2993,14 @@ Terse command-style prompts produce shallow, generic work.
2964
2993
  } else if (choice === "Settings") {
2965
2994
  await showSettings(ctx);
2966
2995
  await showAgentsMenu(ctx);
2996
+ } else if (choice === "Model routing") {
2997
+ const patch = await showRoutingMenu(ctx);
2998
+ if (patch) {
2999
+ const toast = saveAndEmitChanged({ ...snapshotSettings(), ...patch }, "Model routing settings updated", (event, payload) => pi.events.emit(event, payload), ctx.cwd);
3000
+ ctx.ui.notify(toast.message, toast.level);
3001
+ reloadCustomAgents();
3002
+ }
3003
+ await showAgentsMenu(ctx);
2967
3004
  }
2968
3005
  }
2969
3006
 
@@ -3331,6 +3368,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3331
3368
 
3332
3369
  const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
3333
3370
  description: `Generate ${name} agent`,
3371
+ routing: { modelExplicit: false, thinkingExplicit: false, entrypoint: "internal" },
3334
3372
  maxTurns: 5,
3335
3373
  // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3336
3374
  // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
@@ -3440,6 +3478,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3440
3478
  */
3441
3479
  function snapshotSettings() {
3442
3480
  return {
3481
+ ...projectRoutingSettings(process.cwd()),
3443
3482
  maxConcurrent: manager.getMaxConcurrent(),
3444
3483
  // 0 = unlimited, and the default — see SubagentsSettings.
3445
3484
  maxConcurrentForeground: manager.getMaxConcurrentForeground(),