@tintinweb/pi-subagents 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] - 2026-03-26
11
+
12
+ ### Fixed
13
+ - **Extension `session_start` handlers now fire in subagent sessions** ([#20](https://github.com/tintinweb/pi-subagents/issues/20)) — `bindExtensions()` was never called on subagent sessions, so extensions that initialize state in `session_start` (e.g. loading credentials, setting up connections) silently failed at runtime. Tools appeared registered but were non-functional. Now calls `session.bindExtensions()` after tool filtering and before prompting, matching the lifecycle used by pi's interactive, print, and RPC modes. Also triggers `extendResourcesFromExtensions("startup")` so extension-provided skills and prompts are discovered.
14
+
15
+ ## [0.5.1] - 2026-03-24
16
+
17
+ ### Changed
18
+ - **Agent config is authoritative** — frontmatter values for `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, and `isolation` now take precedence over `Agent` tool-call parameters. Tool-call params only fill fields the agent config leaves unspecified.
19
+ - **`join_mode` is now a global setting only** — removed the per-call `join_mode` parameter from the `Agent` tool. Join behavior is configured via `/agents` → Settings → Join mode.
20
+ - **`max_turns: 0` means unlimited** — agent files can now explicitly set `max_turns: 0` to lock unlimited turns. Previously `0` was silently clamped to `1`.
21
+
22
+ ### Fixed
23
+ - **Final subagent text preserved from non-streaming providers** — agents using providers that return the final message without streaming `text_delta` events no longer return empty results. Falls back to extracting text from the completed session history.
24
+ - **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely.
25
+
10
26
  ## [0.5.0] - 2026-03-22
11
27
 
12
28
  ### Added
@@ -329,6 +345,8 @@ Initial release.
329
345
  - **Thinking level** — per-agent extended thinking control
330
346
  - **`/agent` and `/agents` commands**
331
347
 
348
+ [0.5.2]: https://github.com/tintinweb/pi-subagents/compare/v0.5.1...v0.5.2
349
+ [0.5.1]: https://github.com/tintinweb/pi-subagents/compare/v0.5.0...v0.5.1
332
350
  [0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0
333
351
  [0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
334
352
  [0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
package/README.md CHANGED
@@ -170,7 +170,7 @@ All fields are optional — sensible defaults for everything.
170
170
  | `isolated` | `false` | No extension/MCP tools, only built-in |
171
171
  | `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
172
172
 
173
- Frontmatter sets defaults. Explicit `Agent` parameters always override them.
173
+ Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
174
174
 
175
175
  ## Tools
176
176
 
@@ -191,7 +191,6 @@ Launch a sub-agent.
191
191
  | `isolated` | boolean | no | No extension/MCP tools |
192
192
  | `isolation` | `"worktree"` | no | Run in an isolated git worktree |
193
193
  | `inherit_context` | boolean | no | Fork parent conversation into agent |
194
- | `join_mode` | `"async"` \| `"group"` | no | Override join strategy for background completion notifications (default: smart) |
195
194
 
196
195
  ### `get_subagent_result`
197
196
 
@@ -260,7 +259,7 @@ Foreground agents bypass the queue — they block the parent anyway.
260
259
 
261
260
  ## Join Strategies
262
261
 
263
- When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered:
262
+ When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents.
264
263
 
265
264
  | Mode | Behavior |
266
265
  |------|----------|
@@ -271,8 +270,7 @@ When background agents complete, they notify the main agent. The **join mode** c
271
270
  **Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers.
272
271
 
273
272
  **Configuration:**
274
- - Per-call: `Agent({ ..., join_mode: "async" })` overrides for that agent
275
- - Global default: `/agents` → Settings → Join mode
273
+ - Configure join mode in `/agents` Settings Join mode
276
274
 
277
275
  ## Events
278
276
 
@@ -5,9 +5,11 @@ import type { Model } from "@mariozechner/pi-ai";
5
5
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
6
  import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
7
  import type { SubagentType, ThinkingLevel } from "./types.js";
8
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
9
+ export declare function normalizeMaxTurns(n: number | undefined): number | undefined;
8
10
  /** Get the default max turns value. undefined = unlimited. */
9
11
  export declare function getDefaultMaxTurns(): number | undefined;
10
- /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
12
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
11
13
  export declare function setDefaultMaxTurns(n: number | undefined): void;
12
14
  /** Get the grace turns value. */
13
15
  export declare function getGraceTurns(): number;
@@ -12,10 +12,16 @@ import { preloadSkills } from "./skill-loader.js";
12
12
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
13
13
  /** Default max turns. undefined = unlimited (no turn limit). */
14
14
  let defaultMaxTurns;
15
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
16
+ export function normalizeMaxTurns(n) {
17
+ if (n == null || n === 0)
18
+ return undefined;
19
+ return Math.max(1, n);
20
+ }
15
21
  /** Get the default max turns value. undefined = unlimited. */
16
22
  export function getDefaultMaxTurns() { return defaultMaxTurns; }
17
- /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
18
- export function setDefaultMaxTurns(n) { defaultMaxTurns = n != null ? Math.max(1, n) : undefined; }
23
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
24
+ export function setDefaultMaxTurns(n) { defaultMaxTurns = normalizeMaxTurns(n); }
19
25
  /** Additional turns allowed after the soft limit steer message. */
20
26
  let graceTurns = 5;
21
27
  /** Get the grace turns value. */
@@ -61,6 +67,18 @@ function collectResponseText(session) {
61
67
  });
62
68
  return { getText: () => text, unsubscribe };
63
69
  }
70
+ /** Get the last assistant text from the completed session history. */
71
+ function getLastAssistantText(session) {
72
+ for (let i = session.messages.length - 1; i >= 0; i--) {
73
+ const msg = session.messages[i];
74
+ if (msg.role !== "assistant")
75
+ continue;
76
+ const text = extractText(msg.content).trim();
77
+ if (text)
78
+ return text;
79
+ }
80
+ return "";
81
+ }
64
82
  /**
65
83
  * Wire an AbortSignal to abort a session.
66
84
  * Returns a cleanup function to remove the listener.
@@ -195,10 +213,22 @@ export async function runAgent(ctx, type, prompt, options) {
195
213
  const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
196
214
  session.setActiveToolsByName(activeTools);
197
215
  }
216
+ // Bind extensions so that session_start fires and extensions can initialize
217
+ // (e.g. loading credentials, setting up state). Placed after tool filtering
218
+ // so extension-provided skills/prompts from extendResourcesFromExtensions()
219
+ // respect the active tool set. All ExtensionBindings fields are optional.
220
+ await session.bindExtensions({
221
+ onError: (err) => {
222
+ options.onToolActivity?.({
223
+ type: "end",
224
+ toolName: `extension-error:${err.extensionPath}`,
225
+ });
226
+ },
227
+ });
198
228
  options.onSessionCreated?.(session);
199
229
  // Track turns for graceful max_turns enforcement
200
230
  let turnCount = 0;
201
- const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
231
+ const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
202
232
  let softLimitReached = false;
203
233
  let aborted = false;
204
234
  let currentMessageText = "";
@@ -249,7 +279,8 @@ export async function runAgent(ctx, type, prompt, options) {
249
279
  collector.unsubscribe();
250
280
  cleanupAbort();
251
281
  }
252
- return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
282
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
283
+ return { responseText, session, aborted, steered: softLimitReached };
253
284
  }
254
285
  /**
255
286
  * Send a new prompt to an existing session (resume).
@@ -273,7 +304,7 @@ export async function resumeAgent(session, prompt, options = {}) {
273
304
  unsubToolUse();
274
305
  cleanupAbort();
275
306
  }
276
- return collector.getText();
307
+ return collector.getText().trim() || getLastAssistantText(session);
277
308
  }
278
309
  /**
279
310
  * Send a steering message to a running subagent.
@@ -54,12 +54,12 @@ function loadFromDir(dir, agents, source) {
54
54
  skills: inheritField(fm.skills ?? fm.inherit_skills),
55
55
  model: str(fm.model),
56
56
  thinking: str(fm.thinking),
57
- maxTurns: positiveInt(fm.max_turns),
57
+ maxTurns: nonNegativeInt(fm.max_turns),
58
58
  systemPrompt: body.trim(),
59
59
  promptMode: fm.prompt_mode === "append" ? "append" : "replace",
60
- inheritContext: fm.inherit_context === true,
61
- runInBackground: fm.run_in_background === true,
62
- isolated: fm.isolated === true,
60
+ inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
61
+ runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
62
+ isolated: fm.isolated != null ? fm.isolated === true : undefined,
63
63
  memory: parseMemory(fm.memory),
64
64
  isolation: fm.isolation === "worktree" ? "worktree" : undefined,
65
65
  enabled: fm.enabled !== false, // default true; explicitly false disables
@@ -73,9 +73,9 @@ function loadFromDir(dir, agents, source) {
73
73
  function str(val) {
74
74
  return typeof val === "string" ? val : undefined;
75
75
  }
76
- /** Extract a positive integer or undefined. */
77
- function positiveInt(val) {
78
- return typeof val === "number" && val >= 1 ? val : undefined;
76
+ /** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
77
+ function nonNegativeInt(val) {
78
+ return typeof val === "number" && val >= 0 ? val : undefined;
79
79
  }
80
80
  /**
81
81
  * Parse a raw CSV field value into items, or undefined if absent/empty/"none".
package/dist/index.js CHANGED
@@ -15,11 +15,12 @@ import { join } from "node:path";
15
15
  import { Text } from "@mariozechner/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import { AgentManager } from "./agent-manager.js";
18
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
18
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
19
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
20
20
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
22
22
  import { GroupJoinManager } from "./group-join.js";
23
+ import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
23
24
  import { resolveModel } from "./model-resolver.js";
24
25
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
25
26
  import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
@@ -511,8 +512,7 @@ Guidelines:
511
512
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
512
513
  - Use thinking to control extended thinking level.
513
514
  - Use inherit_context if the agent needs the parent conversation history.
514
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
515
- - Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
515
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
516
516
  parameters: Type.Object({
517
517
  prompt: Type.String({
518
518
  description: "The task for the agent to perform.",
@@ -548,10 +548,6 @@ Guidelines:
548
548
  isolation: Type.Optional(Type.Literal("worktree", {
549
549
  description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
550
550
  })),
551
- join_mode: Type.Optional(Type.Union([
552
- Type.Literal("async"),
553
- Type.Literal("group"),
554
- ], { description: "Override join behavior for background agents. async: individual nudge on completion. group: hold and send one consolidated notification when all agents in the group complete. Default: smart (auto-groups 2+ background agents spawned in the same turn)." })),
555
551
  }),
556
552
  // ---- Custom rendering: Claude Code style ----
557
553
  renderCall(args, theme) {
@@ -650,27 +646,25 @@ Guidelines:
650
646
  const displayName = getDisplayName(subagentType);
651
647
  // Get agent config (if any)
652
648
  const customConfig = getAgentConfig(subagentType);
653
- // Resolve model if specified (supports exact "provider/modelId" or fuzzy match)
649
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
650
+ // Resolve model from agent config first; tool-call params only fill gaps.
654
651
  let model = ctx.model;
655
- const modelInput = params.model ?? customConfig?.model;
656
- if (modelInput) {
657
- const resolved = resolveModel(modelInput, ctx.modelRegistry);
652
+ if (resolvedConfig.modelInput) {
653
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
658
654
  if (typeof resolved === "string") {
659
- if (params.model)
660
- return textResult(resolved); // user-specified: error
655
+ if (resolvedConfig.modelFromParams)
656
+ return textResult(resolved);
661
657
  // config-specified: silent fallback to parent
662
658
  }
663
659
  else {
664
660
  model = resolved;
665
661
  }
666
662
  }
667
- // Resolve thinking: explicit param > custom config > undefined
668
- const thinking = (params.thinking ?? customConfig?.thinking);
669
- // Resolve spawn-time defaults from custom config (caller overrides)
670
- const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
671
- const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
672
- const isolated = params.isolated ?? customConfig?.isolated ?? false;
673
- const isolation = params.isolation ?? customConfig?.isolation;
663
+ const thinking = resolvedConfig.thinking;
664
+ const inheritContext = resolvedConfig.inheritContext;
665
+ const runInBackground = resolvedConfig.runInBackground;
666
+ const isolated = resolvedConfig.isolated;
667
+ const isolation = resolvedConfig.isolation;
674
668
  // Build display tags for non-default config
675
669
  const parentModelId = ctx.model?.id;
676
670
  const effectiveModelId = model?.id;
@@ -687,7 +681,7 @@ Guidelines:
687
681
  agentTags.push("isolated");
688
682
  if (isolation === "worktree")
689
683
  agentTags.push("worktree");
690
- const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
684
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
691
685
  // Shared base fields for all AgentDetails in this call
692
686
  const detailBase = {
693
687
  displayName,
@@ -709,7 +703,7 @@ Guidelines:
709
703
  if (!record) {
710
704
  return textResult(`Failed to resume agent "${params.resume}".`);
711
705
  }
712
- return textResult(record.result ?? record.error ?? "No output.", buildDetails(detailBase, record));
706
+ return textResult(record.result?.trim() || record.error?.trim() || "No output.", buildDetails(detailBase, record));
713
707
  }
714
708
  // Background execution
715
709
  if (runInBackground) {
@@ -729,7 +723,7 @@ Guidelines:
729
723
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
730
724
  description: params.description,
731
725
  model,
732
- maxTurns: params.max_turns,
726
+ maxTurns: effectiveMaxTurns,
733
727
  isolated,
734
728
  inheritContext,
735
729
  thinkingLevel: thinking,
@@ -739,16 +733,16 @@ Guidelines:
739
733
  });
740
734
  // Set output file + join mode synchronously after spawn, before the
741
735
  // event loop yields — onSessionCreated is async so this is safe.
742
- const joinMode = params.join_mode ?? defaultJoinMode;
736
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
743
737
  const record = manager.getRecord(id);
744
- if (record) {
738
+ if (record && joinMode) {
745
739
  record.joinMode = joinMode;
746
740
  record.toolCallId = toolCallId;
747
741
  record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
748
742
  writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
749
743
  }
750
- if (joinMode === 'async') {
751
- // Explicit async — not part of any batch
744
+ if (joinMode == null || joinMode === 'async') {
745
+ // Foreground/no join mode or explicit async — not part of any batch
752
746
  }
753
747
  else {
754
748
  // smart or group — add to current batch
@@ -824,7 +818,7 @@ Guidelines:
824
818
  const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
825
819
  description: params.description,
826
820
  model,
827
- maxTurns: params.max_turns,
821
+ maxTurns: effectiveMaxTurns,
828
822
  isolated,
829
823
  inheritContext,
830
824
  thinkingLevel: thinking,
@@ -851,7 +845,7 @@ Guidelines:
851
845
  if (tokenText)
852
846
  statsParts.push(tokenText);
853
847
  return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
854
- (record.result ?? "No output."), details);
848
+ (record.result?.trim() || "No output."), details);
855
849
  },
856
850
  });
857
851
  // ---- get_subagent_result tool ----
@@ -898,7 +892,7 @@ Guidelines:
898
892
  output += `Error: ${record.error}`;
899
893
  }
900
894
  else {
901
- output += record.result ?? "No output.";
895
+ output += record.result?.trim() || "No output.";
902
896
  }
903
897
  // Mark result as consumed — suppresses the completion notification
904
898
  if (record.status !== "running" && record.status !== "queued") {
@@ -0,0 +1,22 @@
1
+ import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
2
+ interface AgentInvocationParams {
3
+ model?: string;
4
+ thinking?: string;
5
+ max_turns?: number;
6
+ run_in_background?: boolean;
7
+ inherit_context?: boolean;
8
+ isolated?: boolean;
9
+ isolation?: IsolationMode;
10
+ }
11
+ export declare function resolveAgentInvocationConfig(agentConfig: AgentConfig | undefined, params: AgentInvocationParams): {
12
+ modelInput?: string;
13
+ modelFromParams: boolean;
14
+ thinking?: ThinkingLevel;
15
+ maxTurns?: number;
16
+ inheritContext: boolean;
17
+ runInBackground: boolean;
18
+ isolated: boolean;
19
+ isolation?: IsolationMode;
20
+ };
21
+ export declare function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined;
22
+ export {};
@@ -0,0 +1,15 @@
1
+ export function resolveAgentInvocationConfig(agentConfig, params) {
2
+ return {
3
+ modelInput: agentConfig?.model ?? params.model,
4
+ modelFromParams: agentConfig?.model == null && params.model != null,
5
+ thinking: (agentConfig?.thinking ?? params.thinking),
6
+ maxTurns: agentConfig?.maxTurns ?? params.max_turns,
7
+ inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
8
+ runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
9
+ isolated: agentConfig?.isolated ?? params.isolated ?? false,
10
+ isolation: agentConfig?.isolation ?? params.isolation,
11
+ };
12
+ }
13
+ export function resolveJoinMode(defaultJoinMode, runInBackground) {
14
+ return runInBackground ? defaultJoinMode : undefined;
15
+ }
package/dist/types.d.ts CHANGED
@@ -29,12 +29,12 @@ export interface AgentConfig {
29
29
  maxTurns?: number;
30
30
  systemPrompt: string;
31
31
  promptMode: "replace" | "append";
32
- /** Default for spawn: fork parent conversation */
33
- inheritContext: boolean;
34
- /** Default for spawn: run in background */
35
- runInBackground: boolean;
36
- /** Default for spawn: no extension tools */
37
- isolated: boolean;
32
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
33
+ inheritContext?: boolean;
34
+ /** Default for spawn: run in background. undefined = caller decides. */
35
+ runInBackground?: boolean;
36
+ /** Default for spawn: no extension tools. undefined = caller decides. */
37
+ isolated?: boolean;
38
38
  /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
39
39
  memory?: MemoryScope;
40
40
  /** Isolation mode — "worktree" runs the agent in a temporary git worktree */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -21,9 +21,9 @@
21
21
  "autonomous"
22
22
  ],
23
23
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.61.1",
25
- "@mariozechner/pi-coding-agent": "^0.61.1",
26
- "@mariozechner/pi-tui": "^0.61.1",
24
+ "@mariozechner/pi-ai": "^0.62.0",
25
+ "@mariozechner/pi-coding-agent": "^0.62.0",
26
+ "@mariozechner/pi-tui": "^0.62.0",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
@@ -27,10 +27,16 @@ const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
27
27
  /** Default max turns. undefined = unlimited (no turn limit). */
28
28
  let defaultMaxTurns: number | undefined;
29
29
 
30
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
31
+ export function normalizeMaxTurns(n: number | undefined): number | undefined {
32
+ if (n == null || n === 0) return undefined;
33
+ return Math.max(1, n);
34
+ }
35
+
30
36
  /** Get the default max turns value. undefined = unlimited. */
31
37
  export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
32
- /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
33
- export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = n != null ? Math.max(1, n) : undefined; }
38
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
39
+ export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
34
40
 
35
41
  /** Additional turns allowed after the soft limit steer message. */
36
42
  let graceTurns = 5;
@@ -123,6 +129,17 @@ function collectResponseText(session: AgentSession) {
123
129
  return { getText: () => text, unsubscribe };
124
130
  }
125
131
 
132
+ /** Get the last assistant text from the completed session history. */
133
+ function getLastAssistantText(session: AgentSession): string {
134
+ for (let i = session.messages.length - 1; i >= 0; i--) {
135
+ const msg = session.messages[i];
136
+ if (msg.role !== "assistant") continue;
137
+ const text = extractText(msg.content).trim();
138
+ if (text) return text;
139
+ }
140
+ return "";
141
+ }
142
+
126
143
  /**
127
144
  * Wire an AbortSignal to abort a session.
128
145
  * Returns a cleanup function to remove the listener.
@@ -275,11 +292,24 @@ export async function runAgent(
275
292
  session.setActiveToolsByName(activeTools);
276
293
  }
277
294
 
295
+ // Bind extensions so that session_start fires and extensions can initialize
296
+ // (e.g. loading credentials, setting up state). Placed after tool filtering
297
+ // so extension-provided skills/prompts from extendResourcesFromExtensions()
298
+ // respect the active tool set. All ExtensionBindings fields are optional.
299
+ await session.bindExtensions({
300
+ onError: (err) => {
301
+ options.onToolActivity?.({
302
+ type: "end",
303
+ toolName: `extension-error:${err.extensionPath}`,
304
+ });
305
+ },
306
+ });
307
+
278
308
  options.onSessionCreated?.(session);
279
309
 
280
310
  // Track turns for graceful max_turns enforcement
281
311
  let turnCount = 0;
282
- const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
312
+ const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
283
313
  let softLimitReached = false;
284
314
  let aborted = false;
285
315
 
@@ -333,7 +363,8 @@ export async function runAgent(
333
363
  cleanupAbort();
334
364
  }
335
365
 
336
- return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
366
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
367
+ return { responseText, session, aborted, steered: softLimitReached };
337
368
  }
338
369
 
339
370
  /**
@@ -362,7 +393,7 @@ export async function resumeAgent(
362
393
  cleanupAbort();
363
394
  }
364
395
 
365
- return collector.getText();
396
+ return collector.getText().trim() || getLastAssistantText(session);
366
397
  }
367
398
 
368
399
  /**
@@ -61,12 +61,12 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
61
61
  skills: inheritField(fm.skills ?? fm.inherit_skills),
62
62
  model: str(fm.model),
63
63
  thinking: str(fm.thinking) as ThinkingLevel | undefined,
64
- maxTurns: positiveInt(fm.max_turns),
64
+ maxTurns: nonNegativeInt(fm.max_turns),
65
65
  systemPrompt: body.trim(),
66
66
  promptMode: fm.prompt_mode === "append" ? "append" : "replace",
67
- inheritContext: fm.inherit_context === true,
68
- runInBackground: fm.run_in_background === true,
69
- isolated: fm.isolated === true,
67
+ inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
68
+ runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
69
+ isolated: fm.isolated != null ? fm.isolated === true : undefined,
70
70
  memory: parseMemory(fm.memory),
71
71
  isolation: fm.isolation === "worktree" ? "worktree" : undefined,
72
72
  enabled: fm.enabled !== false, // default true; explicitly false disables
@@ -83,9 +83,9 @@ function str(val: unknown): string | undefined {
83
83
  return typeof val === "string" ? val : undefined;
84
84
  }
85
85
 
86
- /** Extract a positive integer or undefined. */
87
- function positiveInt(val: unknown): number | undefined {
88
- return typeof val === "number" && val >= 1 ? val : undefined;
86
+ /** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
87
+ function nonNegativeInt(val: unknown): number | undefined {
88
+ return typeof val === "number" && val >= 0 ? val : undefined;
89
89
  }
90
90
 
91
91
  /**
package/src/index.ts CHANGED
@@ -17,14 +17,15 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@m
17
17
  import { Text } from "@mariozechner/pi-tui";
18
18
  import { Type } from "@sinclair/typebox";
19
19
  import { AgentManager } from "./agent-manager.js";
20
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
20
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
21
21
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
22
22
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
23
23
  import { loadCustomAgents } from "./custom-agents.js";
24
24
  import { GroupJoinManager } from "./group-join.js";
25
+ import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
25
26
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
26
27
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
27
- import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ThinkingLevel } from "./types.js";
28
+ import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
28
29
  import {
29
30
  type AgentActivity,
30
31
  type AgentDetails,
@@ -572,8 +573,7 @@ Guidelines:
572
573
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
573
574
  - Use thinking to control extended thinking level.
574
575
  - Use inherit_context if the agent needs the parent conversation history.
575
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
576
- - Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
576
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
577
577
  parameters: Type.Object({
578
578
  prompt: Type.String({
579
579
  description: "The task for the agent to perform.",
@@ -626,12 +626,6 @@ Guidelines:
626
626
  description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
627
627
  }),
628
628
  ),
629
- join_mode: Type.Optional(
630
- Type.Union([
631
- Type.Literal("async"),
632
- Type.Literal("group"),
633
- ], { description: "Override join behavior for background agents. async: individual nudge on completion. group: hold and send one consolidated notification when all agents in the group complete. Default: smart (auto-groups 2+ background agents spawned in the same turn)." }),
634
- ),
635
629
  }),
636
630
 
637
631
  // ---- Custom rendering: Claude Code style ----
@@ -743,27 +737,25 @@ Guidelines:
743
737
  // Get agent config (if any)
744
738
  const customConfig = getAgentConfig(subagentType);
745
739
 
746
- // Resolve model if specified (supports exact "provider/modelId" or fuzzy match)
740
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
741
+
742
+ // Resolve model from agent config first; tool-call params only fill gaps.
747
743
  let model = ctx.model;
748
- const modelInput = params.model ?? customConfig?.model;
749
- if (modelInput) {
750
- const resolved = resolveModel(modelInput, ctx.modelRegistry);
744
+ if (resolvedConfig.modelInput) {
745
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
751
746
  if (typeof resolved === "string") {
752
- if (params.model) return textResult(resolved); // user-specified: error
747
+ if (resolvedConfig.modelFromParams) return textResult(resolved);
753
748
  // config-specified: silent fallback to parent
754
749
  } else {
755
750
  model = resolved;
756
751
  }
757
752
  }
758
753
 
759
- // Resolve thinking: explicit param > custom config > undefined
760
- const thinking = (params.thinking ?? customConfig?.thinking) as ThinkingLevel | undefined;
761
-
762
- // Resolve spawn-time defaults from custom config (caller overrides)
763
- const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
764
- const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
765
- const isolated = params.isolated ?? customConfig?.isolated ?? false;
766
- const isolation = params.isolation ?? customConfig?.isolation;
754
+ const thinking = resolvedConfig.thinking;
755
+ const inheritContext = resolvedConfig.inheritContext;
756
+ const runInBackground = resolvedConfig.runInBackground;
757
+ const isolated = resolvedConfig.isolated;
758
+ const isolation = resolvedConfig.isolation;
767
759
 
768
760
  // Build display tags for non-default config
769
761
  const parentModelId = ctx.model?.id;
@@ -777,7 +769,7 @@ Guidelines:
777
769
  if (thinking) agentTags.push(`thinking: ${thinking}`);
778
770
  if (isolated) agentTags.push("isolated");
779
771
  if (isolation === "worktree") agentTags.push("worktree");
780
- const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
772
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
781
773
  // Shared base fields for all AgentDetails in this call
782
774
  const detailBase = {
783
775
  displayName,
@@ -801,7 +793,7 @@ Guidelines:
801
793
  return textResult(`Failed to resume agent "${params.resume}".`);
802
794
  }
803
795
  return textResult(
804
- record.result ?? record.error ?? "No output.",
796
+ record.result?.trim() || record.error?.trim() || "No output.",
805
797
  buildDetails(detailBase, record),
806
798
  );
807
799
  }
@@ -826,7 +818,7 @@ Guidelines:
826
818
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
827
819
  description: params.description,
828
820
  model,
829
- maxTurns: params.max_turns,
821
+ maxTurns: effectiveMaxTurns,
830
822
  isolated,
831
823
  inheritContext,
832
824
  thinkingLevel: thinking,
@@ -837,17 +829,17 @@ Guidelines:
837
829
 
838
830
  // Set output file + join mode synchronously after spawn, before the
839
831
  // event loop yields — onSessionCreated is async so this is safe.
840
- const joinMode: JoinMode = params.join_mode ?? defaultJoinMode;
832
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
841
833
  const record = manager.getRecord(id);
842
- if (record) {
834
+ if (record && joinMode) {
843
835
  record.joinMode = joinMode;
844
836
  record.toolCallId = toolCallId;
845
837
  record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
846
838
  writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
847
839
  }
848
840
 
849
- if (joinMode === 'async') {
850
- // Explicit async — not part of any batch
841
+ if (joinMode == null || joinMode === 'async') {
842
+ // Foreground/no join mode or explicit async — not part of any batch
851
843
  } else {
852
844
  // smart or group — add to current batch
853
845
  currentBatchAgents.push({ id, joinMode });
@@ -934,7 +926,7 @@ Guidelines:
934
926
  const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
935
927
  description: params.description,
936
928
  model,
937
- maxTurns: params.max_turns,
929
+ maxTurns: effectiveMaxTurns,
938
930
  isolated,
939
931
  inheritContext,
940
932
  thinkingLevel: thinking,
@@ -968,7 +960,7 @@ Guidelines:
968
960
  if (tokenText) statsParts.push(tokenText);
969
961
  return textResult(
970
962
  `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
971
- (record.result ?? "No output."),
963
+ (record.result?.trim() || "No output."),
972
964
  details,
973
965
  );
974
966
  },
@@ -1027,7 +1019,7 @@ Guidelines:
1027
1019
  } else if (record.status === "error") {
1028
1020
  output += `Error: ${record.error}`;
1029
1021
  } else {
1030
- output += record.result ?? "No output.";
1022
+ output += record.result?.trim() || "No output.";
1031
1023
  }
1032
1024
 
1033
1025
  // Mark result as consumed — suppresses the completion notification
@@ -0,0 +1,40 @@
1
+ import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
2
+
3
+ interface AgentInvocationParams {
4
+ model?: string;
5
+ thinking?: string;
6
+ max_turns?: number;
7
+ run_in_background?: boolean;
8
+ inherit_context?: boolean;
9
+ isolated?: boolean;
10
+ isolation?: IsolationMode;
11
+ }
12
+
13
+ export function resolveAgentInvocationConfig(
14
+ agentConfig: AgentConfig | undefined,
15
+ params: AgentInvocationParams,
16
+ ): {
17
+ modelInput?: string;
18
+ modelFromParams: boolean;
19
+ thinking?: ThinkingLevel;
20
+ maxTurns?: number;
21
+ inheritContext: boolean;
22
+ runInBackground: boolean;
23
+ isolated: boolean;
24
+ isolation?: IsolationMode;
25
+ } {
26
+ return {
27
+ modelInput: agentConfig?.model ?? params.model,
28
+ modelFromParams: agentConfig?.model == null && params.model != null,
29
+ thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
30
+ maxTurns: agentConfig?.maxTurns ?? params.max_turns,
31
+ inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
32
+ runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
33
+ isolated: agentConfig?.isolated ?? params.isolated ?? false,
34
+ isolation: agentConfig?.isolation ?? params.isolation,
35
+ };
36
+ }
37
+
38
+ export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
39
+ return runInBackground ? defaultJoinMode : undefined;
40
+ }
package/src/types.ts CHANGED
@@ -36,12 +36,12 @@ export interface AgentConfig {
36
36
  maxTurns?: number;
37
37
  systemPrompt: string;
38
38
  promptMode: "replace" | "append";
39
- /** Default for spawn: fork parent conversation */
40
- inheritContext: boolean;
41
- /** Default for spawn: run in background */
42
- runInBackground: boolean;
43
- /** Default for spawn: no extension tools */
44
- isolated: boolean;
39
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
40
+ inheritContext?: boolean;
41
+ /** Default for spawn: run in background. undefined = caller decides. */
42
+ runInBackground?: boolean;
43
+ /** Default for spawn: no extension tools. undefined = caller decides. */
44
+ isolated?: boolean;
45
45
  /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
46
46
  memory?: MemoryScope;
47
47
  /** Isolation mode — "worktree" runs the agent in a temporary git worktree */