@diousk/pi-subagents-fast 0.20.0 → 0.22.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/src/settings.ts CHANGED
@@ -3,12 +3,20 @@
3
3
  // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
4
 
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
7
8
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
9
  import { NO_FALLBACK } from "./agent-types.js";
10
+ import { type JevConfig, parseJevConfig, type RoutingMode } from "./routing-config.js";
9
11
  import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
10
12
 
11
13
  export interface SubagentsSettings {
14
+ /** auto (default), observe-only shadow, Jev-first jev, or no routing guidance/off. */
15
+ routingMode?: RoutingMode;
16
+ /** Main-agent routing instructions. false disables an inherited guideline. */
17
+ customGuideline?: string | false;
18
+ /** Model descriptions and optional credentials for automatic routing. */
19
+ jev?: JevConfig | false;
12
20
  maxConcurrent?: number;
13
21
  /**
14
22
  * Max concurrent FOREGROUND (blocking) agents — `0` = unlimited, the default,
@@ -351,11 +359,29 @@ const MAX_TURNS_CEILING = 10_000;
351
359
  const GRACE_TURNS_CEILING = 1_000;
352
360
  const SUBAGENT_DEPTH_CEILING = 16;
353
361
 
354
- /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
362
+ /** Drop invalid operational fields; explicit invalid routing settings disable their route. */
355
363
  function sanitize(raw: unknown): SubagentsSettings {
356
364
  if (!raw || typeof raw !== "object") return {};
357
365
  const r = raw as Record<string, unknown>;
358
366
  const out: SubagentsSettings = {};
367
+ if (Object.hasOwn(r, "routingMode")) {
368
+ if (r.routingMode === "auto" || r.routingMode === "shadow" || r.routingMode === "jev" || r.routingMode === "off") {
369
+ out.routingMode = r.routingMode;
370
+ } else {
371
+ out.routingMode = "off";
372
+ console.warn("[pi-subagents] Invalid routingMode; model routing is off. Use auto, shadow, jev or off.");
373
+ }
374
+ }
375
+ if (Object.hasOwn(r, "customGuideline")) {
376
+ out.customGuideline = r.customGuideline === false ? false : typeof r.customGuideline === "string" ? r.customGuideline.trim() : "";
377
+ }
378
+ if (Object.hasOwn(r, "jev")) {
379
+ try { out.jev = parseJevConfig(r.jev); }
380
+ catch (err) {
381
+ out.jev = false;
382
+ console.warn(`[pi-subagents] Invalid Jev settings: ${err instanceof Error ? err.message : "invalid configuration"}. Using the existing model.`);
383
+ }
384
+ }
359
385
  if (
360
386
  Number.isInteger(r.maxConcurrent) &&
361
387
  (r.maxConcurrent as number) >= 1 &&
@@ -475,22 +501,41 @@ function projectPath(cwd: string): string {
475
501
  /**
476
502
  * Read a settings file. Missing file is silent (returns `{}`). A file that
477
503
  * exists but can't be parsed emits a warning to stderr so users aren't
478
- * silently reverted to defaults — and still returns `{}` so startup proceeds.
504
+ * silently reverted to defaults. Routing is disabled for that file; startup proceeds.
479
505
  */
480
506
  function readSettingsFile(path: string): SubagentsSettings {
481
507
  if (!existsSync(path)) return {};
482
508
  try {
483
- return sanitize(JSON.parse(readFileSync(path, "utf-8")));
484
- } catch (err) {
485
- const reason = err instanceof Error ? err.message : String(err);
486
- console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
487
- return {};
509
+ const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
510
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Settings must be an object");
511
+ return sanitize(raw);
512
+ } catch {
513
+ // JSON parser errors can quote a literal API key. Never echo file contents.
514
+ console.warn(`[pi-subagents] Ignoring malformed settings at ${path}. Model routing is disabled for this file.`);
515
+ return { routingMode: "off", customGuideline: "", jev: false };
488
516
  }
489
517
  }
490
518
 
491
519
  /** Load merged settings: global provides defaults, project overrides. */
492
520
  export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
493
- return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
521
+ return loadRoutingSettings(cwd).settings;
522
+ }
523
+
524
+ /** Keep the supplying config directory for relative guideline paths. */
525
+ export function loadRoutingSettings(cwd: string): { settings: SubagentsSettings; guidelineFile?: string } {
526
+ const global = readSettingsFile(globalPath());
527
+ const project = readSettingsFile(projectPath(cwd));
528
+ const settings = { ...global, ...project };
529
+ const root = dirname(project.customGuideline !== undefined ? projectPath(cwd) : globalPath());
530
+ const path = settings.customGuideline;
531
+ return { settings, guidelineFile: typeof path === "string" && path
532
+ ? resolve(root, path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path) : undefined };
533
+ }
534
+
535
+ /** Preserve routing inheritance when an unrelated menu setting is saved. */
536
+ export function projectRoutingSettings(cwd: string): Pick<SubagentsSettings, "routingMode" | "customGuideline" | "jev"> {
537
+ const settings = readSettingsFile(projectPath(cwd));
538
+ return { routingMode: settings.routingMode, customGuideline: settings.customGuideline, jev: settings.jev };
494
539
  }
495
540
 
496
541
  /**
@@ -565,7 +610,7 @@ export function applyAndEmitLoaded(
565
610
  ): SubagentsSettings {
566
611
  const settings = loadSettings(cwd);
567
612
  applySettings(settings, appliers);
568
- emit("subagents:settings_loaded", { settings });
613
+ emit("subagents:settings_loaded", { settings: publicSettings(settings) });
569
614
  return settings;
570
615
  }
571
616
 
@@ -582,6 +627,12 @@ export function saveAndEmitChanged(
582
627
  cwd: string = process.cwd(),
583
628
  ): { message: string; level: "info" | "warning" } {
584
629
  const persisted = saveSettings(snapshot, cwd);
585
- emit("subagents:settings_changed", { settings: snapshot, persisted });
630
+ emit("subagents:settings_changed", { settings: publicSettings(snapshot), persisted });
586
631
  return persistToastFor(successMsg, persisted);
587
632
  }
633
+
634
+ /** Public snapshots never expose a literal Typesafe credential. */
635
+ export function publicSettings(settings: SubagentsSettings): SubagentsSettings {
636
+ if (!settings.jev) return { ...settings };
637
+ return { ...settings, jev: { models: settings.jev.models.map(entry => ({ ...entry })) } };
638
+ }
package/src/types.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * types.ts — Type definitions for the subagent system.
3
3
  */
4
4
 
5
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
5
+ import type { ThinkingLevel, Usage } from "@earendil-works/pi-ai";
6
6
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import type { RoutingDecision } from "./model-routing.js";
7
8
  import type { LifetimeUsage } from "./usage.js";
8
9
 
9
10
  export type { ThinkingLevel };
@@ -18,7 +19,7 @@ export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as con
18
19
  export type MemoryScope = "user" | "project" | "local";
19
20
 
20
21
  /** OpenAI Responses/Codex request processing tier. */
21
- export type ServiceTier = "auto" | "default" | "flex" | "priority" | "scale";
22
+ export type ServiceTier = "auto" | "default" | "flex" | "fast" | "priority" | "scale";
22
23
 
23
24
  /**
24
25
  * Isolation mode for agent execution.
@@ -159,6 +160,10 @@ export type MentionResolution =
159
160
  | { kind: "tombstone"; entry: AgentTombstone };
160
161
 
161
162
  export interface AgentRecord {
163
+ /** Credential-free startup decision, separate from effective session metadata. */
164
+ routing?: RoutingDecision;
165
+ /** Classifier spend; excluded from coding-context token counters. */
166
+ routingUsage?: Usage;
162
167
  id: string;
163
168
  type: SubagentType;
164
169
  /**
@@ -0,0 +1,90 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Input, Text } from "@earendil-works/pi-tui";
3
+ import { loadRoutingPolicy } from "../model-routing.js";
4
+ import { parseJevConfig, type RoutingMode } from "../routing-config.js";
5
+ import { loadSettings, projectRoutingSettings, type SubagentsSettings } from "../settings.js";
6
+
7
+ type RoutingSettings = Pick<SubagentsSettings, "routingMode" | "customGuideline" | "jev">;
8
+
9
+ /** Input handles paste/editing, but its unmasked renderer is never called. */
10
+ export async function maskedApiKey(ctx: ExtensionCommandContext): Promise<string | undefined> {
11
+ return ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
12
+ const input = new Input();
13
+ input.onSubmit = value => done(value.trim());
14
+ input.onEscape = () => done(undefined);
15
+ return {
16
+ render: (width: number) => new Text(`Typesafe API key: ${"*".repeat(input.getValue().length)}\nEnter to save; Esc to cancel`, 0, 0).render(width),
17
+ invalidate: () => {},
18
+ handleInput: (data: string) => input.handleInput(data),
19
+ };
20
+ });
21
+ }
22
+
23
+ /** Simple mode, guideline, models and credentials; saves project overrides. */
24
+ export async function showRoutingMenu(ctx: ExtensionCommandContext): Promise<RoutingSettings | undefined> {
25
+ const settings = loadSettings(ctx.cwd);
26
+ const local = projectRoutingSettings(ctx.cwd);
27
+ const policy = loadRoutingPolicy(ctx.cwd);
28
+ const labels = { agents: "Custom agents", guideline: "Custom guideline", jev: "Jev", baseline: "Existing model" };
29
+ const inactive = policy.source === "agents" ? "Guideline and Jev are inactive while custom agents exist."
30
+ : policy.source === "guideline" ? "Jev is inactive while a custom guideline is configured." : "";
31
+ const note = policy.mode === "off" ? "No routing guidance or Jev requests."
32
+ : policy.mode === "shadow" ? `Observe Jev; actual choice: ${labels[policy.source]}. Jev calls may incur charges.`
33
+ : policy.mode === "jev" ? `Jev first; fallback: ${labels[policy.fallbackSource ?? "baseline"]}.${policy.jev ? "" : " Configure Jev models and credentials to use Jev."}`
34
+ : policy.diagnostic ?? inactive;
35
+ 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",
37
+ ]);
38
+ if (!choice || choice === "Back") return;
39
+ if (choice === "Routing mode") {
40
+ const modes: { mode: RoutingMode; label: string }[] = [
41
+ { mode: "auto", label: "auto — default priority: agents, guideline, Jev, existing model" },
42
+ { mode: "shadow", label: "shadow — record Jev suggestions; keep the default model (may incur charges)" },
43
+ { mode: "jev", label: "jev — Jev first; default-priority fallback on uncertainty or failure" },
44
+ { mode: "off", label: "off — no routing guidance or Jev requests" },
45
+ ];
46
+ const selected = await ctx.ui.select(`Routing mode (current: ${policy.mode})`, modes.map(entry => entry.label));
47
+ const mode = modes.find(entry => entry.label === selected)?.mode;
48
+ return mode ? { routingMode: mode } : undefined;
49
+ }
50
+ if (choice === "Disable custom guideline") return { customGuideline: false };
51
+ if (choice === "Disable Jev") return { jev: false };
52
+ if (choice === "Custom guideline path") {
53
+ const path = await ctx.ui.input("Routing guideline Markdown path", typeof settings.customGuideline === "string" ? settings.customGuideline : "~/.pi/agent/agents/custom-route.md");
54
+ return path?.trim() ? { customGuideline: path.trim() } : undefined;
55
+ }
56
+ const models = settings.jev ? settings.jev.models.map(entry => ({ ...entry })) : [];
57
+ // Creating a project block must not silently copy a global credential.
58
+ const localKey = local.jev ? local.jev.TYPESAFE_API_KEY : undefined;
59
+ if (choice === "Use Pi/environment credentials") return models.length ? { jev: { models } } : undefined;
60
+ if (choice === "Typesafe API key") {
61
+ if (!models.length) { ctx.ui.notify("Configure Jev models first.", "info"); return; }
62
+ const key = await maskedApiKey(ctx);
63
+ if (!key) return;
64
+ try { return { jev: parseJevConfig({ models, TYPESAFE_API_KEY: key }) }; }
65
+ catch (err) { ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev settings", "warning"); return; }
66
+ }
67
+ for (;;) {
68
+ const options = [...models.map((entry, index) => `${index + 1}. ${entry.model}`), "Add model", "Save", "Cancel"];
69
+ const action = await ctx.ui.select("Jev models — describe which tasks each model should handle", options);
70
+ if (!action || action === "Cancel") return;
71
+ if (action === "Save") {
72
+ try { return { jev: parseJevConfig({ models, ...(localKey ? { TYPESAFE_API_KEY: localKey } : {}) }) }; }
73
+ catch (err) { ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev models", "warning"); continue; }
74
+ }
75
+ const index = options.indexOf(action);
76
+ const current = action === "Add model" ? undefined : models[index];
77
+ if (current) {
78
+ const edit = await ctx.ui.select(current.model, ["Edit", "Remove", "Back"]);
79
+ if (edit === "Remove") { models.splice(index, 1); continue; }
80
+ if (edit !== "Edit") continue;
81
+ }
82
+ const model = await ctx.ui.input("Exact provider/model-id", current?.model);
83
+ if (!model) continue;
84
+ const description = await ctx.ui.editor("Tasks this model is suitable for", current?.description ?? "");
85
+ if (!description?.trim()) continue;
86
+ const entry = { model: model.trim(), description: description.trim() };
87
+ if (current) models[index] = entry;
88
+ else models.push(entry);
89
+ }
90
+ }
@@ -298,6 +298,8 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
298
298
  request.prompt,
299
299
  {
300
300
  description: request.label,
301
+ agentConfig: config,
302
+ routing: { modelExplicit: modelInput !== undefined, thinkingExplicit: request.effort !== undefined || config?.thinking !== undefined, entrypoint: "workflow" },
301
303
  // The stamp is what keeps this child out of the session's
302
304
  // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
303
305
  // bounds how many of its agents run at once, and counting them
@@ -0,0 +1,47 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ // The print-mode e2e suite (test/subagents-print-mode-e2e.test.ts) drives REAL
5
+ // faux-model turns through pi-coding-agent + pi-agent-core. That requires ONE
6
+ // shared @earendil-works/pi-ai instance so the faux provider the test registers
7
+ // lands in the same api-registry the session streams through. npm physically
8
+ // duplicates pi-ai (a top-level copy and one nested under pi-coding-agent), which
9
+ // otherwise yields two registries and "No API provider registered" errors.
10
+ // Inlining the @earendil-works packages routes them through Vite's resolver so
11
+ // dedupe can collapse pi-ai to a single instance — for the parent AND for every
12
+ // subagent session the extension spawns. dedupe alone is insufficient (it only
13
+ // affects modules Vite resolves; without inline the runtime stays externalized).
14
+ test: {
15
+ server: { deps: { inline: [/@earendil-works\/pi-/] } },
16
+ // Local reporting only — deliberately no `thresholds`, and not wired into
17
+ // CI. src/index.ts is mostly the /agents wizard, which is TUI flow with
18
+ // almost no logic and is not worth a fake-TUI harness; any global floor
19
+ // would therefore either sit below what the rest of the suite achieves
20
+ // (and ratchet down as tests are deleted) or force exactly that harness.
21
+ // Coverage here measures lines touched, not behavior pinned.
22
+ //
23
+ // `istanbul`, NOT the `v8` default. v8 under-reports badly on this suite:
24
+ // `src/env.ts` came out at 54.54% for the full run but 90.9% for
25
+ // `vitest run --coverage test/env.test.ts` — coverage cannot fall as more
26
+ // tests run. Bisecting pins it on test/subagents-print-mode-e2e.test.ts,
27
+ // which boots a REAL pi session; pi's own resource loader re-loads the
28
+ // extension and its imports, so the same source file appears to v8 twice —
29
+ // once executed, once barely — and the merge takes the wrong one rather
30
+ // than the union. The damage was not confined to env.ts: v8 also reported
31
+ // module-level `const` declarations in src/settings.ts as uncovered, which
32
+ // is impossible since they run on import.
33
+ //
34
+ // istanbul instruments at transform time and accumulates per source path,
35
+ // so a second load adds to the same counters instead of shadowing them.
36
+ // Same suite, same run: v8 68.6% vs istanbul 76.5% statements, and the
37
+ // per-file numbers now match what a single-file run reports. If you switch
38
+ // this back to v8, re-check env.ts against `--coverage test/env.test.ts`
39
+ // before trusting anything the table says.
40
+ coverage: {
41
+ provider: "istanbul",
42
+ reporter: ["text", "html"],
43
+ include: ["src/**/*.ts"],
44
+ },
45
+ },
46
+ resolve: { dedupe: ["@earendil-works/pi-ai"] },
47
+ });