@henryqw/pi-subagent 0.1.1 → 1.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @henryqw/pi-subagent
2
2
 
3
- Delegate one bounded task to one isolated Pi process. Main chooses role and may override model and thinking level per task.
3
+ Delegate one bounded task to one isolated Pi process. Main chooses role and model class per task.
4
4
 
5
5
  Based on Pi's authoritative [`examples/extensions/subagent`](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent): child processes use `pi --mode json -p --no-session`.
6
6
 
@@ -12,7 +12,7 @@ pi install npm:@henryqw/pi-subagent
12
12
 
13
13
  ## Configure roles
14
14
 
15
- Use Pi's existing role Markdown format in package-owned `~/.pi/agent/config/pi-subagent/*.md`. No JSON config or profile layer.
15
+ Use Pi's existing role Markdown format in package-owned `~/.pi/agent/config/pi-subagent/*.md`. Configure `fast`, `balanced`, and `frontier` model routes with `/subagent`.
16
16
 
17
17
  ```markdown
18
18
  ---
@@ -36,18 +36,20 @@ Fields:
36
36
  | --- | --- | --- |
37
37
  | `name` | yes | Role selected by Main |
38
38
  | `description` | yes | Tells Main when to use role |
39
- | `tools` | yes | Exact built-in and extension tool allowlist; use `[]` for none |
39
+ | `tools` | no | Omit for Pi effective `defaultTools`; a non-empty list is an exact built-in and extension tool allowlist; use `[]` for none |
40
40
  | `extensions` | no | Absolute/user-home paths or package sources passed to Pi `--extension` |
41
41
  | `skills` | no | Effective Pi Skill names loaded for role |
42
42
  | Markdown body | yes | Role system instructions |
43
43
 
44
+ Omitted `tools` uses Pi's effective `defaultTools` for built-ins. Tools registered by Role extensions stay active without listing their names. A non-empty `tools` list strictly allowlists both built-in and extension tools; `tools: []` sends `--no-tools`.
45
+
44
46
  String lists may also use comma-separated text, matching Pi's example role files. Repository-relative extension paths are rejected: child working directory is delegated project, so relative paths could load untrusted project code. Use absolute paths, `~/...`, or explicit package sources such as `npm:...`.
45
47
 
46
48
  Skill entries use Pi Skill names, normally Skill directory names, not filesystem paths. At delegation time, package resolves names from Main's effective Pi Skill registry and passes matching files to child. Missing or unavailable Skills produce warning and are skipped; they do not block delegation. This preserves Main's trust and Skill collision decisions.
47
49
 
48
- Pi's example `agents/` directory contains sample Role files, not another runtime mechanism. This package reuses that Markdown format but ships no presets: model choice and capabilities stay explicit in user config. No nested `agents/` directory is needed because `pi-subagent` config contains only Roles.
50
+ Pi's example `agents/` directory contains sample Role files, not another runtime mechanism. This package reuses that Markdown format but ships no presets: role capabilities and model routes stay explicit in user config. No nested `agents/` directory is needed because Roles remain Markdown files.
49
51
 
50
- Reload Pi after adding or changing role files so tool description exposes current roles.
52
+ Reload Pi after adding or changing role files or manually editing model config. `/subagent` applies changes immediately.
51
53
 
52
54
  ## Execution
53
55
 
@@ -55,12 +57,25 @@ Main calls `delegate_task` with:
55
57
 
56
58
  - `role`: configured role name
57
59
  - `task`: one bounded task
58
- - `model`: optional exact `provider/model`; defaults to Main model
59
- - `thinkingLevel`: optional; defaults to Main thinking level
60
+ - `modelClass`: optional `fast`, `balanced`, or `frontier`; defaults to configured `balanced`, then Main route
61
+
62
+ Run `/subagent` once per class. Select class, authenticated text model, and supported thinking level. Config lives in `~/.pi/agent/config/pi-subagent.json`:
63
+
64
+ ```json
65
+ {
66
+ "models": {
67
+ "fast": { "model": "provider/fast-model", "thinkingLevel": "off" },
68
+ "balanced": { "model": "provider/balanced-model", "thinkingLevel": "medium" },
69
+ "frontier": { "model": "provider/frontier-model", "thinkingLevel": "max" }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Explicit class routes must be configured and available. Omitted `modelClass` falls back to Main when `balanced` route is missing or stale.
60
75
 
61
76
  Each call starts isolated child process. Ambient extensions and skills are disabled. Only role resources load. Child uses delegated working directory and normal Pi project context files, inheriting Main's project approval decision. Abort terminates child process group.
62
77
 
63
- Model and thinking overrides must exist in Main model registry. Invalid role config, model, or thinking level fails before child starts.
78
+ Configured model and thinking level must exist in Main model registry. Invalid role config or explicit model class fails before child starts.
64
79
 
65
80
  Main-visible streaming updates, final output, and errors are capped at 50 KiB of UTF-8 text. Error collection stays bounded while child runs; malformed JSON events above 1 MiB fail delegation. Truncated output ends with exact omitted-byte count.
66
81
 
@@ -1,14 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, isAbsolute, join } from "node:path";
5
+ import { basename, dirname, isAbsolute, join } from "node:path";
6
6
  import { getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
7
7
  import { type ExtensionAPI, type ExtensionContext, getAgentDir, parseFrontmatter, type Theme } from "@earendil-works/pi-coding-agent";
8
8
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import { Type } from "typebox";
10
10
 
11
11
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
12
+ const MODEL_CLASSES = ["fast", "balanced", "frontier"] as const;
13
+ const configPath = () => join(getAgentDir(), "config", "pi-subagent.json");
12
14
  const MAX_OUTPUT_BYTES = 50 * 1024;
13
15
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
14
16
  const WIDGET_KEY = "subagent-status";
@@ -18,10 +20,13 @@ const MAX_WIDGET_ROWS = 8;
18
20
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
21
 
20
22
  type ThinkingLevel = (typeof THINKING_LEVELS)[number];
23
+ type ModelClass = (typeof MODEL_CLASSES)[number];
24
+ type ConfiguredModel = { model: string; thinkingLevel: ThinkingLevel };
25
+ type Config = { models: Partial<Record<ModelClass, ConfiguredModel>> };
21
26
  type Role = {
22
27
  name: string;
23
28
  description: string;
24
- tools: string[];
29
+ tools?: string[];
25
30
  extensions: string[];
26
31
  skills: string[];
27
32
  systemPrompt: string;
@@ -44,6 +49,62 @@ type WidgetItem = {
44
49
  removeAt?: number;
45
50
  };
46
51
 
52
+ const defaultConfig = (): Config => ({ models: {} });
53
+ const isModelClass = (value: unknown): value is ModelClass =>
54
+ typeof value === "string" && MODEL_CLASSES.includes(value as ModelClass);
55
+ const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
56
+ typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel);
57
+ const isModelReference = (value: unknown): value is string => {
58
+ if (typeof value !== "string" || value !== value.trim() || value.includes("\0")) return false;
59
+ const separator = value.indexOf("/");
60
+ return separator > 0 && separator < value.length - 1;
61
+ };
62
+
63
+ function readConfig(): { value: Config; invalid: boolean } {
64
+ try {
65
+ const value = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { value: defaultConfig(), invalid: true };
67
+ const record = value as Record<string, unknown>;
68
+ if (Object.keys(record).some((key) => key !== "models")) return { value: defaultConfig(), invalid: true };
69
+ if (record.models === undefined) return { value: defaultConfig(), invalid: false };
70
+ if (!record.models || typeof record.models !== "object" || Array.isArray(record.models)) {
71
+ return { value: defaultConfig(), invalid: true };
72
+ }
73
+ const modelRecord = record.models as Record<string, unknown>;
74
+ const models: Config["models"] = {};
75
+ let invalid = Object.keys(modelRecord).some((key) => !isModelClass(key));
76
+ for (const modelClass of MODEL_CLASSES) {
77
+ if (!Object.hasOwn(modelRecord, modelClass)) continue;
78
+ const candidate = modelRecord[modelClass];
79
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
80
+ invalid = true;
81
+ continue;
82
+ }
83
+ const route = candidate as Record<string, unknown>;
84
+ if (isModelReference(route.model) && isThinkingLevel(route.thinkingLevel)) {
85
+ models[modelClass] = { model: route.model, thinkingLevel: route.thinkingLevel };
86
+ } else invalid = true;
87
+ }
88
+ return { value: { models }, invalid };
89
+ } catch (error: unknown) {
90
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
91
+ return { value: defaultConfig(), invalid: false };
92
+ }
93
+ return { value: defaultConfig(), invalid: true };
94
+ }
95
+ }
96
+
97
+ function writeConfig(config: Config): void {
98
+ const file = configPath();
99
+ mkdirSync(dirname(file), { recursive: true });
100
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
101
+ }
102
+
103
+ const modelReference = (model: { provider: string; id: string }): string => `${model.provider}/${model.id}`;
104
+ const availableTextModels = (ctx: ExtensionContext) => ctx.modelRegistry
105
+ .getAvailable()
106
+ .filter((model) => model.input.includes("text"));
107
+
47
108
  const cleanText = (value: unknown, field: string, file: string): string => {
48
109
  if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
49
110
  throw new Error(`${file}: ${field} must be non-empty text.`);
@@ -100,7 +161,7 @@ export function loadRoles(agentDir = getAgentDir()): Role[] {
100
161
  return {
101
162
  name: cleanText(frontmatter.name, "name", file),
102
163
  description: cleanText(frontmatter.description, "description", file),
103
- tools: stringList(frontmatter.tools, "tools", file, true),
164
+ tools: frontmatter.tools === undefined ? undefined : stringList(frontmatter.tools, "tools", file, true),
104
165
  extensions: extensionList(frontmatter.extensions, file),
105
166
  skills: stringList(frontmatter.skills, "skills", file),
106
167
  systemPrompt: cleanText(parsed.body, "system prompt", file),
@@ -404,9 +465,8 @@ async function runPi(
404
465
  const Parameters = Type.Object({
405
466
  role: Type.String({ description: "Configured Subagent role name" }),
406
467
  task: Type.String({ description: "One bounded task with needed context and expected result" }),
407
- model: Type.Optional(Type.String({ description: "Exact provider/model; defaults to Main model" })),
408
- thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, {
409
- description: "Thinking level; defaults to Main thinking level",
468
+ modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
469
+ description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning. Defaults to configured balanced, then Main route.",
410
470
  })),
411
471
  });
412
472
 
@@ -439,6 +499,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
439
499
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
440
500
  let spinnerIndex = 0;
441
501
  let activeTui: TUI | undefined;
502
+ let config = defaultConfig();
442
503
 
443
504
  const stopWidgetTimer = () => {
444
505
  if (!widgetTimer) return;
@@ -512,7 +573,16 @@ export default function subagentExtension(pi: ExtensionAPI): void {
512
573
  requestWidgetRender();
513
574
  };
514
575
 
515
- pi.on("session_start", (_event, ctx) => ensureWidget(ctx));
576
+ const refreshConfig = (ctx: ExtensionContext) => {
577
+ const loaded = readConfig();
578
+ config = loaded.value;
579
+ if (loaded.invalid) ctx.ui.notify("Invalid pi-subagent config values were ignored.", "warning");
580
+ };
581
+
582
+ pi.on("session_start", (_event, ctx) => {
583
+ ensureWidget(ctx);
584
+ refreshConfig(ctx);
585
+ });
516
586
  pi.on("session_shutdown", (_event, ctx) => {
517
587
  stopWidgetTimer();
518
588
  widgetItems.clear();
@@ -521,10 +591,52 @@ export default function subagentExtension(pi: ExtensionAPI): void {
521
591
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
522
592
  });
523
593
 
594
+ pi.registerCommand("subagent", {
595
+ description: "configure fast, balanced, and frontier Subagent model routes",
596
+ handler: async (args, ctx) => {
597
+ if (args.trim()) {
598
+ ctx.ui.notify("Usage: /subagent", "warning");
599
+ return;
600
+ }
601
+ refreshConfig(ctx);
602
+ const modelClass = await ctx.ui.select("Subagent model class", [...MODEL_CLASSES]);
603
+ if (!isModelClass(modelClass)) return;
604
+ const models = availableTextModels(ctx);
605
+ if (!models.length) {
606
+ ctx.ui.notify("No authenticated text models are available.", "warning");
607
+ return;
608
+ }
609
+ const saved = config.models[modelClass];
610
+ const references = models.map(modelReference).sort();
611
+ const selected = await ctx.ui.select(
612
+ `${modelClass} Subagent model · saved: ${saved?.model ?? "none"}`,
613
+ references,
614
+ );
615
+ const selectedModel = models.find((model) => modelReference(model) === selected);
616
+ if (!selectedModel) return;
617
+ const levels = getSupportedThinkingLevels(selectedModel);
618
+ const thinkingLevel = await ctx.ui.select(
619
+ `${modelClass} Subagent thinking level · saved: ${saved?.thinkingLevel ?? "none"}`,
620
+ levels,
621
+ );
622
+ if (!isThinkingLevel(thinkingLevel) || !levels.some((level) => level === thinkingLevel)) return;
623
+ try {
624
+ const latest = readConfig();
625
+ const route = { model: modelReference(selectedModel), thinkingLevel };
626
+ const next = { ...latest.value, models: { ...latest.value.models, [modelClass]: route } };
627
+ writeConfig(next);
628
+ config = next;
629
+ ctx.ui.notify(`${modelClass} Subagent set to ${route.model} with thinking ${thinkingLevel}.`, "info");
630
+ } catch {
631
+ ctx.ui.notify("Couldn't save pi-subagent model config.", "warning");
632
+ }
633
+ },
634
+ });
635
+
524
636
  pi.registerTool({
525
637
  name: "delegate_task",
526
638
  label: "Subagent",
527
- description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Select model and thinking level only when task needs a different route. Request concise conclusions and file/line references; split broad scouting work.`,
639
+ description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work. Request concise conclusions and file/line references; split broad scouting work.`,
528
640
  parameters: Parameters,
529
641
  async execute(toolCallId, params, signal, onUpdate, ctx) {
530
642
  const task = cleanText(params.task, "task", "delegate_task");
@@ -534,16 +646,33 @@ export default function subagentExtension(pi: ExtensionAPI): void {
534
646
  throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
535
647
  }
536
648
 
537
- const modelReference = params.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
538
- if (!modelReference || modelReference.includes("\0")) throw new Error("Subagent model is missing.");
539
- const model = ctx.modelRegistry.getAvailable().find(
540
- (candidate) => `${candidate.provider}/${candidate.id}` === modelReference && candidate.input.includes("text"),
541
- );
542
- if (!model) throw new Error(`Subagent model is unavailable: ${modelReference}.`);
543
-
544
- const thinkingLevel = params.thinkingLevel ?? ctx.thinkingLevel;
649
+ refreshConfig(ctx);
650
+ if (!ctx.model) throw new Error("delegate_task requires an active Pi model.");
651
+ if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
652
+ throw new Error("delegate_task modelClass must be fast, balanced, or frontier.");
653
+ }
654
+ const modelClass = params.modelClass ?? "balanced";
655
+ const configuredModel = config.models[modelClass];
656
+ if (params.modelClass && !configuredModel) {
657
+ throw new Error(`No ${modelClass} Subagent model configured; run /subagent.`);
658
+ }
659
+ const selectedModelInfo = configuredModel && availableTextModels(ctx)
660
+ .find((model) => modelReference(model) === configuredModel.model);
661
+ if (params.modelClass && configuredModel && !selectedModelInfo) {
662
+ throw new Error(`Configured ${modelClass} Subagent model is unavailable; run /subagent.`);
663
+ }
664
+ const thinkingAvailable = Boolean(configuredModel && selectedModelInfo
665
+ && getSupportedThinkingLevels(selectedModelInfo).some((level) => level === configuredModel.thinkingLevel));
666
+ if (params.modelClass && configuredModel && selectedModelInfo && !thinkingAvailable) {
667
+ throw new Error(`Configured ${modelClass} Subagent thinking level is unavailable; run /subagent.`);
668
+ }
669
+ const configuredRoute = thinkingAvailable ? configuredModel : undefined;
670
+ const modelReferenceValue = configuredRoute?.model ?? modelReference(ctx.model);
671
+ const model = configuredRoute ? selectedModelInfo : ctx.model;
672
+ if (!model) throw new Error("Subagent model metadata is unavailable.");
673
+ const thinkingLevel = configuredRoute?.thinkingLevel ?? ctx.thinkingLevel;
545
674
  if (thinkingLevel && !getSupportedThinkingLevels(model).includes(thinkingLevel)) {
546
- throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReference}.`);
675
+ throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReferenceValue}.`);
547
676
  }
548
677
 
549
678
  const resolvedSkills = resolveSkillPaths(pi, role.skills);
@@ -562,15 +691,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
562
691
  const args = ["--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
563
692
  for (const extension of role.extensions) args.push("--extension", extension);
564
693
  for (const skill of resolvedSkills.paths) args.push("--skill", skill);
565
- if (role.tools.length) args.push("--tools", role.tools.join(","));
566
- else args.push("--no-tools");
567
- args.push("--model", modelReference);
694
+ if (role.tools !== undefined) {
695
+ if (role.tools.length) args.push("--tools", role.tools.join(","));
696
+ else args.push("--no-tools");
697
+ }
698
+ args.push("--model", modelReferenceValue);
568
699
  if (thinkingLevel) args.push("--thinking", thinkingLevel);
569
700
  args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
570
701
  args.push("--append-system-prompt", promptPath, `Task: ${task}`);
571
702
 
572
703
  startWidgetItem(toolCallId, role.name, model.id, thinkingLevel, task, ctx);
573
- const details = { role: role.name, model: modelReference, thinkingLevel };
704
+ const details = { role: role.name, model: modelReferenceValue, thinkingLevel };
574
705
  const result = await runPi(
575
706
  args,
576
707
  ctx.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "0.1.1",
3
+ "version": "1.1.0",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,9 +24,9 @@
24
24
  "pack:check": "npm pack --dry-run"
25
25
  },
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-ai": "^0.84.1",
28
- "@earendil-works/pi-coding-agent": "^0.84.1",
29
- "@earendil-works/pi-tui": "^0.84.1",
27
+ "@earendil-works/pi-ai": "^0.84.2",
28
+ "@earendil-works/pi-coding-agent": "^0.84.2",
29
+ "@earendil-works/pi-tui": "^0.84.2",
30
30
  "typebox": "^1.3.7"
31
31
  },
32
32
  "repository": {