@bacnh85/pi-subagent 0.15.3 → 0.16.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,154 @@
1
+ /**
2
+ * `/subagent roles` panel (kernel lives in @bacnh85/pi-config-panel).
3
+ *
4
+ * Row model for role-based model routing: one string row per role (comma
5
+ * chain, blank = reset to default) and one string row per discovered agent
6
+ * (`subagent.agentModels` override, blank = inherit agent file settings).
7
+ * Saving writes both maps to the GLOBAL `~/.pi/agent/settings.json` under
8
+ * `subagent.roles` / `subagent.agentModels` (merge + atomic rename), then
9
+ * invalidates the agent cache.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { row } from "@bacnh85/pi-config-panel";
16
+ import type { PanelGroup } from "@bacnh85/pi-config-panel";
17
+ import type { AgentConfig } from "./agents.ts";
18
+ import { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Settings persistence (global only — repo .pi/settings.json is read-only)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function settingsPath(): string {
25
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
26
+ return join(agentDir, "settings.json");
27
+ }
28
+
29
+ /** Read the GLOBAL settings.json. Returns null only when the file is missing;
30
+ * throws when the file exists but is not valid JSON (so a corrupt file can
31
+ * never be silently overwritten by a panel save). */
32
+ function readSettingsJson(): Record<string, unknown> | null {
33
+ const path = settingsPath();
34
+ if (!existsSync(path)) return null;
35
+ const raw = readFileSync(path, "utf8");
36
+ try {
37
+ const parsed = JSON.parse(raw) as unknown;
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
+ throw new Error(`settings.json root is not a JSON object (${path})`);
40
+ }
41
+ return parsed as Record<string, unknown>;
42
+ } catch (err) {
43
+ if (err instanceof Error && err.message.includes("not a JSON object")) throw err;
44
+ throw new Error(`settings.json is not valid JSON (${path})`);
45
+ }
46
+ }
47
+
48
+ /** Read-modify-write the `subagent` section of the GLOBAL settings.json.
49
+ * Writes a temp file then renames (atomic); never touches other keys.
50
+ * Throws if the existing settings.json is corrupt (the file is left intact).
51
+ * Returns false when nothing changes. */
52
+ export function writeSubagentSection(patch: { roles?: RolesConfig["roles"]; agentModels?: RolesConfig["agentModels"] }): boolean {
53
+ const settings = readSettingsJson() ?? {};
54
+ const existing = (settings.subagent ?? {}) as Record<string, unknown>;
55
+ const subagent = { ...existing };
56
+ if (patch.roles !== undefined) subagent.roles = patch.roles;
57
+ if (patch.agentModels !== undefined) subagent.agentModels = patch.agentModels;
58
+ if (JSON.stringify(subagent) === JSON.stringify(existing)) return false;
59
+ const rolesEmpty = subagent.roles === undefined || (typeof subagent.roles === "object" && Object.keys(subagent.roles as object).length === 0);
60
+ const modelsEmpty = subagent.agentModels === undefined || (typeof subagent.agentModels === "object" && Object.keys(subagent.agentModels as object).length === 0);
61
+ if (rolesEmpty) delete subagent.roles;
62
+ if (modelsEmpty) delete subagent.agentModels;
63
+ // Preserve any unrelated subagent.* keys (forward compat); drop the section
64
+ // only when roles/agentModels were the only content.
65
+ if (Object.keys(subagent).length === 0) delete settings.subagent;
66
+ else settings.subagent = subagent;
67
+ const target = settingsPath();
68
+ mkdirSync(dirname(target), { recursive: true });
69
+ const tmp = `${target}.tmp-${Date.now()}`;
70
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
71
+ renameSync(tmp, target);
72
+ return true;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Row model — unit-testable without a TUI
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface RolesPanelCfg {
80
+ /** Working copy: role name → comma chain ("" = use default). */
81
+ roles: Record<string, string>;
82
+ /** Working copy: agent name → override selector ("" = inherit). */
83
+ agentModels: Record<string, string>;
84
+ }
85
+
86
+ /** Seed a working config from current effective settings + bundled agents. */
87
+ export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig): RolesPanelCfg {
88
+ const roleNames = new Set([...Object.keys(DEFAULT_ROLES), ...Object.keys(current.roles)]);
89
+ const cfg: RolesPanelCfg = { roles: {}, agentModels: {} };
90
+ for (const name of roleNames) {
91
+ // Only show explicitly-configured values; defaults render blank (= default).
92
+ const explicit = (current.roles[name] !== undefined && JSON.stringify(current.roles[name]) !== JSON.stringify(DEFAULT_ROLES[name]))
93
+ ? (Array.isArray(current.roles[name]) ? current.roles[name].join(", ") : String(current.roles[name]))
94
+ : "";
95
+ cfg.roles[name] = explicit;
96
+ }
97
+ for (const agent of agents) cfg.agentModels[agent.name] = current.agentModels[agent.name] ?? "";
98
+ return cfg;
99
+ }
100
+
101
+ /** Build panel groups. Role rows first, then one override row per agent. */
102
+ export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[]): PanelGroup[] {
103
+ const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
104
+ const roleRows = Object.keys(cfg.roles).sort().map((name) => {
105
+ // Label shows the default chain so blank is meaningful.
106
+ return row(`role.${name}`, `@${name} (default: ${defaultChain(name) || "none"})`, "string", cfg.roles[name], (v) => {
107
+ cfg.roles[name] = String(v ?? "").trim();
108
+ });
109
+ });
110
+ const agentRows = agents.map((agent) =>
111
+ row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
112
+ const value = String(v ?? "").trim();
113
+ if (value) cfg.agentModels[agent.name] = value;
114
+ else delete cfg.agentModels[agent.name];
115
+ }),
116
+ );
117
+ return [
118
+ { key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
119
+ { key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
120
+ ];
121
+ }
122
+
123
+ /** Convert a working config back to a settings patch. Blank values drop keys
124
+ * (role falls back to default; agent override is removed). */
125
+ export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; agentModels: RolesConfig["agentModels"] } {
126
+ const roles: RolesConfig["roles"] = {};
127
+ for (const [name, chain] of Object.entries(cfg.roles)) {
128
+ const trimmed = chain.trim();
129
+ if (!trimmed) continue;
130
+ roles[name] = trimmed.includes(",") ? trimmed.split(",").map((s) => s.trim()).filter(Boolean) : trimmed;
131
+ }
132
+ const agentModels: RolesConfig["agentModels"] = {};
133
+ for (const [name, value] of Object.entries(cfg.agentModels)) {
134
+ const trimmed = value.trim();
135
+ if (trimmed) agentModels[name] = trimmed;
136
+ }
137
+ return { roles, agentModels };
138
+ }
139
+
140
+ /** Keep overrides for agents NOT shown in the panel (e.g. overrides for
141
+ * project-local agents saved globally from another project) so a panel save
142
+ * doesn't wipe them. Discovered-agent entries always follow the panel. */
143
+ export function preserveUnknownAgentModels(
144
+ patch: RolesConfig["agentModels"],
145
+ discoveredNames: readonly string[],
146
+ existing: RolesConfig["agentModels"] | undefined,
147
+ ): RolesConfig["agentModels"] {
148
+ if (!existing) return patch;
149
+ const out = { ...patch };
150
+ for (const [name, value] of Object.entries(existing)) {
151
+ if (!discoveredNames.includes(name) && out[name] === undefined) out[name] = value;
152
+ }
153
+ return out;
154
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Role-based model routing for pi-subagent.
3
+ *
4
+ * A role maps a *function* (fast / coder / smart / custom) to an ordered model
5
+ * chain in settings.json under `subagent.roles`. Agent frontmatter references
6
+ * roles via `@role` aliases (`model: @fast`), so remapping a function is one
7
+ * settings edit instead of N agent-file edits.
8
+ *
9
+ * Settings shape (`~/.pi/agent/settings.json`; repo `.pi/settings.json` is a
10
+ * read-only overlay honored when the project is trusted):
11
+ *
12
+ * {
13
+ * "subagent": {
14
+ * "roles": {
15
+ * "fast": "nvidia/openai/gpt-oss-20b, opencode-go/deepseek-v4-flash",
16
+ * "coder": ["zai-coding-cn/glm-5.1", "opencode-go/deepseek-v4-flash"],
17
+ * "smart": "*"
18
+ * },
19
+ * "agentModels": { "reviewer": "@smart:high" }
20
+ * }
21
+ * }
22
+ *
23
+ * - Role value: string (comma chain) or array. `*` / `@default` = parent model.
24
+ * - `@role` entries may reference other roles (visited-set cycle guard).
25
+ * - Unknown roles are skipped and recorded in `unresolved` for diagnostics.
26
+ * - A trailing `:thinking` suffix (off|minimal|low|medium|high|xhigh|max) on
27
+ * any entry overrides the agent's frontmatter thinking for that match.
28
+ * Strict trailing match only — openrouter ids like `model:free` keep intact.
29
+ */
30
+
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import type { AgentConfig } from "./agents.ts";
36
+
37
+ export type SubagentThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
38
+
39
+ const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
40
+
41
+ /** Role → raw chain value (string/array, may contain `@role` and `*` refs). */
42
+ export type RoleMap = Record<string, string | string[]>;
43
+
44
+ export interface RolesConfig {
45
+ roles: RoleMap;
46
+ /** Per-agent model override (agent name → selector / role alias / `*`). */
47
+ agentModels: Record<string, string>;
48
+ }
49
+
50
+ export interface ExpandedCandidates {
51
+ /** Concrete candidate names in priority order (may be empty = parent fallback). */
52
+ candidates: string[];
53
+ /** thinking suffix per matched candidate name (name as written, without suffix). */
54
+ thinkingByCandidate: Map<string, SubagentThinkingLevel>;
55
+ /** Unknown role aliases encountered (for diagnostics). */
56
+ unresolved: string[];
57
+ }
58
+
59
+ /** Today's bundled chains, as role defaults — behavior-preserving baseline. */
60
+ export const DEFAULT_ROLES: RoleMap = {
61
+ fast: ["zai-coding-cn/glm-5-turbo", "nvidia/openai/gpt-oss-20b", "opencode-go/deepseek-v4-flash"],
62
+ coder: [
63
+ "zai-coding-cn/glm-5.1",
64
+ "nvidia/mistralai/mistral-small-4-119b-2603",
65
+ "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
66
+ "opencode-go/deepseek-v4-flash",
67
+ ],
68
+ smart: ["zai-coding-cn/glm-5.3", "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "opencode-go/deepseek-v4-pro"],
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Settings reading
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function agentDir(): string {
76
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
77
+ }
78
+
79
+ function readJson(path: string): Record<string, unknown> | null {
80
+ try {
81
+ if (!existsSync(path)) return null;
82
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function subagentSection(json: Record<string, unknown> | null): Record<string, unknown> {
89
+ const section = json?.subagent;
90
+ return section && typeof section === "object" && !Array.isArray(section)
91
+ ? (section as Record<string, unknown>)
92
+ : {};
93
+ }
94
+
95
+ /** Normalize a role value: comma string → array of trimmed non-empty entries. */
96
+ function normalizeChain(value: string | string[]): string[] {
97
+ const entries = typeof value === "string" ? value.split(",") : value;
98
+ return entries
99
+ .map((entry) => String(entry).trim())
100
+ .filter(Boolean);
101
+ }
102
+
103
+ /** Merge `subagent.roles` / `subagent.agentModels` from a settings JSON into cfg. */
104
+ function mergeSection(cfg: RolesConfig, json: Record<string, unknown> | null): void {
105
+ const section = subagentSection(json);
106
+ const roles = section.roles;
107
+ if (roles && typeof roles === "object" && !Array.isArray(roles)) {
108
+ for (const [name, value] of Object.entries(roles as RoleMap)) {
109
+ if (value === null || value === undefined) continue;
110
+ const chain = normalizeChain(value as string | string[]);
111
+ if (chain.length > 0) cfg.roles[name] = chain;
112
+ }
113
+ }
114
+ const agentModels = section.agentModels;
115
+ if (agentModels && typeof agentModels === "object" && !Array.isArray(agentModels)) {
116
+ for (const [name, value] of Object.entries(agentModels as Record<string, unknown>)) {
117
+ if (typeof value === "string" && value.trim()) cfg.agentModels[name] = value.trim();
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Read effective roles config. Precedence: global settings.json → repo
124
+ * `.pi/settings.json` (project-trusted only) → `(ctx as any).settings` (the
125
+ * SDK-layered view, when present). Later layers win per role/agent key.
126
+ */
127
+ export function readSubagentRoles(ctx?: ExtensionContext): RolesConfig {
128
+ const cfg: RolesConfig = {
129
+ roles: structuredClone(DEFAULT_ROLES),
130
+ agentModels: {},
131
+ };
132
+ mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
133
+ try {
134
+ if (ctx?.isProjectTrusted?.()) {
135
+ mergeSection(cfg, readJson(join(ctx.cwd, ".pi", "settings.json")));
136
+ }
137
+ } catch { /* untrusted or ctx without cwd — global only */ }
138
+ const layered = (ctx as unknown as { settings?: Record<string, unknown> } | undefined)?.settings;
139
+ if (layered) mergeSection(cfg, layered);
140
+ return cfg;
141
+ }
142
+
143
+ /** Global-only variant used by the panel so a save never persists repo
144
+ * `.pi/settings.json` overlay values into the user's global settings. */
145
+ export function readSubagentRolesGlobal(): RolesConfig {
146
+ const cfg: RolesConfig = {
147
+ roles: structuredClone(DEFAULT_ROLES),
148
+ agentModels: {},
149
+ };
150
+ mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
151
+ return cfg;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Expansion
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** Split a trailing `:thinking` suffix. Returns null when the trailing segment
159
+ * is not a known level (e.g. openrouter `:free`), leaving the name intact. */
160
+ export function splitThinkingSuffix(name: string): { name: string; thinking?: SubagentThinkingLevel } {
161
+ const idx = name.lastIndexOf(":");
162
+ if (idx <= 0 || idx >= name.length - 1) return { name };
163
+ const suffix = name.slice(idx + 1);
164
+ if (!THINKING_LEVELS.includes(suffix)) return { name };
165
+ return { name: name.slice(0, idx), thinking: suffix as SubagentThinkingLevel };
166
+ }
167
+
168
+ function isParentRef(entry: string): boolean {
169
+ return entry === "*" || entry === "@default";
170
+ }
171
+
172
+ /**
173
+ * Expand a raw candidate list into concrete model names.
174
+ * `@role` entries expand in place (recursively, cycle-safe); `*` / `@default`
175
+ * resolve to an empty list (= parent fallback in resolveModel).
176
+ */
177
+ export function expandModelCandidates(candidates: readonly string[], roles: RoleMap): ExpandedCandidates {
178
+ const out: string[] = [];
179
+ const thinkingByCandidate = new Map<string, SubagentThinkingLevel>();
180
+ const unresolved: string[] = [];
181
+ const seen = new Set<string>();
182
+
183
+ const pushEntry = (entry: string, thinking?: SubagentThinkingLevel): void => {
184
+ const { name, thinking: own } = splitThinkingSuffix(entry);
185
+ if (!name) return;
186
+ if (isParentRef(name)) return; // parent fallback: resolveModel handles the tail
187
+ if (seen.has(name)) {
188
+ // Earlier occurrence wins for thinking too (priority order).
189
+ return;
190
+ }
191
+ seen.add(name);
192
+ out.push(name);
193
+ const level = own ?? thinking;
194
+ if (level) thinkingByCandidate.set(name, level);
195
+ };
196
+
197
+ const walk = (entry: string, visited: Set<string>, inheritedThinking?: SubagentThinkingLevel): void => {
198
+ const raw = entry.trim();
199
+ if (!raw) return;
200
+ const { name: bare, thinking: own } = splitThinkingSuffix(raw);
201
+ if (isParentRef(bare)) return;
202
+ if (bare.startsWith("@")) {
203
+ const roleName = bare.slice(1);
204
+ const value = roles[roleName];
205
+ if (value === undefined || normalizeChain(value).length === 0) {
206
+ if (!unresolved.includes(bare)) unresolved.push(bare);
207
+ return;
208
+ }
209
+ if (visited.has(roleName)) return; // cycle guard
210
+ const next = new Set(visited).add(roleName);
211
+ // A suffix on the role reference ("@smart:high") applies to every model
212
+ // the role expands to; per-entry suffixes win over the inherited one.
213
+ const effective = own ?? inheritedThinking;
214
+ for (const child of normalizeChain(value)) walk(child, next, effective);
215
+ return;
216
+ }
217
+ pushEntry(bare, own ?? inheritedThinking);
218
+ };
219
+
220
+ for (const entry of candidates) walk(entry, new Set());
221
+ return { candidates: out, thinkingByCandidate, unresolved };
222
+ }
223
+
224
+ export interface AgentModelChain {
225
+ candidates: string[];
226
+ thinkingByCandidate: Map<string, SubagentThinkingLevel>;
227
+ unresolved: string[];
228
+ /** True when `subagent.agentModels` replaced the agent's own list. */
229
+ overridden: boolean;
230
+ }
231
+
232
+ /**
233
+ * Effective model chain for an agent: `subagent.agentModels[name]` (if set)
234
+ * replaces the agent's own candidates entirely, then `@role` / `:thinking`
235
+ * entries expand. No settings → bundled defaults → today's exact chains.
236
+ */
237
+ export function resolveAgentModelChain(
238
+ agent: Pick<AgentConfig, "name" | "model" | "models">,
239
+ roles: RolesConfig,
240
+ ): AgentModelChain {
241
+ const override = roles.agentModels[agent.name];
242
+ const raw =
243
+ override !== undefined
244
+ ? [override]
245
+ : [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
246
+ const expanded = expandModelCandidates(raw, roles.roles);
247
+ return { ...expanded, overridden: override !== undefined };
248
+ }
249
+
250
+ /** Human-readable effective chain for prompt catalog + `/subagent` details. */
251
+ export function describeAgentModels(
252
+ agent: Pick<AgentConfig, "name" | "model" | "models">,
253
+ roles: RolesConfig,
254
+ ): string {
255
+ const raw =
256
+ roles.agentModels[agent.name] ??
257
+ [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))].join(", ");
258
+ const { candidates, unresolved } = resolveAgentModelChain(agent, roles);
259
+ const chain = candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback";
260
+ const source = roles.agentModels[agent.name] ? "override" : "frontmatter";
261
+ const warn = unresolved.length > 0 ? ` (unresolved: ${unresolved.join(", ")})` : "";
262
+ return `[${source}: ${raw}] ${chain}${warn}`;
263
+ }
@@ -413,7 +413,9 @@ export function getFinalOutput(messages: Message[]): string {
413
413
  if (part.type === "text" && part.text.trim()) texts.push(part.text);
414
414
  }
415
415
  if (texts.length === 0) continue;
416
- return texts.join("");
416
+ // Join with a newline so interleaved text segments (text around toolCall
417
+ // parts in one message) stay separated instead of gluing "…done.Next step…".
418
+ return texts.join("\n");
417
419
  }
418
420
  return "";
419
421
  }
@@ -565,6 +565,14 @@ export function validateExecutionRequest(
565
565
  ): ValidationError[] {
566
566
  const errors: ValidationError[] = [];
567
567
 
568
+ // Timeout (single/parallel/chain share one requested timeout)
569
+ if (options.timeout !== undefined) {
570
+ const t = normalizeTimeout({ requested: options.timeout });
571
+ if (t.error) {
572
+ errors.push({ field: "timeout", message: t.error });
573
+ }
574
+ }
575
+
568
576
  // Agent name
569
577
  if (options.agentName !== undefined) {
570
578
  if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
@@ -1,7 +1,8 @@
1
1
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { type AgentConfig, getModelCandidates } from "./agents.ts";
2
+ import { type AgentConfig } from "./agents.ts";
3
3
  import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
4
- import { resolveModel } from "./model.ts";
4
+ import { resolveModel, runWithModelFallback } from "./model.ts";
5
+ import { readSubagentRoles, resolveAgentModelChain } from "./roles.ts";
5
6
  import {
6
7
  isRateLimitError,
7
8
  validateAgentTools,
@@ -42,10 +43,15 @@ export async function runNamedAgent(options: {
42
43
  signal?: AbortSignal;
43
44
  /** When true, only read-only tools are permitted regardless of agent.sandbox. */
44
45
  readOnly?: boolean;
46
+ /** Trusted opt-out for child cwd outside the workspace (from getTrustedConfig). */
47
+ allowExternalCwd?: boolean;
45
48
  onMessage?: (result: SubAgentResult) => void;
46
49
  onProgress?: (progress: SubAgentProgress) => void;
47
50
  }): Promise<SubAgentResult> {
48
- const { model, attempted } = await resolveModel(getModelCandidates(options.agent), options.ctx.model, options.ctx.modelRegistry);
51
+ const rolesCfg = readSubagentRoles(options.ctx);
52
+ const agentChain = resolveAgentModelChain(options.agent, rolesCfg);
53
+ const resolvedModel = await resolveModel(agentChain.candidates, options.ctx.model, options.ctx.modelRegistry);
54
+ const { model, attempted } = resolvedModel;
49
55
  if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
50
56
 
51
57
  const modelRegistry = options.ctx.modelRegistry;
@@ -53,7 +59,11 @@ export async function runNamedAgent(options: {
53
59
  const authStorage = (modelRegistry as any).authStorage;
54
60
 
55
61
  // Security: validate and normalise timeout.
56
- const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
62
+ const timeoutResult = normalizeTimeout({ requested: options.timeout });
63
+ if (timeoutResult.error) {
64
+ throw new Error(timeoutResult.error);
65
+ }
66
+ const effectiveTimeoutMs = timeoutResult.timeoutMs;
57
67
 
58
68
  // Parent tool names — agents without an explicit `tools` line inherit them.
59
69
  const parentToolNames = (options.ctx as any).getAllTools?.()?.map((t: { name: string }) => t.name) as string[] | undefined;
@@ -77,80 +87,55 @@ export async function runNamedAgent(options: {
77
87
 
78
88
  // Security: validate cwd (service caller must provide valid cwd).
79
89
  // The service path uses the same policy as the tool path.
80
- const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd });
90
+ const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd, allowExternalCwd: options.allowExternalCwd });
81
91
  if (safeCwd.error) {
82
92
  throw new Error(safeCwd.error);
83
93
  }
84
94
 
85
95
  const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
86
96
 
87
- // Retry loop: rate-limit model fallback
88
- const candidates = getModelCandidates(options.agent);
89
- const triedModels: string[] = [];
90
-
91
- const tryWithFallback = async (): Promise<SubAgentResult> => {
92
- const remaining = candidates.filter(m => !triedModels.includes(m));
93
- const isParentFallback = remaining.length === 0;
94
- const fallbackResolved = await resolveModel(remaining, options.ctx.model, options.ctx.modelRegistry);
95
- if (!fallbackResolved.model) {
96
- throw new Error(
97
- `All models rate-limited or unavailable. Tried: ${triedModels.join(" → ") || "(none)"}. ` +
98
- `Remaining candidates: ${remaining.join(", ") || "none"}. ` +
99
- `Parent: ${options.ctx.model?.provider}/${options.ctx.model?.id}.`,
100
- );
101
- }
102
- const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
103
- if (triedModels.includes(triedName)) {
104
- // Already tried this model (e.g., all candidates unavailable
105
- // and parent fallback) — no further options.
106
- throw new Error(
107
- `All available models exhausted. Tried: ${triedModels.join(" → ")}.`,
108
- );
109
- }
110
- triedModels.push(triedName);
111
- // Also track the raw candidate name so candidates.filter() can
112
- // exclude it even when the agent uses unqualified names.
113
- // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
114
- if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
115
- triedModels.push(fallbackResolved.matchedCandidate);
116
- }
117
-
118
- const result = await runSubAgent({
119
- cwd: safeCwd.path,
120
- sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
121
- systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
122
- task: options.task,
123
- tools: toolValidation.tools,
124
- model: fallbackResolved.model,
125
- modelRuntime,
126
- authStorage,
127
- modelRegistry,
128
- signal: options.signal,
129
- timeoutMs: effectiveTimeoutMs,
130
- agentName: options.agent.name,
131
- thinkingLevel: options.agent.thinking,
132
- onMessage: options.onMessage,
133
- onProgress: options.onProgress,
134
- loadExtensions,
135
- projectTrusted,
136
- });
137
-
138
- if (result.errorMessage && isRateLimitError(result.errorMessage)) {
139
- // If the model that just rate-limited was the parent fallback
140
- // (no remaining candidates), stop — no further options.
141
- if (isParentFallback) {
97
+ // Retry loop: rate-limit model fallback — shared with the tool path so the
98
+ // triedModels bookkeeping and per-candidate `:thinking` resolution stay in
99
+ // one place (see runWithModelFallback in model.ts). Errors are thrown here;
100
+ // the caller maps them to its own response shape.
101
+ return runWithModelFallback<SubAgentResult>({
102
+ candidates: agentChain.candidates,
103
+ parentModel: options.ctx.model,
104
+ modelRegistry: options.ctx.modelRegistry,
105
+ thinkingByCandidate: agentChain.thinkingByCandidate,
106
+ defaultThinking: options.agent.thinking,
107
+ runAttempt: (model, thinkingLevel) =>
108
+ runSubAgent({
109
+ cwd: safeCwd.path,
110
+ sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
111
+ systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
112
+ task: options.task,
113
+ tools: toolValidation.tools,
114
+ model,
115
+ modelRuntime,
116
+ authStorage,
117
+ modelRegistry,
118
+ signal: options.signal,
119
+ timeoutMs: effectiveTimeoutMs,
120
+ agentName: options.agent.name,
121
+ thinkingLevel,
122
+ onMessage: options.onMessage,
123
+ onProgress: options.onProgress,
124
+ loadExtensions,
125
+ projectTrusted,
126
+ }),
127
+ isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
128
+ onExhausted: (reason, triedModels, remaining) => {
129
+ const tried = triedModels.join(" → ") || "(none)";
130
+ const parent = options.ctx.model ? `${options.ctx.model.provider}/${options.ctx.model.id}` : "none";
131
+ if (reason === "no-model") {
142
132
  throw new Error(
143
- `All available models exhausted. Tried: ${triedModels.join(" ")}.`,
133
+ `All models rate-limited or unavailable. Tried: ${tried}. ` +
134
+ `Remaining candidates: ${remaining.join(", ") || "none"}. ` +
135
+ `Parent: ${parent}.`,
144
136
  );
145
137
  }
146
- return tryWithFallback();
147
- }
148
- return result;
149
- };
150
-
151
- try {
152
- return await tryWithFallback();
153
- } finally {
154
- // No manual timeout handling needed — runSubAgent handles timeouts internally.
155
- }
138
+ throw new Error(`All available models exhausted. Tried: ${tried}.`);
139
+ },
140
+ });
156
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,8 @@
34
34
  "extensions/index.ts",
35
35
  "extensions/agents.ts",
36
36
  "extensions/model.ts",
37
+ "extensions/roles.ts",
38
+ "extensions/roles-panel.ts",
37
39
  "extensions/runner.ts",
38
40
  "extensions/service.ts",
39
41
  "extensions/render.ts",
@@ -66,6 +68,9 @@
66
68
  "@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
67
69
  "typebox": ">=1.3.0 <2.0.0"
68
70
  },
71
+ "dependencies": {
72
+ "@bacnh85/pi-config-panel": "^0.1.0"
73
+ },
69
74
  "devDependencies": {
70
75
  "@earendil-works/pi-agent-core": "^0.84.0",
71
76
  "@earendil-works/pi-ai": "^0.84.0",