@ferris1225/pi-subagents 1.0.1 → 2.0.1

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/format.ts CHANGED
@@ -73,33 +73,40 @@ export function formatUsage(usage: UsageStats): string {
73
73
  return parts.join(" ");
74
74
  }
75
75
 
76
- export function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
76
+ export interface CompletionFormatOptions {
77
+ /** Include individual failed-tool errors. Reserved for explicit status lookup. */
78
+ failedToolDetails?: boolean;
79
+ }
80
+
81
+ export function formatCompletionBlock(
82
+ result: SingleResult,
83
+ maxResultLines: number,
84
+ cwd?: string,
85
+ options: CompletionFormatOptions = {},
86
+ ): string {
77
87
  const failed = isFailedResult(result);
78
88
  const failedTools = result.failedTools ?? [];
79
89
  const status = failed
80
90
  ? "failed"
81
- : failedTools.length > 0
91
+ : options.failedToolDetails && failedTools.length > 0
82
92
  ? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
83
93
  : "completed";
84
94
  const usage = formatUsage(result.usage);
85
95
  const output = getResultOutput(result);
86
96
  const { text, truncated } = truncateResultOutput(output, maxResultLines);
87
97
  const fallbackNote = result.modelFallbackFrom
88
- ? ` (model pool fallback: primary ${result.modelFallbackFrom} → final ${result.model ?? "dynamic default"})`
98
+ ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
89
99
  : "";
90
100
  const startupRetryNote = result.startupRetries
91
101
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
92
102
  : "";
93
- const modelRetryNote = result.modelRetries
94
- ? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
95
- : "";
96
103
  const relations = [
97
104
  result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
98
105
  (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
99
106
  ].filter((value): value is string => Boolean(value));
100
107
  const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
101
108
  const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
102
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
109
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
103
110
  if (result.isolation === "worktree") {
104
111
  const isolation =
105
112
  result.integrationStatus === "integrated"
@@ -120,23 +127,22 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
120
127
  lines.push(`Relation: ${relations.join(" · ")}`, "");
121
128
  }
122
129
  lines.push(text);
123
- // A run can exit cleanly while its last tools failed (e.g. a build that broke):
124
- // the final text alone may claim more than the tools achieved, so surface the
125
- // failures explicitly and tell the main agent to verify before relying on it.
126
- if (!failed && failedTools.length > 0) {
127
- const shown = failedTools.slice(0, 3);
128
- const more = failedTools.length - shown.length;
130
+ // Explicit status always exposes every retained diagnostic, including when
131
+ // the overall run failed or was aborted. Automatic delivery adds only a
132
+ // compact pointer for otherwise-clean runs.
133
+ if (options.failedToolDetails && failedTools.length > 0) {
129
134
  lines.push(
130
135
  "",
131
- `⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"} failed during this run — the final text above may not reflect a working state:`,
132
- ...shown.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
136
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
137
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
133
138
  );
134
- if (more > 0) lines.push(`- … and ${more} more`);
135
- lines.push("Verify the actual artifacts before relying on this report.");
139
+ } else if (!failed && failedTools.length > 0) {
140
+ const lookup = result.runId !== undefined ? ` · details: subagent_status #${result.runId}` : "";
141
+ lines.push("", `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}${lookup}`);
136
142
  }
137
143
  if (truncated) {
138
144
  // The full text lives on disk so the main agent can read it on demand.
139
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
145
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
140
146
  }
141
147
  return lines.join("\n");
142
148
  }
@@ -148,15 +154,12 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
148
154
  * RESUME it in-context once a model is available, instead of re-dispatching
149
155
  * fresh (which would re-scan everything). */
150
156
  export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
151
- const sameModel = result.modelRetries
152
- ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
153
- : "";
154
- const retry = result.modelFallbackFrom ? ", and the configured backup chain also failed" : "";
157
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
155
158
  const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
156
159
  const recovery = sessionPreserved
157
160
  ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
158
161
  : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
159
- return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
162
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}.${recovery}`;
160
163
  }
161
164
 
162
165
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
68
68
  registerLookupTools(pi, runtime);
69
69
 
70
70
  pi.registerCommand("subagents-setup", {
71
- description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
71
+ description: "Configure pi-subagents: agents, selected models, capability-aware thinking, vision, and runtime settings",
72
72
  handler: async (_args, ctx) => {
73
73
  await runSetup(ctx, configPath);
74
74
  },
package/src/models.ts CHANGED
@@ -1,22 +1,26 @@
1
1
  /*
2
- * Model-pool resolution and setup-picker helpers.
2
+ * Model routing, capability-aware thinking, and setup-picker helpers.
3
3
  *
4
- * Runtime pools deliberately do not filter configured references by current
5
- * availability: a stale primary/backup is attempted and normal provider/model
6
- * failure handling advances to the next candidate. Setup uses the same catalog
7
- * only for honest availability labels; it never rewrites persisted choices.
4
+ * Runtime has one explicit fallback only: a configured agent/vision model hands
5
+ * off directly to the current main-window model. Setup lists only currently
6
+ * available models and derives thinking choices from Pi's model metadata.
8
7
  */
9
8
 
10
- import type { Api, Model } from "@earendil-works/pi-ai";
9
+ import {
10
+ clampThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ type Api,
13
+ type Model,
14
+ } from "@earendil-works/pi-ai";
11
15
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
12
17
 
13
18
  export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
14
19
  Partial<Pick<ExtensionContext, "scopedModels">>;
15
20
 
16
21
  export const CURRENT_MAIN_MODEL = "__current_main_model__";
17
22
 
18
- export type ModelPoolSlot = "primary" | "backup";
19
- export type ModelPickerSlot = ModelPoolSlot | "vision";
23
+ export type ModelPickerSlot = "agent" | "vision";
20
24
 
21
25
  export interface ModelPickerItem {
22
26
  value: string;
@@ -26,34 +30,26 @@ export interface ModelPickerItem {
26
30
 
27
31
  export type ModelListEntry = Pick<
28
32
  Model<Api>,
29
- "provider" | "id" | "name" | "input" | "reasoning"
33
+ "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
30
34
  >;
31
35
 
32
- export interface ResolvedAgentModelPool {
33
- /** Effective first candidate. Undefined means let pi use its normal default. */
36
+ export interface ResolvedAgentModelRoute {
37
+ /** Effective first candidate. Undefined means let Pi use its normal default. */
34
38
  primaryRef?: string;
35
- /** Ordered candidates after the primary, already deduplicated. */
36
- fallbackModelRefs: string[];
37
- /** All known references in runtime order, useful for tests/inspection. */
39
+ /** Current main-window model when it differs from the selection. */
40
+ mainFallbackRef?: string;
41
+ /** Runtime order, useful for status/tests. */
38
42
  candidateRefs: string[];
43
+ /** Configured ref skipped because Pi does not currently report it available. */
44
+ unavailableSelectedRef?: string;
39
45
  }
40
46
 
41
- export interface AgentModelPoolInput {
42
- primaryRef?: string;
43
- backupRef?: string;
47
+ export interface AgentModelRouteInput {
48
+ selectedRef?: string;
44
49
  mainRef?: string;
45
50
  declaredDefaultRef?: string;
46
- }
47
-
48
- export interface AgentModelPoolMaps {
49
- agentModels: Record<string, string>;
50
- agentBackupModels: Record<string, string>;
51
- }
52
-
53
- export interface AgentModelPoolRow {
54
- name: string;
55
- primary: string;
56
- backup: string;
51
+ /** When supplied, a configured selection outside this live set is skipped. */
52
+ availableRefs?: readonly string[];
57
53
  }
58
54
 
59
55
  function cleanModelRef(ref: string | undefined): string | undefined {
@@ -76,62 +72,84 @@ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | unde
76
72
  */
77
73
  export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
78
74
  const models = ctx.modelRegistry.getAvailable();
79
- // scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
80
- // missing field exactly like an empty scope and use the full live registry.
75
+ // scopedModels was added after the original Pi minimum. Treat a missing field
76
+ // exactly like an empty scope and use the full live registry.
81
77
  const scopedModels = ctx.scopedModels ?? [];
82
78
  if (scopedModels.length === 0) return models;
83
79
  const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
84
80
  return models.filter((model) => scopedRefs.has(modelRef(model)));
85
81
  }
86
82
 
83
+ export function findModelByRef(
84
+ models: readonly Model<Api>[],
85
+ ref: string | undefined,
86
+ ): Model<Api> | undefined {
87
+ const normalized = cleanModelRef(ref);
88
+ return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
89
+ }
90
+
87
91
  /**
88
- * Resolve one agent's ordered runtime pool:
92
+ * Resolve one agent's runtime route:
89
93
  *
90
- * configured primary -> configured backup -> current main-window model
94
+ * configured selection -> current main-window model
91
95
  *
92
- * Without a primary override, the current main model remains the primary; an
93
- * agent-declared default is used only when no main model exists. Equal refs are
94
- * removed without consulting availability, so stale refs stay in the chain and
95
- * fail normally at runtime instead of being silently repaired.
96
+ * Without an override, current main is primary; the agent-declared default is
97
+ * used only when no main model exists. A configured selection that Pi no longer
98
+ * reports as available is skipped immediately instead of spawning a doomed child.
96
99
  */
97
- export function resolveAgentModelPool(input: AgentModelPoolInput): ResolvedAgentModelPool {
100
+ export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
101
+ const selectedRef = cleanModelRef(input.selectedRef);
98
102
  const mainRef = cleanModelRef(input.mainRef);
99
- const primaryRef = cleanModelRef(input.primaryRef) ?? mainRef ?? cleanModelRef(input.declaredDefaultRef);
100
- const ordered = [primaryRef, cleanModelRef(input.backupRef), mainRef];
101
- const seen = new Set<string>();
102
- const candidateRefs: string[] = [];
103
- for (const ref of ordered) {
104
- if (!ref || seen.has(ref)) continue;
105
- seen.add(ref);
106
- candidateRefs.push(ref);
107
- }
108
- const fallbackModelRefs = candidateRefs.filter((ref) => ref !== primaryRef);
109
- return { primaryRef, fallbackModelRefs, candidateRefs };
103
+ const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
104
+ const available = input.availableRefs
105
+ ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
106
+ : undefined;
107
+ const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
108
+ const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
109
+ const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
110
+ const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
111
+ const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
112
+ return {
113
+ primaryRef,
114
+ ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
115
+ candidateRefs,
116
+ ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
117
+ };
118
+ }
119
+
120
+ /** The exact levels Pi exposes for this model, including `off` when supported. */
121
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
122
+ return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
123
+ }
124
+
125
+ /** Clamp an agent preference to the effective model's actual capability map. */
126
+ export function resolveThinkingLevel(
127
+ model: Model<Api> | undefined,
128
+ preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
129
+ ): ThinkingLevel {
130
+ return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
110
131
  }
111
132
 
112
133
  function modelCapabilities(model: ModelListEntry): string {
113
- const capabilities = [model.input.includes("image") ? "vision" : "text-only"];
114
- if (model.reasoning) capabilities.push("reasoning");
115
- return capabilities.join(" + ");
134
+ const input = model.input.includes("image") ? "vision" : "text-only";
135
+ const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
136
+ return `${input} · thinking: ${thinking}`;
116
137
  }
117
138
 
118
- /** Build the single searchable model list shared by primary/backup/vision picks.
119
- * Only refs Pi currently reports as available are shown, which means providers
120
- * without a configured API key/OAuth session never flood the setup picker. */
139
+ /** Build one searchable list for agent or vision selection. Only models Pi
140
+ * currently reports as available are supplied by setup. */
121
141
  export function buildModelPickerItems(options: {
122
142
  models: readonly ModelListEntry[];
123
- availableRefs: readonly string[];
124
143
  slot: ModelPickerSlot;
125
144
  configuredRef?: string;
126
145
  mainRef?: string;
127
146
  }): ModelPickerItem[] {
128
147
  const configuredRef = cleanModelRef(options.configuredRef);
129
148
  const mainRef = cleanModelRef(options.mainRef);
130
- const available = new Set(options.availableRefs.map((ref) => ref.trim()));
131
149
  const byRef = new Map<string, ModelListEntry>();
132
150
  for (const model of options.models) {
133
151
  const ref = modelRef(model);
134
- if (available.has(ref) && !byRef.has(ref)) byRef.set(ref, model);
152
+ if (!byRef.has(ref)) byRef.set(ref, model);
135
153
  }
136
154
 
137
155
  const refs = [...byRef.keys()]
@@ -142,26 +160,18 @@ export function buildModelPickerItems(options: {
142
160
  return leftRank - rightRank || left.localeCompare(right);
143
161
  });
144
162
 
145
- const dynamic = options.slot === "backup"
146
- ? {
147
- value: CURRENT_MAIN_MODEL,
148
- label: "Current main model (default)",
149
- description: "Clear configured backup; use the main-window model dynamically",
150
- }
151
- : {
152
- value: CURRENT_MAIN_MODEL,
153
- label: "Current main model (dynamic)",
154
- description: options.slot === "vision"
155
- ? "Clear vision override; use the main-window model for vision tasks"
156
- : "Clear primary override; use the main-window model dynamically",
157
- };
158
-
163
+ const dynamic: ModelPickerItem = {
164
+ value: CURRENT_MAIN_MODEL,
165
+ label: "Current main model (dynamic)",
166
+ description: options.slot === "vision"
167
+ ? "Clear vision override; use the current main model for image tasks"
168
+ : "Clear agent override; use the current main model dynamically",
169
+ };
159
170
  const items: ModelPickerItem[] = [dynamic];
160
171
  for (const ref of refs) {
161
172
  const model = byRef.get(ref)!;
162
- const tags = ["available"];
163
- if (ref === configuredRef) tags.push("configured");
164
- if (ref === mainRef) tags.push("current main");
173
+ const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
174
+ .filter(Boolean);
165
175
  const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
166
176
  items.push({
167
177
  value: ref,
@@ -172,31 +182,14 @@ export function buildModelPickerItems(options: {
172
182
  return items;
173
183
  }
174
184
 
175
- /** Pure pool update: the dynamic/default choice removes the persisted override. */
176
- export function applyModelPoolChoice(
177
- current: AgentModelPoolMaps,
185
+ /** The dynamic choice removes the persisted per-agent override. */
186
+ export function applyAgentModelChoice(
187
+ current: Record<string, string>,
178
188
  agentName: string,
179
- slot: ModelPoolSlot,
180
189
  choice: string,
181
- ): AgentModelPoolMaps {
182
- const next: AgentModelPoolMaps = {
183
- agentModels: { ...current.agentModels },
184
- agentBackupModels: { ...current.agentBackupModels },
185
- };
186
- const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
187
- if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
188
- else target[agentName] = choice.trim();
190
+ ): Record<string, string> {
191
+ const next = { ...current };
192
+ if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
193
+ else next[agentName] = choice.trim();
189
194
  return next;
190
195
  }
191
-
192
- /** Pure rows used by the overview component and focused helper tests. */
193
- export function buildAgentModelPoolRows(
194
- agentNames: readonly string[],
195
- pools: AgentModelPoolMaps,
196
- ): AgentModelPoolRow[] {
197
- return agentNames.map((name) => ({
198
- name,
199
- primary: pools.agentModels[name] ?? "Main (dynamic)",
200
- backup: pools.agentBackupModels[name] ?? "Main (default)",
201
- }));
202
- }
package/src/monitor.ts CHANGED
@@ -33,7 +33,7 @@ export interface RunView {
33
33
  * are doing, not just their run id. */
34
34
  label?: string;
35
35
  model?: string;
36
- /** Primary model ref when the run advanced to another candidate in its pool. */
36
+ /** Selected model ref when the run handed off to current main. */
37
37
  modelFallbackFrom?: string;
38
38
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
39
39
  thinking?: string;
@@ -406,7 +406,7 @@ export class MonitorStore {
406
406
  run.status = status;
407
407
  if (status === "running" || status === "steering" || status === "interrupting") {
408
408
  if (run.startedAt === undefined) run.startedAt = Date.now();
409
- // A model-fallback retry or resumed generation restarts the clock; a
409
+ // A selected-to-main handoff or resumed generation restarts the clock; a
410
410
  // stale endedAt would freeze the elapsed display at the first attempt.
411
411
  if (run.endedAt !== undefined) run.endedAt = undefined;
412
412
  } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
@@ -422,7 +422,7 @@ export class MonitorStore {
422
422
  this.notify();
423
423
  }
424
424
 
425
- /** Record the final actual model and primary-to-backup transition. */
425
+ /** Record the final actual model and selected-to-main transition. */
426
426
  setModel(id: number, model?: string, fallbackFrom?: string): void {
427
427
  const run = this.find(id);
428
428
  if (!run) return;
@@ -431,6 +431,14 @@ export class MonitorStore {
431
431
  this.notify();
432
432
  }
433
433
 
434
+ /** Record the capability-clamped thinking level for the active model. */
435
+ setThinking(id: number, thinking?: string): void {
436
+ const run = this.find(id);
437
+ if (!run || !thinking) return;
438
+ run.thinking = thinking;
439
+ this.notify();
440
+ }
441
+
434
442
  /** Update the run's current one-line activity (what it is doing now). */
435
443
  setActivity(id: number, text: string): void {
436
444
  const run = this.find(id);
package/src/prompt.ts CHANGED
@@ -16,8 +16,9 @@ import { formatCatalogEntry } from "./agents.ts";
16
16
 
17
17
  /** Compact role routing hints, emitted only for roles that are enabled. */
18
18
  const ROLE_ROUTING: Record<string, string> = {
19
- explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only, cheap fast model); NOT for one-line lookups.",
19
+ explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only, competent fast model); NOT for one-line lookups.",
20
20
  worker: "worker — implement/fix/refactor/test a self-contained task worth a separate context (full tools; plans internally).",
21
+ cleaner: "cleaner — evidence-first cleanup for explicit cleanup intent in any language (for example dead code, redundancy, simplification, or over-engineering) or a requested periodic cleanup pass; audit/find/inspect/report is read-only, while explicit remove/clean/simplify/refactor wording permits verified edits; never PR-count or pre-commit driven (reviewer remains the gate).",
21
22
  reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
22
23
  };
23
24
 
@@ -30,6 +31,8 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
30
31
  .filter((line): line is string => Boolean(line))
31
32
  .map((line) => `- ${line}`)
32
33
  .join("\n");
34
+ const hasExplore = agents.some((a) => a.name === "explore");
35
+ const hasCleaner = agents.some((a) => a.name === "cleaner");
33
36
  const hasReviewer = agents.some((a) => a.name === "reviewer");
34
37
  const hasMultiple = agents.length > 1;
35
38
 
@@ -54,18 +57,18 @@ ${catalog}
54
57
 
55
58
  ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
56
59
  - Handle SIMPLE work INLINE with direct tools: a one-line lookup, single edit, or quick question is a grep/read/edit in the main context — never a sub-agent. Sub-agents cost startup time, tokens, and a context switch.
57
- - Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It runs on a fast cheap model and returns compressed findings, so delegating recon costs little.
58
- - Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker), or a fresh-context review gate (reviewer).
59
- - When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
60
+ - Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It should run on a competent fast code model and returns compressed findings, saving main-context space.
61
+ - Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker)${hasCleaner ? ", explicit evidence-first cleanup (cleaner)" : ""}, or a fresh-context review gate (reviewer).
62
+ ${hasCleaner ? "- Route explicit cleanup intent in any language to `cleaner` (for example dead code, redundancy, simplification, or over-engineering), including a requested periodic maintenance pass. Audit/find/inspect/report wording means read-only evidence; apply only for explicit remove/clean/simplify/refactor wording. Generic code review without cleanup intent goes to `reviewer`. Never dispatch cleaner by PR count or automatically as the pre-commit gate; `reviewer` separately reviews cleaner edits.\n" : ""}- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
60
63
  - For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
61
- ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass `isolation: \"shared\"` only when a worker intentionally needs the caller's live uncommitted tree. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for worker/write-capable agents in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
64
+ ${hasMultiple ? `- Run INDEPENDENT tasks in parallel: one subagent call with a \`tasks\` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass \`isolation: "shared"\` only when a worker intentionally needs the caller's live uncommitted tree.${hasCleaner ? " Cleaner is also write-capable and may use explicit worktree isolation." : ""} Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then ${hasCleaner ? "worker/cleaner" : "worker"}, then reviewer).\n` : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for ${hasCleaner ? "worker, cleaner, or another" : "worker or another"} write-capable agent in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
62
65
  - Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
63
66
  - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool. Use \`subagent_control fork\` on a parked/settled retained thread when you need an independent continuation with preserved context and a new run id.
64
67
  - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
65
-
68
+ ${hasExplore ? "- Treat `explore` findings as a retrieval index, never as sole proof for edits, deletion, security, compatibility, persistence, or dynamic reachability. Re-read load-bearing files before acting. An underpowered model can be false economy on complex dynamic, concurrent, migration, or security-sensitive code; use a stronger model or specialist there.\n" : ""}
66
69
  Vision tasks:
67
70
  - Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
68
- - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
71
+ - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast — a non-vision model cannot see the images.
69
72
 
70
73
  Result handoff (do not re-state):
71
74
  - A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
@@ -75,5 +78,7 @@ Result handoff (do not re-state):
75
78
 
76
79
  Review & verification:
77
80
  - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
78
- ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
81
+ ${hasReviewer ? `- For non-trivial diffs${hasCleaner ? " (including cleaner edits)" : ""}, run one fresh read-only \`reviewer\` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.
82
+ - Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.
83
+ ` : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
79
84
  }
package/src/rpc-run.ts CHANGED
@@ -52,18 +52,23 @@ export interface RpcSingleResult {
52
52
  thinking?: string;
53
53
  stopReason?: string;
54
54
  errorMessage?: string;
55
- /** Primary model when this result came from a later candidate in its configured pool. */
55
+ /** Selected model when this result handed off to the current main model. */
56
56
  modelFallbackFrom?: string;
57
57
  dispatchFailed?: boolean;
58
58
  /** An accepted generation failed because an RPC prompt was rejected before
59
- * model execution. This remains model-candidate retry/fallback eligible even
60
- * when an earlier, aborted objective left assistant text in the session. */
59
+ * model execution. This remains main-model handoff eligible even when an
60
+ * earlier, aborted objective left assistant text in the session. */
61
61
  rpcPromptRejected?: boolean;
62
+ /** The child accepted a prompt; startup retries must never duplicate it. */
63
+ rpcPromptAccepted?: boolean;
64
+ /** Pi emitted agent/turn/model/tool activity for this attempt. */
65
+ rpcActivity?: boolean;
62
66
  startupRetries?: number;
63
- modelRetries?: number;
64
67
  failedTools?: Array<{ toolName: string; error: string }>;
65
68
  sessionId?: string;
66
69
  sessionDir?: string;
70
+ /** Original task/project cwd used for result-artifact retention buckets. */
71
+ projectCwd?: string;
67
72
  /** Internal disposition: dispatch suppresses completion delivery for parks. */
68
73
  parked?: boolean;
69
74
  /** Stable logical run id assigned by dispatch (also present on queued results). */
@@ -84,7 +89,7 @@ export interface RpcSingleResult {
84
89
 
85
90
  export type SubagentLiveEvent =
86
91
  | { kind: "status"; status: "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed" }
87
- | { kind: "model"; model?: string; fallbackFrom?: string }
92
+ | { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
88
93
  | { kind: "usage"; usage: UsageStats; model?: string }
89
94
  | { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
90
95
  | { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
@@ -110,7 +115,7 @@ interface AttemptControl {
110
115
  }
111
116
 
112
117
  /**
113
- * Stable control surface for a logical run generation. Retry/fallback attempts
118
+ * Stable control surface for a logical run generation. Startup/main-handoff attempts
114
119
  * attach and detach beneath it, so callers never retain a stale child handle.
115
120
  * Control calls are serialized to prevent overlapping abort/settle/prompt flows.
116
121
  */
@@ -338,6 +343,46 @@ export function extractToolErrorText(content: unknown): string {
338
343
  .join("\n");
339
344
  }
340
345
 
346
+ interface ChildRetryPolicyExtension {
347
+ dir: string;
348
+ filePath: string;
349
+ }
350
+
351
+ /** Build a child-only Pi extension that replaces the selected provider's
352
+ * stream adapter with its registered API implementation while forcing
353
+ * maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
354
+ * it works in Node and standalone/Bun builds without touching user settings. */
355
+ export async function writeChildRetryPolicyExtension(
356
+ modelRef?: string,
357
+ ): Promise<ChildRetryPolicyExtension> {
358
+ const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
359
+ const filePath = join(dir, "no-provider-retries.mjs");
360
+ const slash = modelRef?.indexOf("/") ?? -1;
361
+ const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
362
+ const source = `import { getApiProvider } from "@earendil-works/pi-ai/compat";\n`
363
+ + `const selectedProvider = ${JSON.stringify(selectedProvider)};\n`
364
+ + `export default function noProviderRetries(pi) {\n`
365
+ + ` pi.on("before_provider_request", (_event, ctx) => {\n`
366
+ + ` const providerId = ctx.model?.provider ?? selectedProvider;\n`
367
+ + ` if (!providerId) return;\n`
368
+ + ` pi.registerProvider(providerId, {\n`
369
+ + ` streamSimple(model, context, options) {\n`
370
+ + ` const api = getApiProvider(model.api);\n`
371
+ + ` if (!api) throw new Error(\`No API stream implementation is registered for \${model.api}.\`);\n`
372
+ + ` return api.streamSimple(model, context, { ...options, maxRetries: 0 });\n`
373
+ + ` },\n`
374
+ + ` });\n`
375
+ + ` });\n`
376
+ + `}\n`;
377
+ try {
378
+ await writeFile(filePath, source, "utf8");
379
+ return { dir, filePath };
380
+ } catch (error) {
381
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
382
+ throw error;
383
+ }
384
+ }
385
+
341
386
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
342
387
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
343
388
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
@@ -422,6 +467,15 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
422
467
  args.push("--append-system-prompt", tmpPromptPath);
423
468
  }
424
469
 
470
+ let retryPolicy: ChildRetryPolicyExtension;
471
+ try {
472
+ retryPolicy = await writeChildRetryPolicyExtension(agent.model);
473
+ args.push("--extension", retryPolicy.filePath);
474
+ } catch (error) {
475
+ if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
476
+ throw error;
477
+ }
478
+
425
479
  const result: RpcSingleResult = {
426
480
  agent: agentName,
427
481
  task,
@@ -512,6 +566,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
512
566
  const resolveInitialPrompt = (accepted: boolean, error?: Error): void => {
513
567
  if (initialPromptResolved) return;
514
568
  initialPromptResolved = true;
569
+ if (accepted) result.rpcPromptAccepted = true;
515
570
  initialPrompt.resolve({ accepted, error });
516
571
  };
517
572
 
@@ -738,6 +793,33 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
738
793
  }
739
794
  if (finished) return;
740
795
 
796
+ if (
797
+ [
798
+ "agent_start",
799
+ "agent_end",
800
+ "turn_start",
801
+ "turn_end",
802
+ "message_start",
803
+ "message_update",
804
+ "message_end",
805
+ "tool_execution_start",
806
+ "tool_execution_update",
807
+ "tool_execution_end",
808
+ "auto_retry_start",
809
+ "auto_retry_end",
810
+ "agent_settled",
811
+ ].includes(event.type)
812
+ ) {
813
+ result.rpcActivity = true;
814
+ }
815
+
816
+ // The child provider adapter disables request-level retries. Cancel Pi's
817
+ // separate outer turn retry the instant it is scheduled, before another
818
+ // same-model provider call can begin.
819
+ if (event.type === "auto_retry_start") {
820
+ void send({ type: "abort_retry" }).catch(() => undefined);
821
+ }
822
+
741
823
  // Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
742
824
  // cancel blocking dialogs so an unrelated child extension cannot deadlock.
743
825
  if (
@@ -973,5 +1055,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
973
1055
  /* ignore */
974
1056
  }
975
1057
  }
1058
+ await rm(retryPolicy.dir, { recursive: true, force: true }).catch(() => undefined);
976
1059
  }
977
1060
  }