@ferris1225/pi-subagents 0.31.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fixloop.ts CHANGED
@@ -87,7 +87,7 @@ export function chainKeyFragments(result: SingleResult): string[] {
87
87
  }
88
88
 
89
89
  /**
90
- * Compact one-line outcome for a finished chain run, shown in the widget so
90
+ * Compact one-line outcome for a finished chain run, retained so
91
91
  * each round reads as what it did: a reviewer reports its verdict plus the
92
92
  * key fragments of what it found ("fail · src/index.ts · render()"), a worker
93
93
  * the fragments of what it changed. Failed runs and runs with nothing
package/src/format.ts CHANGED
@@ -90,7 +90,7 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
90
90
  const output = getResultOutput(result);
91
91
  const { text, truncated } = truncateResultOutput(output, maxResultLines);
92
92
  const fallbackNote = result.modelFallbackFrom
93
- ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
93
+ ? ` (model pool fallback: primary ${result.modelFallbackFrom} final ${result.model ?? "dynamic default"})`
94
94
  : "";
95
95
  const startupRetryNote = result.startupRetries
96
96
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
@@ -98,7 +98,33 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
98
98
  const modelRetryNote = result.modelRetries
99
99
  ? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
100
100
  : "";
101
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
101
+ const relations = [
102
+ result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
103
+ (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
104
+ ].filter((value): value is string => Boolean(value));
105
+ const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
106
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
107
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
108
+ if (result.isolation === "worktree") {
109
+ const isolation =
110
+ result.integrationStatus === "integrated"
111
+ ? "worktree · changes integrated into the original working tree"
112
+ : result.integrationStatus === "no_changes"
113
+ ? "worktree · no changes; temporary worktree removed"
114
+ : result.integrationStatus === "retained"
115
+ ? result.integrationApplied
116
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
117
+ : "worktree · integration failed; recovery artifacts retained"
118
+ : "worktree · isolated";
119
+ lines.push(`Isolation: ${isolation}${relationNote}`);
120
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
121
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
122
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
123
+ lines.push("");
124
+ } else if (relations.length > 0) {
125
+ lines.push(`Relation: ${relations.join(" · ")}`, "");
126
+ }
127
+ lines.push(text);
102
128
  // A run can exit cleanly while its last tools failed (e.g. a build that broke):
103
129
  // the final text alone may claim more than the tools achieved, so surface the
104
130
  // failures explicitly and tell the main agent to verify before relying on it.
@@ -130,10 +156,10 @@ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: nu
130
156
  const sameModel = result.modelRetries
131
157
  ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
132
158
  : "";
133
- const retry = result.modelFallbackFrom ? ", and the resume on the main-window model also failed" : "";
159
+ const retry = result.modelFallbackFrom ? ", and the configured backup chain also failed" : "";
134
160
  const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
135
161
  const recovery = sessionPreserved
136
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent with { resume: ${opts!.runId} } to CONTINUE it in-context (it picks up where it stopped no re-scan), or execute the task in the main window with your own tools.`
162
+ ? ` 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.`
137
163
  : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
138
164
  return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
139
165
  }
package/src/index.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Assembly point: builds the shared runtime and registers everything.
5
5
  * The heavy lifting lives in focused modules:
6
6
  * - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
7
- * - tools.ts — subagent_wait / subagent_status / subagent_stop
8
- * - widget.ts session_start widget + one-time feature announcements
9
- * - runtime.ts — shared per-session state
7
+ * - tools.ts — subagent_control / subagent_wait / status / stop
8
+ * - announcements.ts session-start recovery and feature notices
9
+ * - runtime.ts — shared per-session state
10
10
  *
11
11
  * Also registers the `/subagents-setup` command and a `before_agent_start` hook
12
12
  * that injects a delegation directive into the parent system prompt so the main
@@ -19,6 +19,7 @@
19
19
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { Text } from "@earendil-works/pi-tui";
21
21
  import { discoverAgents } from "./agents.ts";
22
+ import { registerAnnouncements } from "./announcements.ts";
22
23
  import { getConfigPath, loadConfig } from "./config.ts";
23
24
  import { registerSubagentTool } from "./dispatch.ts";
24
25
  import { matchRunIds } from "./format.ts";
@@ -27,7 +28,6 @@ import { createRuntime } from "./runtime.ts";
27
28
  import { runSetup } from "./setup.ts";
28
29
  import { currentSubagentDepth } from "./spawn.ts";
29
30
  import { registerLookupTools } from "./tools.ts";
30
- import { registerWidget } from "./widget.ts";
31
31
 
32
32
  export { matchRunIds };
33
33
 
@@ -57,23 +57,21 @@ export default function (pi: ExtensionAPI): void {
57
57
  ),
58
58
  );
59
59
 
60
- pi.on("session_shutdown", () => {
61
- runtime.shutdown();
60
+ pi.on("session_shutdown", async () => {
61
+ await runtime.shutdown();
62
62
  });
63
63
 
64
64
  registerSubagentTool(pi, runtime);
65
65
  registerLookupTools(pi, runtime);
66
66
 
67
67
  pi.registerCommand("subagents-setup", {
68
- description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
68
+ description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
69
69
  handler: async (_args, ctx) => {
70
70
  await runSetup(ctx, configPath);
71
71
  },
72
72
  });
73
73
 
74
- // Persistent widget above the editor showing live sub-agent status, plus
75
- // one-time feature announcements after updates.
76
- registerWidget(pi, runtime);
74
+ registerAnnouncements(pi, runtime);
77
75
 
78
76
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
79
77
  pi.on("before_agent_start", async (event, ctx) => {
@@ -82,6 +80,7 @@ export default function (pi: ExtensionAPI): void {
82
80
  const { agents } = discoverAgents(ctx.cwd, {
83
81
  scope: config.agentScope,
84
82
  enabledNames: config.enabledAgents,
83
+ projectTrusted: ctx.isProjectTrusted?.() === true,
85
84
  });
86
85
  const directive = buildDelegationDirective(agents);
87
86
  if (!directive) return undefined;
package/src/models.ts CHANGED
@@ -1,82 +1,212 @@
1
- /**
2
- * Model availability helpers shared by setup and sub-agent execution.
1
+ /*
2
+ * Model-pool resolution and setup-picker helpers.
3
3
  *
4
- * A configured override can outlive a provider login, a scoped-model change, or
5
- * a model rename. Keep the main session usable by replacing such overrides with
6
- * the model currently selected in the main window (or the first available model)
7
- * and let callers persist the repaired mapping.
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.
8
8
  */
9
9
 
10
+ import type { Api, Model } from "@earendil-works/pi-ai";
10
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
12
 
12
- export type ModelContext = Pick<ExtensionContext, "model" | "scopedModels" | "modelRegistry">;
13
+ export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
14
+ Partial<Pick<ExtensionContext, "scopedModels">>;
15
+
16
+ export const CURRENT_MAIN_MODEL = "__current_main_model__";
17
+
18
+ export type ModelPoolSlot = "primary" | "backup";
19
+ export type ModelPickerSlot = ModelPoolSlot | "vision";
20
+
21
+ export interface ModelPickerItem {
22
+ value: string;
23
+ label: string;
24
+ description?: string;
25
+ /** Visible for diagnosis/search, but cannot be selected. */
26
+ disabled?: boolean;
27
+ }
28
+
29
+ export type ModelListEntry = Pick<
30
+ Model<Api>,
31
+ "provider" | "id" | "name" | "input" | "reasoning"
32
+ >;
33
+
34
+ export interface ResolvedAgentModelPool {
35
+ /** Effective first candidate. Undefined means let pi use its normal default. */
36
+ primaryRef?: string;
37
+ /** Ordered candidates after the primary, already deduplicated. */
38
+ fallbackModelRefs: string[];
39
+ /** All known references in runtime order, useful for tests/inspection. */
40
+ candidateRefs: string[];
41
+ }
42
+
43
+ export interface AgentModelPoolInput {
44
+ primaryRef?: string;
45
+ backupRef?: string;
46
+ mainRef?: string;
47
+ declaredDefaultRef?: string;
48
+ }
13
49
 
14
- export interface ModelOverrideRepair {
50
+ export interface AgentModelPoolMaps {
15
51
  agentModels: Record<string, string>;
16
- changed: boolean;
17
- replaced: number;
18
- removed: number;
19
- fallbackRef?: string;
52
+ agentBackupModels: Record<string, string>;
53
+ }
54
+
55
+ export interface AgentModelPoolRow {
56
+ name: string;
57
+ primary: string;
58
+ backup: string;
59
+ }
60
+
61
+ function cleanModelRef(ref: string | undefined): string | undefined {
62
+ const trimmed = ref?.trim();
63
+ return trimmed || undefined;
20
64
  }
21
65
 
22
66
  export function modelRef(model: { provider: string; id: string }): string {
23
67
  return `${model.provider}/${model.id}`;
24
68
  }
25
69
 
70
+ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
71
+ return ctx.model ? modelRef(ctx.model) : undefined;
72
+ }
73
+
26
74
  /**
27
- * Return model references usable by the current main window, with its current
28
- * model first so it is the deterministic fallback for stale configuration.
75
+ * Current authenticated registry models narrowed by the session scope. Scope
76
+ * entries are a session snapshot, so they act only as a whitelist; the live
77
+ * registry remains the source of truth for availability and model metadata.
29
78
  */
79
+ export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
80
+ const models = ctx.modelRegistry.getAvailable();
81
+ // scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
82
+ // missing field exactly like an empty scope and use the full live registry.
83
+ const scopedModels = ctx.scopedModels ?? [];
84
+ if (scopedModels.length === 0) return models;
85
+ const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
86
+ return models.filter((model) => scopedRefs.has(modelRef(model)));
87
+ }
88
+
89
+ /** Model refs usable by setup, with an available current main model first. */
30
90
  export function availableModelRefs(ctx: ModelContext): string[] {
31
- const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
32
- const models = scoped ?? ctx.modelRegistry.getAvailable();
33
- const refs = [...new Set(models.map(modelRef))];
34
- const currentRef = ctx.model ? modelRef(ctx.model) : undefined;
35
- if (!currentRef) return refs;
91
+ const refs = [...new Set(availableModelsInScope(ctx).map(modelRef))];
92
+ const currentRef = currentModelRef(ctx);
93
+ if (!currentRef || !refs.includes(currentRef)) return refs;
36
94
  return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
37
95
  }
38
96
 
39
97
  /**
40
- * Resolve the model for a `vision: true` dispatch. The configured vision model
41
- * wins when it is usable by the current session; otherwise the task falls back
42
- * to the main window's current model (the documented behavior when the vision
43
- * model is unset). Returns undefined only when neither exists — callers then
44
- * keep the agent's own model as the last resort.
98
+ * Resolve one agent's ordered runtime pool:
99
+ *
100
+ * configured primary -> configured backup -> current main-window model
101
+ *
102
+ * Without a primary override, the current main model remains the primary; an
103
+ * agent-declared default is used only when no main model exists. Equal refs are
104
+ * removed without consulting availability, so stale refs stay in the chain and
105
+ * fail normally at runtime instead of being silently repaired.
45
106
  */
46
- export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
47
- const configured = visionModel?.trim();
48
- if (configured && availableModelRefs(ctx).includes(configured)) return configured;
49
- return ctx.model ? modelRef(ctx.model) : undefined;
107
+ export function resolveAgentModelPool(input: AgentModelPoolInput): ResolvedAgentModelPool {
108
+ const mainRef = cleanModelRef(input.mainRef);
109
+ const primaryRef = cleanModelRef(input.primaryRef) ?? mainRef ?? cleanModelRef(input.declaredDefaultRef);
110
+ const ordered = [primaryRef, cleanModelRef(input.backupRef), mainRef];
111
+ const seen = new Set<string>();
112
+ const candidateRefs: string[] = [];
113
+ for (const ref of ordered) {
114
+ if (!ref || seen.has(ref)) continue;
115
+ seen.add(ref);
116
+ candidateRefs.push(ref);
117
+ }
118
+ const fallbackModelRefs = candidateRefs.filter((ref) => ref !== primaryRef);
119
+ return { primaryRef, fallbackModelRefs, candidateRefs };
50
120
  }
51
121
 
52
- /** Replace unavailable persisted overrides with a model usable by the main session. */
53
- export function repairUnavailableModelOverrides(
54
- ctx: ModelContext,
55
- agentModels: Record<string, string>,
56
- ): ModelOverrideRepair {
57
- const refs = availableModelRefs(ctx);
58
- const available = new Set(refs);
59
- const fallbackRef = refs[0];
60
- const repaired: Record<string, string> = {};
61
- let changed = false;
62
- let replaced = 0;
63
- let removed = 0;
64
-
65
- for (const [name, configuredRef] of Object.entries(agentModels)) {
66
- const ref = configuredRef.trim();
67
- if (available.has(ref)) {
68
- repaired[name] = ref;
69
- continue;
70
- }
122
+ function modelCapabilities(model: ModelListEntry): string {
123
+ const capabilities = [model.input.includes("image") ? "vision" : "text-only"];
124
+ if (model.reasoning) capabilities.push("reasoning");
125
+ return capabilities.join(" + ");
126
+ }
127
+
128
+ /** Build the single searchable model list shared by primary/backup/vision picks.
129
+ * Only refs Pi currently reports as available are shown, which means providers
130
+ * without a configured API key/OAuth session never flood the setup picker. */
131
+ export function buildModelPickerItems(options: {
132
+ models: readonly ModelListEntry[];
133
+ availableRefs: readonly string[];
134
+ slot: ModelPickerSlot;
135
+ configuredRef?: string;
136
+ mainRef?: string;
137
+ }): ModelPickerItem[] {
138
+ const configuredRef = cleanModelRef(options.configuredRef);
139
+ const mainRef = cleanModelRef(options.mainRef);
140
+ const available = new Set(options.availableRefs.map((ref) => ref.trim()));
141
+ const byRef = new Map<string, ModelListEntry>();
142
+ for (const model of options.models) {
143
+ const ref = modelRef(model);
144
+ if (available.has(ref) && !byRef.has(ref)) byRef.set(ref, model);
145
+ }
146
+
147
+ const refs = [...byRef.keys()]
148
+ .filter((ref) => options.slot !== "vision" || byRef.get(ref)?.input.includes("image") === true)
149
+ .sort((left, right) => {
150
+ const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
151
+ const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
152
+ return leftRank - rightRank || left.localeCompare(right);
153
+ });
71
154
 
72
- changed = true;
73
- if (fallbackRef) {
74
- repaired[name] = fallbackRef;
75
- replaced++;
76
- } else {
77
- removed++;
155
+ const dynamic = options.slot === "backup"
156
+ ? {
157
+ value: CURRENT_MAIN_MODEL,
158
+ label: "Current main model (default)",
159
+ description: "Clear configured backup; use the main-window model dynamically",
78
160
  }
161
+ : {
162
+ value: CURRENT_MAIN_MODEL,
163
+ label: "Current main model (dynamic)",
164
+ description: options.slot === "vision"
165
+ ? "Clear vision override; use the main-window model for vision tasks"
166
+ : "Clear primary override; use the main-window model dynamically",
167
+ };
168
+
169
+ const items: ModelPickerItem[] = [dynamic];
170
+ for (const ref of refs) {
171
+ const model = byRef.get(ref)!;
172
+ const tags = ["available"];
173
+ if (ref === configuredRef) tags.push("configured");
174
+ if (ref === mainRef) tags.push("current main");
175
+ const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
176
+ items.push({
177
+ value: ref,
178
+ label: ref,
179
+ description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
180
+ });
79
181
  }
182
+ return items;
183
+ }
184
+
185
+ /** Pure pool update: the dynamic/default choice removes the persisted override. */
186
+ export function applyModelPoolChoice(
187
+ current: AgentModelPoolMaps,
188
+ agentName: string,
189
+ slot: ModelPoolSlot,
190
+ choice: string,
191
+ ): AgentModelPoolMaps {
192
+ const next: AgentModelPoolMaps = {
193
+ agentModels: { ...current.agentModels },
194
+ agentBackupModels: { ...current.agentBackupModels },
195
+ };
196
+ const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
197
+ if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
198
+ else target[agentName] = choice.trim();
199
+ return next;
200
+ }
80
201
 
81
- return { agentModels: repaired, changed, replaced, removed, fallbackRef };
202
+ /** Pure rows used by the overview component and focused helper tests. */
203
+ export function buildAgentModelPoolRows(
204
+ agentNames: readonly string[],
205
+ pools: AgentModelPoolMaps,
206
+ ): AgentModelPoolRow[] {
207
+ return agentNames.map((name) => ({
208
+ name,
209
+ primary: pools.agentModels[name] ?? "Main (dynamic)",
210
+ backup: pools.agentBackupModels[name] ?? "Main (default)",
211
+ }));
82
212
  }