@bacnh85/pi-subagent 0.15.2 → 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.
@@ -65,10 +65,57 @@ export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
65
65
  export const EXECUTION_TOOLS: readonly string[] = ["bash"];
66
66
 
67
67
  /**
68
- * Default inactivity timeout. Real SDK lifecycle activity resets this window;
69
- * the runner separately enforces a fixed 20-minute absolute cap.
68
+ * Generic warning sink for configuration problems. Stderr so it is visible
69
+ * with or without a TUI attached. Warnings are also buffered so the extension
70
+ * host can surface them as TUI notifications — the interactive TUI swallows
71
+ * module-load stderr, so module-load warnings alone are invisible in-TUI.
70
72
  */
71
- export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
73
+ const warnings: string[] = [];
74
+ function warn(message: string): void {
75
+ process.stderr.write(`[pi-subagent] ${message}\n`);
76
+ warnings.push(message);
77
+ }
78
+
79
+ /**
80
+ * Flush env-var config warnings collected at module load. Called once from
81
+ * the /subagent tool handler so the warnings surface as TUI notifications
82
+ * (module-load stderr is not visible inside the interactive TUI).
83
+ */
84
+ export function flushWarnings(): string[] {
85
+ return warnings.splice(0, warnings.length);
86
+ }
87
+
88
+ /**
89
+ * Read a numeric value from an env var, with validation:
90
+ * - undefined/empty -> defaultValue (no warning)
91
+ * - not a finite number (NaN) -> defaultValue + warning
92
+ * - below `min` -> `min` + warning
93
+ * - above `max` -> `max` + warning
94
+ * - otherwise -> the parsed value (no warning)
95
+ */
96
+ export function getNumericEnvVar(
97
+ envVar: string,
98
+ defaultValue: number,
99
+ min: number,
100
+ max: number,
101
+ ): number {
102
+ const raw = process.env[envVar];
103
+ if (raw === undefined || raw === "") return defaultValue;
104
+ const value = Number(raw);
105
+ if (!Number.isFinite(value)) {
106
+ warn(`ENV VAR: ${envVar}="${raw}" is not a number; using default ${defaultValue}.`);
107
+ return defaultValue;
108
+ }
109
+ if (value < min) {
110
+ warn(`ENV VAR: ${envVar}=${value} is below the minimum of ${min}; using ${min}.`);
111
+ return min;
112
+ }
113
+ if (value > max) {
114
+ warn(`ENV VAR: ${envVar}=${value} is above the maximum of ${max}; using ${max}.`);
115
+ return max;
116
+ }
117
+ return value;
118
+ }
72
119
 
73
120
  /**
74
121
  * Absolute maximum timeout. Any requested value above this cap is rejected
@@ -76,6 +123,35 @@ export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
76
123
  */
77
124
  export const MAX_TIMEOUT_MS = 60 * 60 * 1_000; // 60 minutes
78
125
 
126
+ /**
127
+ * Timeout caps, in minutes, shared by getNumericEnvVar for both env vars.
128
+ * No timeout may be shorter than 1 minute; the inactivity window may not
129
+ * exceed the 60-minute absolute maximum.
130
+ */
131
+ const MIN_TIMEOUT_MINS = 1;
132
+ const MAX_TIMEOUT_MINS = MAX_TIMEOUT_MS / 60_000;
133
+
134
+ /**
135
+ * Default inactivity timeout. Real SDK lifecycle activity resets this window;
136
+ * the runner enforces a fixed absolute cap (HARD_TIMEOUT_MS).
137
+ *
138
+ * Reads PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS (default 3 min, range 1–60) and
139
+ * PI_SUBAGENT_HARD_TIMEOUT_MINS (default 20 min, range 1–60) from env via
140
+ * getNumericEnvVar, which warns on invalid/out-of-range values. The hard cap
141
+ * is additionally clamped up to the inactivity window: a lifetime cap smaller
142
+ * than the idle window is nonsensical.
143
+ */
144
+ const INACTIVITY_TIMEOUT_MINS = getNumericEnvVar("PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS", 3, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
145
+ let hardTimeoutMins = getNumericEnvVar("PI_SUBAGENT_HARD_TIMEOUT_MINS", 20, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
146
+ if (hardTimeoutMins < INACTIVITY_TIMEOUT_MINS) {
147
+ warn(
148
+ `PI_SUBAGENT_HARD_TIMEOUT_MINS (${hardTimeoutMins}) is below the inactivity window (${INACTIVITY_TIMEOUT_MINS}); raising hard cap to ${INACTIVITY_TIMEOUT_MINS}.`,
149
+ );
150
+ hardTimeoutMins = INACTIVITY_TIMEOUT_MINS;
151
+ }
152
+ export const DEFAULT_TIMEOUT_MS = INACTIVITY_TIMEOUT_MINS * 60 * 1_000;
153
+ export const HARD_TIMEOUT_MS = hardTimeoutMins * 60 * 1_000;
154
+
79
155
  // ---------------------------------------------------------------------------
80
156
  // Canonical result status
81
157
  // ---------------------------------------------------------------------------
@@ -489,6 +565,14 @@ export function validateExecutionRequest(
489
565
  ): ValidationError[] {
490
566
  const errors: ValidationError[] = [];
491
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
+
492
576
  // Agent name
493
577
  if (options.agentName !== undefined) {
494
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.2",
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",
@@ -73,9 +78,15 @@
73
78
  "@earendil-works/pi-tui": "^0.84.0",
74
79
  "@types/mocha": "^10.0.10",
75
80
  "@types/node": "^20.19.43",
76
- "mocha": "^10.8.2",
81
+ "mocha": "^11.8.0",
77
82
  "tsx": "^4.22.4",
78
83
  "typescript": "^5.9.3",
79
84
  "typebox": "^1.3.1"
85
+ },
86
+ "overrides": {
87
+ "serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",
88
+ "js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
89
+ "brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
90
+ "diff": "^8.0.3"
80
91
  }
81
92
  }