oira666_pi-subagent 0.3.4 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,8 +79,9 @@ Built-in agents are only used as a fallback when **all three** locations are emp
79
79
  ---
80
80
  name: writer
81
81
  description: Expert technical writer
82
- model: anthropic/claude-3-5-sonnet
83
82
  thinking: low
83
+ first-layer: enabled
84
+ last-layer: disabled
84
85
  tools: read,write
85
86
  ---
86
87
 
@@ -93,9 +94,11 @@ You are an expert technical writer focused on clarity and conciseness.
93
94
  | ------------- | -------- | -------------------- | -------------------------------------------------------- |
94
95
  | `name` | Yes | — | Agent identifier used in tool calls |
95
96
  | `description` | Yes | — | What the agent does (shown to the main agent) |
96
- | `model` | No | Pi default | Override model, e.g. `anthropic/claude-3-5-sonnet` |
97
+ | `model` | No | Current parent model | Legacy fallback only when live parent model context is unavailable |
97
98
  | `thinking` | No | Pi default | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
98
99
  | `tools` | No | `read,bash,edit,write` | Comma-separated built-in tools |
100
+ | `first-layer` | No | `enabled` | Set to `disabled` to hide/block this agent at depth 1 |
101
+ | `last-layer` | No | `enabled` | Set to `disabled` to hide/block this agent at max depth |
99
102
 
100
103
  Available tools: `read`, `bash`, `edit`, `write`.
101
104
 
@@ -103,7 +106,7 @@ The Markdown body becomes the agent's system prompt (appended to Pi's default, n
103
106
 
104
107
  ## Delegation Guards
105
108
 
106
- Depth and cycle guards prevent runaway recursive delegation.
109
+ Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer.
107
110
 
108
111
  | Config | Default | Description |
109
112
  | ------------------------------ | ------- | ------------------------------------------------ |
@@ -138,6 +141,8 @@ While a `subagents` tool call is running, mid-stream steering input can be broad
138
141
 
139
142
  ## Subagent Session Resume
140
143
 
144
+ > Requires Pi **0.81.0 or newer**. Crash recovery uses Pi's public full Provider SDK and session-replacement lifecycle.
145
+
141
146
  Subagent subprocesses save sessions in `sessions-subagents`. When a main Pi session is resumed and its latest branch contains an unfinished `subagents` tool call (aborted, errored, or closed by Pi's synthetic unfinished-tool error), the extension can resume that delegation from the saved subagent sessions.
142
147
 
143
148
  The same detection also runs after navigating the session tree in the TUI (Esc navigation): if you jump back to a point whose branch ends in an unfinished `subagents` call, the extension offers to resume those subagents from their saved sessions.
@@ -146,6 +151,8 @@ The same detection also runs after navigating the session tree in the TUI (Esc n
146
151
  - Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
147
152
  - Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
148
153
  - Nested subagents use the same mechanism recursively.
154
+ - Provider fallback goes through the selected model's effective Pi provider, so custom providers, custom APIs, auth-derived endpoints, headers, and provider-scoped environment are preserved.
155
+ - Pending resume state and delayed callbacks are discarded on `/resume`, `/new`, `/fork`, and `/reload`, preventing stale work from an old runtime from leaking into the replacement session.
149
156
 
150
157
  | Env Var | Default | Description |
151
158
  | --- | --- | --- |
@@ -211,8 +218,7 @@ subagent *instance* by its unique name.
211
218
  ## CLI Argument Proxying
212
219
 
213
220
  Flags passed to the parent `pi` process are forwarded to subagent child
214
- processes, so they inherit the same provider, API key, model, and other runtime settings. Flags the
215
- extension manages itself are blocked from being forwarded.
221
+ processes, so they inherit the same provider, API key, and other runtime settings. At every new launch, the extension explicitly passes the parent's currently active model; changing `/model` mid-conversation therefore affects all subsequently started subagents. Flags the extension manages itself are blocked from being forwarded.
216
222
 
217
223
  **Always forwarded verbatim:**
218
224
 
@@ -233,7 +239,7 @@ extension manages itself are blocked from being forwarded.
233
239
 
234
240
  | Flag | Overridden by |
235
241
  | --- | --- |
236
- | `--model` | `model:` in agent frontmatter |
242
+ | `--model` | Replaced at launch by the parent's currently active model (`model:` is only a no-context compatibility fallback) |
237
243
  | `--thinking` | `thinking:` in agent frontmatter |
238
244
  | `--tools` / `--no-tools` | `tools:` in agent frontmatter |
239
245
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: team-lead
3
3
  description: "A team of agents with different specializations that can take any complex task, split it into parts, and implement architecture, generation, review, or any other kind of task."
4
+ last-layer: disabled
4
5
  ---
5
6
 
6
7
  You are an experienced team lead, focused on tasks management. You don't do any work yourself. You delegate.
package/agents.ts CHANGED
@@ -24,6 +24,10 @@ export interface AgentConfig {
24
24
  tools?: string[];
25
25
  model?: string;
26
26
  thinking?: string;
27
+ /** Whether this agent may be launched at delegation depth 1 (default: true). */
28
+ firstLayer?: boolean;
29
+ /** Whether this agent may be launched at the maximum delegation depth (default: true). */
30
+ lastLayer?: boolean;
27
31
  systemPrompt: string;
28
32
  source: AgentSource;
29
33
  filePath: string;
@@ -63,8 +67,25 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
63
67
  }
64
68
  }
65
69
 
70
+ function parseLayerSetting(
71
+ value: unknown,
72
+ field: "first-layer" | "last-layer",
73
+ filePath: string,
74
+ ): boolean {
75
+ if (value === undefined) return true;
76
+ if (typeof value === "string") {
77
+ const normalized = value.trim().toLowerCase();
78
+ if (normalized === "enabled") return true;
79
+ if (normalized === "disabled") return false;
80
+ }
81
+ console.warn(
82
+ `[pi-subagent] Ignoring invalid ${field} field in "${filePath}". Expected enabled or disabled.`,
83
+ );
84
+ return true;
85
+ }
86
+
66
87
  /** Parse a single agent markdown file into an AgentConfig. Returns null on skip. */
67
- function parseAgentFile(filePath: string, source: AgentSource): AgentConfig | null {
88
+ export function parseAgentFile(filePath: string, source: AgentSource): AgentConfig | null {
68
89
  let content: string;
69
90
  try {
70
91
  content = fs.readFileSync(filePath, "utf-8");
@@ -113,6 +134,8 @@ function parseAgentFile(filePath: string, source: AgentSource): AgentConfig | nu
113
134
  tools,
114
135
  model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
115
136
  thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
137
+ firstLayer: parseLayerSetting(frontmatter["first-layer"], "first-layer", filePath),
138
+ lastLayer: parseLayerSetting(frontmatter["last-layer"], "last-layer", filePath),
116
139
  systemPrompt: body,
117
140
  source,
118
141
  filePath,
@@ -161,6 +184,17 @@ function dedupeAgents(
161
184
  // Public API
162
185
  // ---------------------------------------------------------------------------
163
186
 
187
+ /** Whether an agent is available to be launched at the requested child depth. */
188
+ export function isAgentEnabledAtLayer(
189
+ agent: AgentConfig,
190
+ targetDepth: number,
191
+ maxDepth: number,
192
+ ): boolean {
193
+ if (targetDepth === 1 && agent.firstLayer === false) return false;
194
+ if (targetDepth === maxDepth && agent.lastLayer === false) return false;
195
+ return true;
196
+ }
197
+
164
198
  /**
165
199
  * Discover all available agents according to the requested scope.
166
200
  *
package/index.ts CHANGED
@@ -10,13 +10,15 @@
10
10
 
11
11
  import * as fs from "node:fs";
12
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
- import { createFauxCore, fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
14
- // The synthetic resume provider forwards to the real fallback model through a
15
- // small dispatcher built on stable pi-ai exports. This deliberately avoids the
16
- // deprecated/temporary `@mariozechner/pi-ai/compat` global `streamSimple`.
17
- import { streamSimpleForModel as streamModelSimple } from "./resumeStream.js";
13
+ import {
14
+ createFauxCore,
15
+ createProvider,
16
+ fauxAssistantMessage,
17
+ fauxToolCall,
18
+ lazyStream,
19
+ } from "@mariozechner/pi-ai";
18
20
  import { Type } from "@sinclair/typebox";
19
- import { type AgentConfig, discoverAgents } from "./agents.js";
21
+ import { type AgentConfig, discoverAgents, isAgentEnabledAtLayer } from "./agents.js";
20
22
  import {
21
23
  allocateSubagentNames,
22
24
  clearResumeActive,
@@ -98,6 +100,38 @@ const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
98
100
  const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
99
101
  const SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV = "PI_SUBAGENT_CONFIRM_PROJECT_AGENTS";
100
102
 
103
+ const BASE_SUBAGENTS_TOOL_DESCRIPTION = [
104
+ "Delegate work to specialized subagents running as isolated pi processes.",
105
+ "",
106
+ "Pass a `tasks` array. Every task in the same call runs IN PARALLEL.",
107
+ " - 1 task -> single delegation",
108
+ " - N tasks -> all N run concurrently in one call",
109
+ "",
110
+ "For sequential work (task B depends on task A's output), make separate",
111
+ "tool calls one after another. Do NOT put dependent tasks in the same array.",
112
+ "",
113
+ 'Single: { tasks: [{ agent: "writer", task: "Rewrite README.md" }] }',
114
+ 'Parallel: { tasks: [{ agent: "writer", task: "..." }, { agent: "tester", task: "..." }] }',
115
+ ].join("\n");
116
+
117
+ const GPT_56_SUBAGENT_GUIDANCE =
118
+ "Be careful with subagents: use them when the user explicitly asks or when they are truly necessary, because they are expensive. Good cases: running several exploration tasks in parallel, solving several tasks in parallel, or delegating several large tasks to separate subagents. Bad cases (don't do this): creating many nested subagents with similar tasks, using sequential subagents for simple short tasks, running a subagent just to read a file or execute a bash command, or delegating work that does not need a team or parallel execution (unless the user asked you to).";
119
+
120
+ export function isGpt56Model(model: unknown): boolean {
121
+ if (typeof model === "string") return model.toLowerCase().includes("gpt-5.6");
122
+ if (!model || typeof model !== "object") return false;
123
+ const candidate = model as { id?: unknown; name?: unknown };
124
+ return [candidate.id, candidate.name].some(
125
+ (value) => typeof value === "string" && value.toLowerCase().includes("gpt-5.6"),
126
+ );
127
+ }
128
+
129
+ export function getSubagentsToolDescription(model?: unknown): string {
130
+ return isGpt56Model(model)
131
+ ? `${BASE_SUBAGENTS_TOOL_DESCRIPTION}\n\n${GPT_56_SUBAGENT_GUIDANCE}`
132
+ : BASE_SUBAGENTS_TOOL_DESCRIPTION;
133
+ }
134
+
101
135
  type ProjectAgentConfirmationSetting = "ask" | "never" | "session";
102
136
  type ProjectAgentApproval = "once" | "session" | "no";
103
137
 
@@ -385,6 +419,15 @@ function makeDetailsFactory(
385
419
  };
386
420
  }
387
421
 
422
+ function filterAgentsForCurrentLayer(
423
+ agents: AgentConfig[],
424
+ currentDepth: number,
425
+ maxDepth: number,
426
+ ): AgentConfig[] {
427
+ const targetDepth = currentDepth + 1;
428
+ return agents.filter((agent) => isAgentEnabledAtLayer(agent, targetDepth, maxDepth));
429
+ }
430
+
388
431
  function formatAgentNames(agents: AgentConfig[]): string {
389
432
  return agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
390
433
  }
@@ -562,7 +605,6 @@ function isStreamingSteerInput(event: any, ctx: { isIdle: () => boolean }): bool
562
605
  return !ctx.isIdle();
563
606
  }
564
607
 
565
- const RESUME_STATE_KEY = "__piSubagentResumeState";
566
608
  const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
567
609
  const RESUME_INTERACTIVE_DELAY_MS = 50;
568
610
 
@@ -572,21 +614,6 @@ type SyntheticResumeState = {
572
614
  trigger: "resumePrompt" | "nextRequest";
573
615
  };
574
616
 
575
- function clearSyntheticResumeState(): void {
576
- const state = getSyntheticResumeState();
577
- state.plans = [];
578
- state.phase = "tool";
579
- state.trigger = "resumePrompt";
580
- }
581
-
582
- function getSyntheticResumeState(): SyntheticResumeState {
583
- const g = globalThis as any;
584
- if (!g[RESUME_STATE_KEY]) {
585
- g[RESUME_STATE_KEY] = { plans: [], phase: "tool", trigger: "resumePrompt" } satisfies SyntheticResumeState;
586
- }
587
- return g[RESUME_STATE_KEY] as SyntheticResumeState;
588
- }
589
-
590
617
  // Model definition for the synthetic subagent-resume provider. Shared between
591
618
  // the faux core (which produces the canned assistant turn) and the provider
592
619
  // registration below.
@@ -654,6 +681,25 @@ function getRestorableModel(ctx: any): any | undefined {
654
681
  return findLastNonResumeModel(ctx) ?? getEnvFallbackModel(ctx);
655
682
  }
656
683
 
684
+ /**
685
+ * Pick the parent model inherited by a subagent launch.
686
+ *
687
+ * A normal tool call was emitted by the current model, so that model is the
688
+ * authoritative choice. Looking backward in the session is only appropriate
689
+ * while our own synthetic resume model is active (or no current model exists).
690
+ */
691
+ export function selectParentModelForSubagent(
692
+ currentModel: any | undefined,
693
+ modelBeforeSynthetic: any | undefined,
694
+ historicalRealModel: any | undefined,
695
+ lastRestorableModel: any | undefined,
696
+ ): any | undefined {
697
+ if (currentModel?.provider && currentModel.provider !== RESUME_PROVIDER) {
698
+ return currentModel;
699
+ }
700
+ return modelBeforeSynthetic ?? historicalRealModel ?? lastRestorableModel;
701
+ }
702
+
657
703
  // ---------------------------------------------------------------------------
658
704
  // Extension entry point
659
705
  // ---------------------------------------------------------------------------
@@ -663,23 +709,116 @@ export default function (pi: ExtensionAPI) {
663
709
  let lastRestorableModel: any | undefined;
664
710
  let latestSessionCtx: any | undefined;
665
711
  let pendingInteractiveResumePrompt: string | null = null;
712
+ let lifecycleGeneration = 0;
713
+ let sessionActive = false;
714
+ const scheduledTasks = new Set<ReturnType<typeof setTimeout>>();
715
+ const resumeState: SyntheticResumeState = {
716
+ plans: [],
717
+ phase: "tool",
718
+ trigger: "resumePrompt",
719
+ };
720
+
721
+ function clearSyntheticResumeState(): void {
722
+ resumeState.plans = [];
723
+ resumeState.phase = "tool";
724
+ resumeState.trigger = "resumePrompt";
725
+ }
666
726
 
667
- async function streamWithRealModelFallback(context: any, options: any, fallback: any) {
668
- if (!fallback) return null;
669
- const auth = resumeModelRegistry
670
- ? await resumeModelRegistry.getApiKeyAndHeaders(fallback)
671
- : { ok: true, apiKey: undefined, headers: undefined };
672
- if (!auth.ok) {
673
- throw new Error(auth.error);
727
+ function getParentModelForSubagent(ctx: any): any | undefined {
728
+ const currentModel = ctx?.model;
729
+ // Avoid historical lookup during normal calls: the current assistant
730
+ // response is the one that emitted the subagents tool call.
731
+ if (currentModel?.provider && currentModel.provider !== RESUME_PROVIDER) {
732
+ return currentModel;
674
733
  }
675
- return streamModelSimple(fallback, context, {
676
- ...options,
677
- apiKey: auth.apiKey,
678
- headers: auth.headers,
734
+ return selectParentModelForSubagent(
735
+ currentModel,
736
+ modelToRestoreAfterResume,
737
+ findLastNonResumeModel(ctx) ?? getEnvFallbackModel(ctx),
738
+ lastRestorableModel,
739
+ );
740
+ }
741
+
742
+ function scheduleSessionTask(callback: () => void, delayMs: number): void {
743
+ const expectedGeneration = lifecycleGeneration;
744
+ const timer = setTimeout(() => {
745
+ scheduledTasks.delete(timer);
746
+ if (!sessionActive || expectedGeneration !== lifecycleGeneration) return;
747
+ callback();
748
+ }, delayMs);
749
+ scheduledTasks.add(timer);
750
+ }
751
+
752
+ function mergeProviderHeaders(
753
+ base: Record<string, string | null> | undefined,
754
+ override: Record<string, string | null> | undefined,
755
+ ): Record<string, string | null> | undefined {
756
+ const merged = new Map<string, [string, string | null]>();
757
+ for (const headers of [base, override]) {
758
+ for (const [name, value] of Object.entries(headers ?? {})) {
759
+ const key = name.toLowerCase();
760
+ if (value === null) merged.delete(key);
761
+ else merged.set(key, [name, value]);
762
+ }
763
+ }
764
+ return merged.size > 0 ? Object.fromEntries(merged.values()) : undefined;
765
+ }
766
+
767
+ function streamWithRealModelFallback(
768
+ context: any,
769
+ options: any,
770
+ fallback: any,
771
+ expectedGeneration = lifecycleGeneration,
772
+ ) {
773
+ if (!fallback || !resumeModelRegistry) return null;
774
+
775
+ // Use pi 0.81's effective Provider instead of dispatching on model.api.
776
+ // This preserves custom provider streams, provider composition, dynamic
777
+ // auth base URLs, provider-scoped env, and future/custom API identifiers.
778
+ return lazyStream(fallback, async () => {
779
+ if (!sessionActive || expectedGeneration !== lifecycleGeneration) {
780
+ throw new Error("Subagent resume fallback was cancelled by session replacement.");
781
+ }
782
+ const provider = resumeModelRegistry.getProvider?.(fallback.provider);
783
+ if (!provider || provider.id === RESUME_PROVIDER) {
784
+ throw new Error(`Subagent resume fallback provider is unavailable: ${fallback.provider}.`);
785
+ }
786
+ const [providerResolution, modelResolution] = await Promise.all([
787
+ resumeModelRegistry.getProviderAuth?.(fallback.provider),
788
+ resumeModelRegistry.getApiKeyAndHeaders?.(fallback),
789
+ ]);
790
+ if (!sessionActive || expectedGeneration !== lifecycleGeneration) {
791
+ throw new Error("Subagent resume fallback was cancelled by session replacement.");
792
+ }
793
+ if (!providerResolution || !modelResolution?.ok) {
794
+ throw new Error(
795
+ modelResolution?.error ?? `Provider is not configured: ${fallback.provider}`,
796
+ );
797
+ }
798
+ const providerAuth = providerResolution.auth ?? {};
799
+ const requestModel = providerAuth.baseUrl
800
+ ? { ...fallback, baseUrl: providerAuth.baseUrl }
801
+ : fallback;
802
+ const requestOptions = {
803
+ ...options,
804
+ // Model-aware resolution includes configured/model headers. Never
805
+ // forward the synthetic provider's no-op credential.
806
+ apiKey: modelResolution.apiKey,
807
+ headers: mergeProviderHeaders(modelResolution.headers, options?.headers),
808
+ env: {
809
+ ...(providerResolution.env ?? {}),
810
+ ...(modelResolution.env ?? {}),
811
+ ...(options?.env ?? {}),
812
+ },
813
+ };
814
+ return provider.streamSimple(requestModel, context, requestOptions);
679
815
  });
680
816
  }
681
817
 
682
- async function restoreVisibleModelForResume(): Promise<any | undefined> {
818
+ async function restoreVisibleModelForResume(
819
+ expectedGeneration = lifecycleGeneration,
820
+ ): Promise<any | undefined> {
821
+ if (!sessionActive || expectedGeneration !== lifecycleGeneration) return undefined;
683
822
  const restore = modelToRestoreAfterResume ?? lastRestorableModel;
684
823
  if (!restore) return undefined;
685
824
  lastRestorableModel = restore;
@@ -687,7 +826,9 @@ export default function (pi: ExtensionAPI) {
687
826
  try {
688
827
  await pi.setModel(restore);
689
828
  } catch (err) {
690
- console.error("[pi-subagent] Failed to restore real model during resume:", err);
829
+ if (sessionActive && expectedGeneration === lifecycleGeneration) {
830
+ console.error("[pi-subagent] Failed to restore real model during resume:", err);
831
+ }
691
832
  }
692
833
  }
693
834
  return restore;
@@ -712,12 +853,26 @@ export default function (pi: ExtensionAPI) {
712
853
  models: [RESUME_MODEL_DEF],
713
854
  });
714
855
 
715
- pi.registerProvider(RESUME_PROVIDER, {
716
- baseUrl: "http://127.0.0.1/pi-subagent-resume",
717
- api: "openai-responses",
718
- apiKey: "pi-subagent-resume-noop-key",
719
- streamSimple: async (model, context, options) => {
720
- const state = getSyntheticResumeState();
856
+ const resumeProvider = createProvider({
857
+ id: RESUME_PROVIDER,
858
+ name: "Pi Subagent Resume",
859
+ auth: {
860
+ apiKey: {
861
+ name: "Internal synthetic resume provider",
862
+ async resolve() {
863
+ return {
864
+ auth: { apiKey: "pi-subagent-resume-noop-key" },
865
+ source: "internal synthetic provider",
866
+ };
867
+ },
868
+ },
869
+ },
870
+ models: resumeCore.models,
871
+ api: {
872
+ stream: resumeCore.stream,
873
+ streamSimple: (model, context, options) => {
874
+ const state = resumeState;
875
+ const expectedGeneration = lifecycleGeneration;
721
876
  const discoveredPlans = state.plans.length > 0
722
877
  ? state.plans
723
878
  : pendingResumePlans.length > 0
@@ -759,7 +914,7 @@ export default function (pi: ExtensionAPI) {
759
914
  .result()
760
915
  .catch(() => {})
761
916
  .finally(() => {
762
- void restoreVisibleModelForResume();
917
+ void restoreVisibleModelForResume(expectedGeneration);
763
918
  });
764
919
  return stream;
765
920
  }
@@ -768,14 +923,28 @@ export default function (pi: ExtensionAPI) {
768
923
  // injection turn (e.g. a request raced ahead of the model restore).
769
924
  // Forward the request to the real fallback model instead.
770
925
  if (phase === "final") {
771
- const restore = await restoreVisibleModelForResume();
772
- const delegated = await streamWithRealModelFallback(context, options, restore);
773
- if (delegated) return delegated;
926
+ const delegated = streamWithRealModelFallback(
927
+ context,
928
+ options,
929
+ modelToRestoreAfterResume ?? lastRestorableModel,
930
+ expectedGeneration,
931
+ );
932
+ if (delegated) {
933
+ void restoreVisibleModelForResume(expectedGeneration);
934
+ return delegated;
935
+ }
774
936
  }
775
937
 
776
- const restore = await restoreVisibleModelForResume();
777
- const fallback = await streamWithRealModelFallback(context, options, restore ?? lastRestorableModel);
778
- if (fallback) return fallback;
938
+ const fallback = streamWithRealModelFallback(
939
+ context,
940
+ options,
941
+ modelToRestoreAfterResume ?? lastRestorableModel,
942
+ expectedGeneration,
943
+ );
944
+ if (fallback) {
945
+ void restoreVisibleModelForResume(expectedGeneration);
946
+ return fallback;
947
+ }
779
948
 
780
949
  // No real model to fall back to: surface a clear error turn.
781
950
  if (!(plans.length > 0 && phase === "tool")) {
@@ -788,9 +957,10 @@ export default function (pi: ExtensionAPI) {
788
957
  () => fauxAssistantMessage([], { stopReason: "error", errorMessage: errorText }),
789
958
  ]);
790
959
  return resumeCore.streamSimple(model, context, options);
960
+ },
791
961
  },
792
- models: [{ ...RESUME_MODEL_DEF, api: "openai-responses" }],
793
962
  });
963
+ pi.registerProvider(resumeProvider);
794
964
 
795
965
  const depthConfig = resolveDelegationDepthConfig(pi);
796
966
  const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
@@ -1178,6 +1348,7 @@ export default function (pi: ExtensionAPI) {
1178
1348
  }
1179
1349
 
1180
1350
  async function restoreModelAfterResumeFailure(ctx?: { ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }) {
1351
+ if (!sessionActive) return;
1181
1352
  const restore = modelToRestoreAfterResume;
1182
1353
  modelToRestoreAfterResume = undefined;
1183
1354
  pendingResumePlans = [];
@@ -1221,7 +1392,14 @@ export default function (pi: ExtensionAPI) {
1221
1392
 
1222
1393
  // Auto-discover agents on session start
1223
1394
  pi.on("session_start", async (event, ctx) => {
1395
+ lifecycleGeneration += 1;
1396
+ sessionActive = true;
1224
1397
  latestSessionCtx = ctx;
1398
+ resumeModelRegistry = ctx.modelRegistry;
1399
+ clearSyntheticResumeState();
1400
+ pendingResumePlans = [];
1401
+ pendingInteractiveResumePrompt = null;
1402
+ modelToRestoreAfterResume = undefined;
1225
1403
  updateCombinedUsageStatus(ctx);
1226
1404
  try {
1227
1405
  // Always repair sessions left on the synthetic resume model, even in
@@ -1239,7 +1417,7 @@ export default function (pi: ExtensionAPI) {
1239
1417
  if (!canDelegate) return;
1240
1418
 
1241
1419
  const discovery = discoverAgents(ctx.cwd, "both");
1242
- discoveredAgents = discovery.agents;
1420
+ discoveredAgents = filterAgentsForCurrentLayer(discovery.agents, currentDepth, maxDepth);
1243
1421
  currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
1244
1422
  currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
1245
1423
  if (resumableSubagentsDisabled()) {
@@ -1317,6 +1495,24 @@ export default function (pi: ExtensionAPI) {
1317
1495
  }
1318
1496
  });
1319
1497
 
1498
+ // Pi 0.81 replaces and rebinds the entire extension runtime on /resume,
1499
+ // /new, /fork, and /reload. Invalidate every detached callback so it cannot
1500
+ // use stale pi/context objects after the old runtime has been torn down.
1501
+ pi.on("session_shutdown", () => {
1502
+ sessionActive = false;
1503
+ lifecycleGeneration += 1;
1504
+ for (const timer of scheduledTasks) clearTimeout(timer);
1505
+ scheduledTasks.clear();
1506
+ clearSyntheticResumeState();
1507
+ pendingResumePlans = [];
1508
+ pendingInteractiveResumePrompt = null;
1509
+ modelToRestoreAfterResume = undefined;
1510
+ latestSessionCtx = undefined;
1511
+ resumeModelRegistry = undefined;
1512
+ activeSubagentUsageSummaries.clear();
1513
+ activeSubagents.clear();
1514
+ });
1515
+
1320
1516
  /**
1321
1517
  * Detect unfinished subagent calls at the current branch leaf and offer to
1322
1518
  * resume them. Shared between session_start (startup/resume) and
@@ -1373,7 +1569,6 @@ export default function (pi: ExtensionAPI) {
1373
1569
  }
1374
1570
 
1375
1571
  pendingResumePlans = [...plans];
1376
- const resumeState = getSyntheticResumeState();
1377
1572
  resumeState.plans = [...plans];
1378
1573
  resumeState.phase = "tool";
1379
1574
  // Headless subprocess/RPC subagents cannot answer a visible resume
@@ -1407,7 +1602,7 @@ export default function (pi: ExtensionAPI) {
1407
1602
  // which is the last extension hook before the initial chat render.
1408
1603
  pendingInteractiveResumePrompt = `Resuming ${totalTaskCount} subagents...`;
1409
1604
  } else {
1410
- setTimeout(() => {
1605
+ scheduleSessionTask(() => {
1411
1606
  try {
1412
1607
  pi.sendUserMessage(`Resuming ${totalTaskCount} subagents...`);
1413
1608
  } catch (err) {
@@ -1452,7 +1647,7 @@ export default function (pi: ExtensionAPI) {
1452
1647
  pi.on("message_end", (_event, ctx) => {
1453
1648
  latestSessionCtx = ctx;
1454
1649
  updateCombinedUsageStatus(ctx);
1455
- setTimeout(() => updateCombinedUsageStatus(ctx), 0);
1650
+ scheduleSessionTask(() => updateCombinedUsageStatus(ctx), 0);
1456
1651
  });
1457
1652
 
1458
1653
  pi.on("tool_execution_end", (event, ctx) => {
@@ -1460,7 +1655,7 @@ export default function (pi: ExtensionAPI) {
1460
1655
  if (isSubagentToolName(event.toolName)) {
1461
1656
  activeSubagentUsageSummaries.delete(event.toolCallId);
1462
1657
  updateCombinedUsageStatus(ctx);
1463
- setTimeout(() => updateCombinedUsageStatus(ctx), 0);
1658
+ scheduleSessionTask(() => updateCombinedUsageStatus(ctx), 0);
1464
1659
  }
1465
1660
  });
1466
1661
 
@@ -1468,7 +1663,7 @@ export default function (pi: ExtensionAPI) {
1468
1663
  const prompt = pendingInteractiveResumePrompt;
1469
1664
  if (!prompt) return;
1470
1665
  pendingInteractiveResumePrompt = null;
1471
- setTimeout(() => {
1666
+ scheduleSessionTask(() => {
1472
1667
  try {
1473
1668
  pi.sendUserMessage(prompt);
1474
1669
  } catch (err) {
@@ -1571,22 +1766,13 @@ keeping their full previous context:
1571
1766
 
1572
1767
  // Register the subagents tool
1573
1768
  if (canDelegate) {
1574
- pi.registerTool({
1769
+ let registeredForGpt56 = false;
1770
+ const registerSubagentsTool = (model?: unknown) => {
1771
+ registeredForGpt56 = isGpt56Model(model);
1772
+ pi.registerTool({
1575
1773
  name: SUBAGENT_TOOL_NAME,
1576
1774
  label: "Subagents",
1577
- description: [
1578
- "Delegate work to specialized subagents running as isolated pi processes.",
1579
- "",
1580
- "Pass a `tasks` array. Every task in the same call runs IN PARALLEL.",
1581
- " - 1 task -> single delegation",
1582
- " - N tasks -> all N run concurrently in one call",
1583
- "",
1584
- "For sequential work (task B depends on task A's output), make separate",
1585
- "tool calls one after another. Do NOT put dependent tasks in the same array.",
1586
- "",
1587
- 'Single: { tasks: [{ agent: "writer", task: "Rewrite README.md" }] }',
1588
- 'Parallel: { tasks: [{ agent: "writer", task: "..." }, { agent: "tester", task: "..." }] }',
1589
- ].join("\n"),
1775
+ description: getSubagentsToolDescription(model),
1590
1776
  parameters: SubagentParams,
1591
1777
 
1592
1778
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -1594,7 +1780,7 @@ keeping their full previous context:
1594
1780
  recordToolCallStart(toolCallId);
1595
1781
  updateLatestBroadcastTargets(undefined);
1596
1782
  const discovery = discoverAgents(ctx.cwd, "both");
1597
- const { agents } = discovery;
1783
+ const agents = filterAgentsForCurrentLayer(discovery.agents, currentDepth, maxDepth);
1598
1784
 
1599
1785
  const makeDetails = makeDetailsFactory(
1600
1786
  discovery.projectAgentsDir,
@@ -1732,7 +1918,8 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1732
1918
  return {
1733
1919
  agent: task.agent,
1734
1920
  task: task.task,
1735
- model: agentConfig?.model,
1921
+ model:
1922
+ formatModelFlag(getParentModelForSubagent(ctx)) ?? agentConfig?.model,
1736
1923
  tools: agentConfig?.tools,
1737
1924
  sessionDir:
1738
1925
  resumePlan?.details?.results[index]?.sessionDir ??
@@ -1762,11 +1949,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1762
1949
  resumePlan?.details?.results[0],
1763
1950
  getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
1764
1951
  !!resumePlan,
1765
- // Prefer the pre-resume model (during resume) or the current
1766
- // active model (normal runs). This prevents children from
1767
- // defaulting to whatever settings.json says at spawn time, which
1768
- // can change while the parent session is long-running.
1769
- formatModelFlag(modelToRestoreAfterResume ?? ctx.model ?? lastRestorableModel),
1952
+ // Normal calls inherit the model that emitted this tool call;
1953
+ // synthetic resume calls recover the preceding real model.
1954
+ formatModelFlag(getParentModelForSubagent(ctx)),
1770
1955
  topLevelBaseId,
1771
1956
  names[0],
1772
1957
  );
@@ -1782,7 +1967,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1782
1967
  resumePlan?.details?.results,
1783
1968
  (index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
1784
1969
  !!resumePlan,
1785
- formatModelFlag(modelToRestoreAfterResume ?? ctx.model ?? lastRestorableModel),
1970
+ formatModelFlag(getParentModelForSubagent(ctx)),
1786
1971
  topLevelBaseId,
1787
1972
  { names },
1788
1973
  );
@@ -1801,6 +1986,19 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1801
1986
  renderCall: (args, theme, context) => renderCall(args, theme, context),
1802
1987
  renderResult: (result, { expanded }, theme) =>
1803
1988
  renderResult(result, expanded, theme),
1989
+ });
1990
+ };
1991
+
1992
+ registerSubagentsTool(latestSessionCtx?.model);
1993
+ pi.on("model_select", (event) => {
1994
+ if (registeredForGpt56 !== isGpt56Model(event.model)) {
1995
+ registerSubagentsTool(event.model);
1996
+ }
1997
+ });
1998
+ pi.on("before_agent_start", (_event, ctx) => {
1999
+ if (registeredForGpt56 !== isGpt56Model(ctx.model)) {
2000
+ registerSubagentsTool(ctx.model);
2001
+ }
1804
2002
  });
1805
2003
 
1806
2004
  if (!resumableSubagentsDisabled()) pi.registerTool({
@@ -1982,7 +2180,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1982
2180
  undefined,
1983
2181
  (index) => targets[index].sessionDir,
1984
2182
  true,
1985
- formatModelFlag(modelToRestoreAfterResume ?? ctx.model ?? lastRestorableModel),
2183
+ formatModelFlag(getParentModelForSubagent(ctx)),
1986
2184
  topLevelBaseId,
1987
2185
  { names: targets.map((target) => target.name), rawPrompts: true },
1988
2186
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.3.4",
3
+ "version": "0.3.7",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -9,7 +9,6 @@
9
9
  "agents.ts",
10
10
  "runner.ts",
11
11
  "resume.ts",
12
- "resumeStream.ts",
13
12
  "names.ts",
14
13
  "shared.ts",
15
14
  "render.ts",
@@ -53,10 +52,10 @@
53
52
  "typescript": "^5.9.3"
54
53
  },
55
54
  "peerDependencies": {
56
- "@mariozechner/pi-agent-core": ">=0.37.0",
57
- "@mariozechner/pi-ai": ">=0.37.0",
58
- "@mariozechner/pi-coding-agent": ">=0.37.0",
59
- "@mariozechner/pi-tui": ">=0.37.0"
55
+ "@mariozechner/pi-agent-core": ">=0.81.0",
56
+ "@mariozechner/pi-ai": ">=0.81.0",
57
+ "@mariozechner/pi-coding-agent": ">=0.81.0",
58
+ "@mariozechner/pi-tui": ">=0.81.0"
60
59
  },
61
60
  "peerDependenciesMeta": {
62
61
  "@mariozechner/pi-agent-core": {
package/runner.ts CHANGED
@@ -542,6 +542,12 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
542
542
  // Build pi CLI arguments
543
543
  // ---------------------------------------------------------------------------
544
544
 
545
+ export function resolveSubagentModel(agentModel?: string, currentParentModel?: string): string | undefined {
546
+ // The active parent model is authoritative. Agent frontmatter is retained as
547
+ // a compatibility fallback only for callers that cannot supply live context.
548
+ return currentParentModel ?? agentModel ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
549
+ }
550
+
545
551
  function buildPiArgs(
546
552
  agent: AgentConfig,
547
553
  systemPromptPath: string | null,
@@ -561,8 +567,9 @@ function buildPiArgs(
561
567
  if (sessionDir) args.push("--session-dir", sessionDir);
562
568
  if (resumeSession) args.push("--continue");
563
569
 
564
- // Agent config takes priority; fall back to parent CLI value
565
- const model = agent.model ?? fallbackModelOverride ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
570
+ // Always use the model active in the parent at launch time. This matters
571
+ // when /model changed after the parent process originally started.
572
+ const model = resolveSubagentModel(agent.model, fallbackModelOverride);
566
573
  if (model) args.push("--model", model);
567
574
 
568
575
  const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
package/shims.d.ts CHANGED
@@ -12,6 +12,7 @@ declare module "@mariozechner/pi-ai" {
12
12
  export type Message = any;
13
13
 
14
14
  export function createAssistantMessageEventStream(): any;
15
+ export function createProvider(options: any): any;
15
16
  export function lazyStream(model: any, setup: () => Promise<any>): any;
16
17
  export type AssistantMessageEventStream = any;
17
18
  export type ProviderStreams = { stream: (...args: any[]) => any; streamSimple: (...args: any[]) => any };
@@ -90,6 +91,7 @@ declare module "@mariozechner/pi-coding-agent" {
90
91
  export interface ExtensionAPI {
91
92
  registerFlag(name: string, config: any): void;
92
93
  getFlag(name: string): unknown;
94
+ registerProvider(provider: any): void;
93
95
  registerProvider(name: string, provider: any): void;
94
96
  registerTool(tool: any): void;
95
97
  addBeforeAgentStart(hook: (ctx: ExtensionContext) => unknown): void;
package/resumeStream.ts DELETED
@@ -1,104 +0,0 @@
1
- /**
2
- * Durable model-streaming helper for the synthetic subagent-resume provider.
3
- *
4
- * The synthetic resume provider only ever synthesizes an assistant turn that
5
- * carries the `subagent` tool call(s). But pi may still invoke the provider's
6
- * `streamSimple` handler in edge/race situations where the real model has not
7
- * been restored yet (e.g. a request arrives before `pi.setModel(realModel)`
8
- * has propagated). In that case the handler must forward the request to the
9
- * real fallback model and return a valid assistant stream.
10
- *
11
- * Historically this used the global `streamSimple` dispatcher exported from the
12
- * `@mariozechner/pi-ai` package root. Pi's provider/model rework moved that
13
- * dispatcher into the explicitly temporary `@mariozechner/pi-ai/compat`
14
- * entrypoint ("deleted with the coding-agent ModelManager migration").
15
- *
16
- * This module reimplements the same behavior using only stable pi-ai exports:
17
- * - the root `lazyStream` helper (returns a stream synchronously while async
18
- * setup runs behind it), and
19
- * - the per-API `ProviderStreams` factories published under the stable
20
- * `@mariozechner/pi-ai/api/*` subpaths.
21
- *
22
- * `model.api` selects the concrete API implementation, mirroring exactly what
23
- * pi core does when it dispatches a stream to the provider that owns a model.
24
- */
25
- import { lazyStream } from "@mariozechner/pi-ai";
26
- import type { AssistantMessageEventStream, ProviderStreams } from "@mariozechner/pi-ai";
27
-
28
- type ProviderStreamsFactory = () => ProviderStreams;
29
-
30
- /**
31
- * Lazily import the `ProviderStreams` factory for a given `model.api`.
32
- *
33
- * Each entry maps a `KnownApi` id to its stable `/api/*` subpath module and the
34
- * factory export that module provides. Dynamic `import()` keeps the API modules
35
- * out of the hot path until a fallback stream is actually needed, and the
36
- * host's module cache deduplicates repeated loads.
37
- */
38
- const API_LOADERS: Record<string, () => Promise<ProviderStreamsFactory>> = {
39
- "openai-responses": async () =>
40
- (await import("@mariozechner/pi-ai/api/openai-responses.lazy")).openAIResponsesApi,
41
- "openai-completions": async () =>
42
- (await import("@mariozechner/pi-ai/api/openai-completions.lazy")).openAICompletionsApi,
43
- "azure-openai-responses": async () =>
44
- (await import("@mariozechner/pi-ai/api/azure-openai-responses.lazy")).azureOpenAIResponsesApi,
45
- "openai-codex-responses": async () =>
46
- (await import("@mariozechner/pi-ai/api/openai-codex-responses.lazy")).openAICodexResponsesApi,
47
- "anthropic-messages": async () =>
48
- (await import("@mariozechner/pi-ai/api/anthropic-messages.lazy")).anthropicMessagesApi,
49
- "bedrock-converse-stream": async () =>
50
- (await import("@mariozechner/pi-ai/api/bedrock-converse-stream.lazy")).bedrockConverseStreamApi,
51
- "google-generative-ai": async () =>
52
- (await import("@mariozechner/pi-ai/api/google-generative-ai.lazy")).googleGenerativeAIApi,
53
- "google-vertex": async () =>
54
- (await import("@mariozechner/pi-ai/api/google-vertex.lazy")).googleVertexApi,
55
- "mistral-conversations": async () =>
56
- (await import("@mariozechner/pi-ai/api/mistral-conversations.lazy")).mistralConversationsApi,
57
- "pi-messages": async () =>
58
- (await import("@mariozechner/pi-ai/api/pi-messages.lazy")).piMessagesApi,
59
- };
60
-
61
- const providerStreamsCache = new Map<string, ProviderStreams>();
62
-
63
- /** API ids for which the resume fallback can forward to a real model. */
64
- export function getSupportedResumeFallbackApis(): string[] {
65
- return Object.keys(API_LOADERS);
66
- }
67
-
68
- /** Load (and cache) the `ProviderStreams` implementation for a `model.api`. */
69
- export async function resolveProviderStreams(api: string): Promise<ProviderStreams> {
70
- const cached = providerStreamsCache.get(api);
71
- if (cached) return cached;
72
-
73
- const loader = API_LOADERS[api];
74
- if (!loader) {
75
- throw new Error(
76
- `Subagent resume cannot forward to fallback model: unsupported model API "${api}". ` +
77
- `Supported APIs: ${Object.keys(API_LOADERS).join(", ")}.`,
78
- );
79
- }
80
-
81
- const factory = await loader();
82
- const providerStreams = factory();
83
- providerStreamsCache.set(api, providerStreams);
84
- return providerStreams;
85
- }
86
-
87
- /**
88
- * Stream a real fallback model through its owning API implementation.
89
- *
90
- * Returns synchronously via `lazyStream`; the async API-module load and the
91
- * underlying provider request run behind the returned stream. `options` is
92
- * expected to already carry the resolved `apiKey`/`headers` (the extension
93
- * resolves those through `ctx.modelRegistry.getApiKeyAndHeaders`).
94
- */
95
- export function streamSimpleForModel(
96
- model: any,
97
- context: any,
98
- options: any,
99
- ): AssistantMessageEventStream {
100
- return lazyStream(model, async () => {
101
- const providerStreams = await resolveProviderStreams(model.api);
102
- return providerStreams.streamSimple(model, context, options);
103
- });
104
- }