@henryqw/pi-subagent 1.1.1 → 2.0.2

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,18 +1,37 @@
1
- # @henryqw/pi-subagent
1
+ # `@henryqw/pi-subagent`
2
2
 
3
- Delegate one bounded task to one isolated Pi process. Main chooses role and model class per task.
4
-
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`.
3
+ Delegate one bounded task to one isolated Pi process. Main chooses the role and model class per call.
6
4
 
7
5
  ## Install
8
6
 
9
7
  ```bash
8
+ pi install npm:@henryqw/pi-task-models
9
+ pi install npm:@henryqw/pi-multi-codex
10
10
  pi install npm:@henryqw/pi-subagent
11
11
  ```
12
12
 
13
- ## Configure roles
13
+ ## With
14
+
15
+ | Package | Why |
16
+ | --- | --- |
17
+ | `@henryqw/pi-task-models` | Required. Shared `fast` / `balanced` / `frontier` routes. |
18
+ | `@henryqw/pi-multi-codex` | Required. Child uses Main's active Codex slot. |
19
+
20
+ ## Use
21
+
22
+ | Surface | Type | Purpose |
23
+ | --- | --- | --- |
24
+ | `delegate_task` | tool | Start one isolated child for `role`, `task`, and optional `modelClass`. |
25
+
26
+ `modelClass` is `fast`, `balanced`, or `frontier`. Omitted class uses `balanced`. Primary route is resolved against current scoped text models; fallback is tried only before launch. If no route is usable, delegation rejects with `Run /task-models`. A started child is never retried.
27
+
28
+ Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and skills are off; only role resources load. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. Streaming output is capped at 50 KiB.
29
+
30
+ TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
14
31
 
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`.
32
+ ## Config
33
+
34
+ Role files live in `~/.pi/agent/config/pi-subagent/*.md`. Model routes live in `~/.pi/agent/config/pi-task-models.json`.
16
35
 
17
36
  ```markdown
18
37
  ---
@@ -30,76 +49,27 @@ Review only requested change. Return ranked findings with file and line evidence
30
49
  Do not edit files.
31
50
  ```
32
51
 
33
- Fields:
34
-
35
52
  | Field | Required | Meaning |
36
53
  | --- | --- | --- |
37
54
  | `name` | yes | Role selected by Main |
38
- | `description` | yes | Tells Main when to use role |
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
- | `extensions` | no | Absolute/user-home paths or package sources passed to Pi `--extension` |
41
- | `skills` | no | Effective Pi Skill names loaded for role |
55
+ | `description` | yes | Tells Main when to use the role |
56
+ | `tools` | no | Omit for Pi `defaultTools`; a non-empty list is an exact allowlist; `[]` means none |
57
+ | `extensions` | no | Absolute/`~/` paths or package sources. Repository-relative paths are rejected. |
58
+ | `skills` | no | Effective Pi skill names, resolved from Main's registry |
42
59
  | Markdown body | yes | Role system instructions |
43
60
 
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
-
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:...`.
47
-
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.
49
-
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.
51
-
52
- Reload Pi after adding or changing role files or manually editing model config. `/subagent` applies changes immediately.
53
-
54
- ## Execution
61
+ Missing skills warn and skip; they do not block delegation. No repo-controlled `.pi/agents` roles. No package-local model picker.
55
62
 
56
- Main calls `delegate_task` with:
63
+ ## Remove
57
64
 
58
- - `role`: configured role name
59
- - `task`: one bounded task
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
- }
65
+ ```bash
66
+ pi remove npm:@henryqw/pi-subagent
72
67
  ```
73
68
 
74
- Explicit class routes must be configured and available. Omitted `modelClass` falls back to Main when `balanced` route is missing or stale.
75
-
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.
77
-
78
- Configured model and thinking level must exist in Main model registry. Invalid role config or explicit model class fails before child starts.
79
-
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.
81
-
82
- ## Widget
69
+ ## Development
83
70
 
84
- TUI shows one aligned row per Subagent:
85
-
86
- ```text
87
- ⠼ scout[haiku:low] find auth flow 18.4k 8s
88
- ✓ reviewer[sonnet:high] inspect auth diff 22.1k 14s
89
- ✗ worker[sonnet:high] fix token expiry 31.8k 27s
90
- ■ scout[haiku:low] map routes 6.2k 5s
71
+ ```bash
72
+ npm test --workspace @henryqw/pi-subagent
73
+ npm run typecheck --workspace @henryqw/pi-subagent
74
+ npm run pack:check --workspace @henryqw/pi-subagent
91
75
  ```
92
-
93
- Spinner means working; terminal icons mean success, failure, or abort. Token column sums `usage.totalTokens` across Subagent turns. Terminal rows auto-remove after one second; empty widget renders nothing. Role/route and task columns truncate to preserve right-aligned token and elapsed columns on narrow terminals.
94
-
95
- ## Scouting roles
96
-
97
- Treat scouting result as index, not file dump. Put conclusions first, followed by exact file/line references, short snippets only, risks, and unexamined scope. Main can read cited files when more detail is needed. Split work when useful index cannot fit within output cap.
98
-
99
- ## Deliberate limits
100
-
101
- - User roles only; no repo-controlled `.pi/agents` trust flow.
102
- - One task per call; Main handles orchestration.
103
- - No custom profile schema; role Markdown already groups instructions, tools, extensions, and skills.
104
- - No persistent child sessions or interactive panes.
105
- - No full-result artifact or retrieval protocol; split oversized work instead.
@@ -1,16 +1,26 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, dirname, isAbsolute, join } from "node:path";
6
- import { getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
5
+ import { basename, isAbsolute, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { StringEnum } from "@earendil-works/pi-ai";
7
8
  import { type ExtensionAPI, type ExtensionContext, getAgentDir, parseFrontmatter, type Theme } from "@earendil-works/pi-coding-agent";
8
9
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
10
+ import {
11
+ modelReference,
12
+ orderedProfileRoutes,
13
+ PROFILE_NAMES,
14
+ readTaskModelsConfig,
15
+ resolveTaskModelRoute,
16
+ type ProfileName,
17
+ type ResolvedTaskRoute,
18
+ } from "@henryqw/pi-task-models";
9
19
  import { Type } from "typebox";
10
20
 
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");
21
+ const MODEL_CLASSES = PROFILE_NAMES;
22
+ const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
23
+ const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
14
24
  const MAX_OUTPUT_BYTES = 50 * 1024;
15
25
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
16
26
  const WIDGET_KEY = "subagent-status";
@@ -19,10 +29,7 @@ const TERMINAL_DISPLAY_MS = 1_000;
19
29
  const MAX_WIDGET_ROWS = 8;
20
30
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
31
 
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>> };
32
+ type ModelClass = ProfileName;
26
33
  type Role = {
27
34
  name: string;
28
35
  description: string;
@@ -49,62 +56,27 @@ type WidgetItem = {
49
56
  removeAt?: number;
50
57
  };
51
58
 
52
- const defaultConfig = (): Config => ({ models: {} });
53
59
  const isModelClass = (value: unknown): value is ModelClass =>
54
60
  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
- };
61
+ const isNumberedCodexProvider = (provider: string): boolean => CODEX_ALIAS.test(provider);
62
62
 
63
- function readConfig(): { value: Config; invalid: boolean } {
63
+ function resolveTaskRoute(ctx: ExtensionContext, modelClass: ModelClass): ResolvedTaskRoute {
64
+ let config;
64
65
  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 };
66
+ config = readTaskModelsConfig();
67
+ } catch {
68
+ throw new Error("Couldn't read task model config. Run /task-models.");
94
69
  }
95
- }
96
70
 
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`);
71
+ const profile = config.profiles[modelClass];
72
+ if (!profile) throw new Error(`No ${modelClass} task model profile is configured. Run /task-models.`);
73
+ for (const route of orderedProfileRoutes(profile)) {
74
+ const resolved = resolveTaskModelRoute(ctx, route);
75
+ if (resolved) return resolved;
76
+ }
77
+ throw new Error(`No usable ${modelClass} task model route. Run /task-models.`);
101
78
  }
102
79
 
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
-
108
80
  const cleanText = (value: unknown, field: string, file: string): string => {
109
81
  if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
110
82
  throw new Error(`${file}: ${field} must be non-empty text.`);
@@ -466,7 +438,7 @@ const Parameters = Type.Object({
466
438
  role: Type.String({ description: "Configured Subagent role name" }),
467
439
  task: Type.String({ description: "One bounded task with needed context and expected result" }),
468
440
  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.",
441
+ 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 the shared balanced profile.",
470
442
  })),
471
443
  });
472
444
 
@@ -499,8 +471,6 @@ export default function subagentExtension(pi: ExtensionAPI): void {
499
471
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
500
472
  let spinnerIndex = 0;
501
473
  let activeTui: TUI | undefined;
502
- let config = defaultConfig();
503
-
504
474
  const stopWidgetTimer = () => {
505
475
  if (!widgetTimer) return;
506
476
  clearInterval(widgetTimer);
@@ -573,15 +543,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
573
543
  requestWidgetRender();
574
544
  };
575
545
 
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
546
  pi.on("session_start", (_event, ctx) => {
583
547
  ensureWidget(ctx);
584
- refreshConfig(ctx);
585
548
  });
586
549
  pi.on("session_shutdown", (_event, ctx) => {
587
550
  stopWidgetTimer();
@@ -591,48 +554,6 @@ export default function subagentExtension(pi: ExtensionAPI): void {
591
554
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
592
555
  });
593
556
 
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
-
636
557
  pi.registerTool({
637
558
  name: "delegate_task",
638
559
  label: "Subagent",
@@ -646,34 +567,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
646
567
  throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
647
568
  }
648
569
 
649
- refreshConfig(ctx);
650
- if (!ctx.model) throw new Error("delegate_task requires an active Pi model.");
651
570
  if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
652
571
  throw new Error("delegate_task modelClass must be fast, balanced, or frontier.");
653
572
  }
654
573
  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;
674
- if (thinkingLevel && !getSupportedThinkingLevels(model).includes(thinkingLevel)) {
675
- throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReferenceValue}.`);
676
- }
574
+ const resolvedRoute = resolveTaskRoute(ctx, modelClass);
575
+ const model = resolvedRoute.model;
576
+ const modelReferenceValue = modelReference(model);
577
+ const thinkingLevel = resolvedRoute.thinkingLevel;
677
578
 
678
579
  const resolvedSkills = resolveSkillPaths(pi, role.skills);
679
580
  if (resolvedSkills.missing.length) {
@@ -689,7 +590,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
689
590
  try {
690
591
  await writeFile(promptPath, role.systemPrompt, { encoding: "utf8", mode: 0o600 });
691
592
  const args = ["--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
692
- for (const extension of role.extensions) args.push("--extension", extension);
593
+ const extensions = isNumberedCodexProvider(model.provider)
594
+ ? [...role.extensions, MULTI_CODEX_EXTENSION]
595
+ : role.extensions;
596
+ for (const extension of new Set(extensions)) args.push("--extension", extension);
693
597
  for (const skill of resolvedSkills.paths) args.push("--skill", skill);
694
598
  if (role.tools !== undefined) {
695
599
  if (role.tools.length) args.push("--tools", role.tools.join(","));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "1.1.1",
3
+ "version": "2.0.2",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -44,5 +44,9 @@
44
44
  "extensions": [
45
45
  "./extensions/subagent.ts"
46
46
  ]
47
+ },
48
+ "dependencies": {
49
+ "@henryqw/pi-multi-codex": "^0.3.8",
50
+ "@henryqw/pi-task-models": "^0.1.0"
47
51
  }
48
52
  }