@tintinweb/pi-subagents 0.4.11 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,22 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.11] - 2026-03-18
8
+ ## [Unreleased]
9
9
 
10
- ### Fixed
11
- - **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`.
12
-
13
- ## [0.4.10] - 2026-03-18
10
+ ## [0.5.1] - 2026-03-24
14
11
 
15
12
  ### Changed
16
- - **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
17
- - **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress.
13
+ - **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.
14
+ - **`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.
15
+ - **`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`.
16
+
17
+ ### Fixed
18
+ - **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.
19
+ - **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely.
20
+
21
+ ## [0.5.0] - 2026-03-22
18
22
 
19
23
  ### Added
24
+ - **RPC stop handler** — new `subagents:rpc:stop` event bus RPC allows other extensions to stop running subagents by agent ID. Returns structured error ("Agent not found") on failure.
25
+ - **`abort` in `SpawnCapable` interface** — cross-extension RPC consumers can now stop agents, not just spawn them.
26
+ - **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress via `onTurnEnd` callback.
20
27
  - **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix.
21
28
  - **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs.
29
+ - **Auto-trigger parent turn on background completion** — background agent completion notifications now use `triggerTurn: true`, automatically prompting the parent agent to process results instead of waiting for user input.
30
+
31
+ ### Changed
32
+ - **Standardized RPC envelope** — cross-extension RPC handlers (`ping`, `spawn`, `stop`) now use a `handleRpc` wrapper that emits structured envelopes (`{ success: true, data }` / `{ success: false, error }`), matching pi-mono's `RpcResponse` convention.
33
+ - **Protocol versioning via ping** — ping reply now includes `{ version: PROTOCOL_VERSION }` (currently v2). Callers can detect version mismatches and warn users to update.
34
+ - **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
35
+ - **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`.
22
36
 
23
37
  ### Fixed
38
+ - **Tool name display** — `getAgentConversation` now reads `ToolCall.name` (the correct property) instead of `toolName`, resolving `[Tool: unknown]` in conversation viewer and verbose output.
24
39
  - **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo.
25
40
 
26
41
  ## [0.4.9] - 2026-03-18
@@ -325,6 +340,7 @@ Initial release.
325
340
  - **Thinking level** — per-agent extended thinking control
326
341
  - **`/agent` and `/agents` commands**
327
342
 
343
+ [0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0
328
344
  [0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
329
345
  [0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
330
346
  [0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7
package/README.md CHANGED
@@ -29,7 +29,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
29
29
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
30
30
  - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
31
31
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
32
- - **Cross-extension RPC** — other pi extensions can spawn subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load
32
+ - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on load
33
33
 
34
34
  ## Install
35
35
 
@@ -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
 
@@ -289,7 +287,9 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
289
287
 
290
288
  ## Cross-Extension RPC
291
289
 
292
- Other pi extensions can spawn subagents programmatically via the `pi.events` event bus, without importing this package directly.
290
+ Other pi extensions can spawn and stop subagents programmatically via the `pi.events` event bus, without importing this package directly.
291
+
292
+ All RPC replies use a standardized envelope: `{ success: true, data?: T }` on success, `{ success: false, error: string }` on failure.
293
293
 
294
294
  ### Discovery
295
295
 
@@ -297,19 +297,19 @@ Listen for `subagents:ready` to know when RPC handlers are available:
297
297
 
298
298
  ```typescript
299
299
  pi.events.on("subagents:ready", () => {
300
- // RPC handlers are registered — safe to call ping/spawn
300
+ // RPC handlers are registered — safe to call ping/spawn/stop
301
301
  });
302
302
  ```
303
303
 
304
304
  ### Ping
305
305
 
306
- Check if the subagents extension is loaded:
306
+ Check if the subagents extension is loaded and get the protocol version:
307
307
 
308
308
  ```typescript
309
309
  const requestId = crypto.randomUUID();
310
- const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, () => {
310
+ const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
311
311
  unsub();
312
- // Extension is alive
312
+ if (reply.success) console.log("Protocol version:", reply.data.version);
313
313
  });
314
314
  pi.events.emit("subagents:rpc:ping", { requestId });
315
315
  ```
@@ -322,10 +322,10 @@ Spawn a subagent and receive its ID:
322
322
  const requestId = crypto.randomUUID();
323
323
  const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => {
324
324
  unsub();
325
- if (reply.error) {
325
+ if (!reply.success) {
326
326
  console.error("Spawn failed:", reply.error);
327
327
  } else {
328
- console.log("Agent ID:", reply.id);
328
+ console.log("Agent ID:", reply.data.id);
329
329
  }
330
330
  });
331
331
  pi.events.emit("subagents:rpc:spawn", {
@@ -336,6 +336,19 @@ pi.events.emit("subagents:rpc:spawn", {
336
336
  });
337
337
  ```
338
338
 
339
+ ### Stop
340
+
341
+ Stop a running agent by ID:
342
+
343
+ ```typescript
344
+ const requestId = crypto.randomUUID();
345
+ const unsub = pi.events.on(`subagents:rpc:stop:reply:${requestId}`, (reply) => {
346
+ unsub();
347
+ if (!reply.success) console.error("Stop failed:", reply.error);
348
+ });
349
+ pi.events.emit("subagents:rpc:stop", { requestId, agentId: "agent-id-here" });
350
+ ```
351
+
339
352
  Reply channels are scoped per `requestId`, so concurrent requests don't interfere.
340
353
 
341
354
  ## Persistent Agent Memory
@@ -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.
@@ -198,7 +216,7 @@ export async function runAgent(ctx, type, prompt, options) {
198
216
  options.onSessionCreated?.(session);
199
217
  // Track turns for graceful max_turns enforcement
200
218
  let turnCount = 0;
201
- const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
219
+ const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
202
220
  let softLimitReached = false;
203
221
  let aborted = false;
204
222
  let currentMessageText = "";
@@ -249,7 +267,8 @@ export async function runAgent(ctx, type, prompt, options) {
249
267
  collector.unsubscribe();
250
268
  cleanupAbort();
251
269
  }
252
- return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
270
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
271
+ return { responseText, session, aborted, steered: softLimitReached };
253
272
  }
254
273
  /**
255
274
  * Send a new prompt to an existing session (resume).
@@ -273,7 +292,7 @@ export async function resumeAgent(session, prompt, options = {}) {
273
292
  unsubToolUse();
274
293
  cleanupAbort();
275
294
  }
276
- return collector.getText();
295
+ return collector.getText().trim() || getLastAssistantText(session);
277
296
  }
278
297
  /**
279
298
  * Send a steering message to a running subagent.
@@ -302,7 +321,7 @@ export function getAgentConversation(session) {
302
321
  if (c.type === "text" && c.text)
303
322
  textParts.push(c.text);
304
323
  else if (c.type === "toolCall")
305
- toolCalls.push(` Tool: ${c.toolName ?? "unknown"}`);
324
+ toolCalls.push(` Tool: ${c.name ?? c.toolName ?? "unknown"}`);
306
325
  }
307
326
  if (textParts.length > 0)
308
327
  parts.push(`[Assistant]: ${textParts.join("\n")}`);
@@ -1,17 +1,32 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping and spawn RPCs over the pi.events event bus,
4
+ * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
5
  * using per-request scoped reply channels.
6
+ *
7
+ * Reply envelope follows pi-mono convention:
8
+ * success → { success: true, data?: T }
9
+ * error → { success: false, error: string }
6
10
  */
7
11
  /** Minimal event bus interface needed by the RPC handlers. */
8
12
  export interface EventBus {
9
13
  on(event: string, handler: (data: unknown) => void): () => void;
10
14
  emit(event: string, data: unknown): void;
11
15
  }
12
- /** Minimal AgentManager interface needed by the spawn RPC. */
16
+ /** RPC reply envelope matches pi-mono's RpcResponse shape. */
17
+ export type RpcReply<T = void> = {
18
+ success: true;
19
+ data?: T;
20
+ } | {
21
+ success: false;
22
+ error: string;
23
+ };
24
+ /** RPC protocol version — bumped when the envelope or method contracts change. */
25
+ export declare const PROTOCOL_VERSION = 2;
26
+ /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
13
27
  export interface SpawnCapable {
14
28
  spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
29
+ abort(id: string): boolean;
15
30
  }
16
31
  export interface RpcDeps {
17
32
  events: EventBus;
@@ -22,9 +37,10 @@ export interface RpcDeps {
22
37
  export interface RpcHandle {
23
38
  unsubPing: () => void;
24
39
  unsubSpawn: () => void;
40
+ unsubStop: () => void;
25
41
  }
26
42
  /**
27
- * Register ping and spawn RPC handlers on the event bus.
43
+ * Register ping, spawn, and stop RPC handlers on the event bus.
28
44
  * Returns unsub functions for cleanup.
29
45
  */
30
46
  export declare function registerRpcHandlers(deps: RpcDeps): RpcHandle;
@@ -1,33 +1,54 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping and spawn RPCs over the pi.events event bus,
4
+ * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
5
  * using per-request scoped reply channels.
6
+ *
7
+ * Reply envelope follows pi-mono convention:
8
+ * success → { success: true, data?: T }
9
+ * error → { success: false, error: string }
6
10
  */
11
+ /** RPC protocol version — bumped when the envelope or method contracts change. */
12
+ export const PROTOCOL_VERSION = 2;
7
13
  /**
8
- * Register ping and spawn RPC handlers on the event bus.
14
+ * Wire a single RPC handler: listen on `channel`, run `fn(params)`,
15
+ * emit the reply envelope on `channel:reply:${requestId}`.
16
+ */
17
+ function handleRpc(events, channel, fn) {
18
+ return events.on(channel, async (raw) => {
19
+ const params = raw;
20
+ try {
21
+ const data = await fn(params);
22
+ const reply = { success: true };
23
+ if (data !== undefined)
24
+ reply.data = data;
25
+ events.emit(`${channel}:reply:${params.requestId}`, reply);
26
+ }
27
+ catch (err) {
28
+ events.emit(`${channel}:reply:${params.requestId}`, {
29
+ success: false, error: err?.message ?? String(err),
30
+ });
31
+ }
32
+ });
33
+ }
34
+ /**
35
+ * Register ping, spawn, and stop RPC handlers on the event bus.
9
36
  * Returns unsub functions for cleanup.
10
37
  */
11
38
  export function registerRpcHandlers(deps) {
12
39
  const { events, pi, getCtx, manager } = deps;
13
- const unsubPing = events.on("subagents:rpc:ping", (raw) => {
14
- const { requestId } = raw;
15
- events.emit(`subagents:rpc:ping:reply:${requestId}`, {});
40
+ const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
41
+ return { version: PROTOCOL_VERSION };
16
42
  });
17
- const unsubSpawn = events.on("subagents:rpc:spawn", async (raw) => {
18
- const { requestId, type, prompt, options } = raw;
43
+ const unsubSpawn = handleRpc(events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
19
44
  const ctx = getCtx();
20
- if (!ctx) {
21
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: "No active session" });
22
- return;
23
- }
24
- try {
25
- const id = manager.spawn(pi, ctx, type, prompt, options ?? {});
26
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { id });
27
- }
28
- catch (err) {
29
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: err.message });
30
- }
45
+ if (!ctx)
46
+ throw new Error("No active session");
47
+ return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
48
+ });
49
+ const unsubStop = handleRpc(events, "subagents:rpc:stop", ({ agentId }) => {
50
+ if (!manager.abort(agentId))
51
+ throw new Error("Agent not found");
31
52
  });
32
- return { unsubPing, unsubSpawn };
53
+ return { unsubPing, unsubSpawn, unsubStop };
33
54
  }
@@ -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";
@@ -254,7 +255,7 @@ export default function (pi) {
254
255
  content: notification + footer,
255
256
  display: true,
256
257
  details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
257
- }, { deliverAs: "followUp" });
258
+ }, { deliverAs: "followUp", triggerTurn: true });
258
259
  }
259
260
  function sendIndividualNudge(record) {
260
261
  agentActivity.delete(record.id);
@@ -290,7 +291,7 @@ export default function (pi) {
290
291
  content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
291
292
  display: true,
292
293
  details,
293
- }, { deliverAs: "followUp" });
294
+ }, { deliverAs: "followUp", triggerTurn: true });
294
295
  });
295
296
  widget.update();
296
297
  }, 30_000);
@@ -383,7 +384,7 @@ export default function (pi) {
383
384
  manager.clearCompleted(); // preserve existing behavior
384
385
  });
385
386
  pi.on("session_switch", () => { manager.clearCompleted(); });
386
- const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc } = registerRpcHandlers({
387
+ const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
387
388
  events: pi.events,
388
389
  pi,
389
390
  getCtx: () => currentCtx,
@@ -395,6 +396,7 @@ export default function (pi) {
395
396
  // If the session is going down, there's nothing left to consume agent results.
396
397
  pi.on("session_shutdown", async () => {
397
398
  unsubSpawnRpc();
399
+ unsubStopRpc();
398
400
  unsubPingRpc();
399
401
  currentCtx = undefined;
400
402
  delete globalThis[MANAGER_KEY];
@@ -510,8 +512,7 @@ Guidelines:
510
512
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
511
513
  - Use thinking to control extended thinking level.
512
514
  - Use inherit_context if the agent needs the parent conversation history.
513
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
514
- - 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).`,
515
516
  parameters: Type.Object({
516
517
  prompt: Type.String({
517
518
  description: "The task for the agent to perform.",
@@ -547,10 +548,6 @@ Guidelines:
547
548
  isolation: Type.Optional(Type.Literal("worktree", {
548
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.',
549
550
  })),
550
- join_mode: Type.Optional(Type.Union([
551
- Type.Literal("async"),
552
- Type.Literal("group"),
553
- ], { 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)." })),
554
551
  }),
555
552
  // ---- Custom rendering: Claude Code style ----
556
553
  renderCall(args, theme) {
@@ -649,27 +646,25 @@ Guidelines:
649
646
  const displayName = getDisplayName(subagentType);
650
647
  // Get agent config (if any)
651
648
  const customConfig = getAgentConfig(subagentType);
652
- // 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.
653
651
  let model = ctx.model;
654
- const modelInput = params.model ?? customConfig?.model;
655
- if (modelInput) {
656
- const resolved = resolveModel(modelInput, ctx.modelRegistry);
652
+ if (resolvedConfig.modelInput) {
653
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
657
654
  if (typeof resolved === "string") {
658
- if (params.model)
659
- return textResult(resolved); // user-specified: error
655
+ if (resolvedConfig.modelFromParams)
656
+ return textResult(resolved);
660
657
  // config-specified: silent fallback to parent
661
658
  }
662
659
  else {
663
660
  model = resolved;
664
661
  }
665
662
  }
666
- // Resolve thinking: explicit param > custom config > undefined
667
- const thinking = (params.thinking ?? customConfig?.thinking);
668
- // Resolve spawn-time defaults from custom config (caller overrides)
669
- const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
670
- const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
671
- const isolated = params.isolated ?? customConfig?.isolated ?? false;
672
- 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;
673
668
  // Build display tags for non-default config
674
669
  const parentModelId = ctx.model?.id;
675
670
  const effectiveModelId = model?.id;
@@ -686,7 +681,7 @@ Guidelines:
686
681
  agentTags.push("isolated");
687
682
  if (isolation === "worktree")
688
683
  agentTags.push("worktree");
689
- const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
684
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
690
685
  // Shared base fields for all AgentDetails in this call
691
686
  const detailBase = {
692
687
  displayName,
@@ -708,7 +703,7 @@ Guidelines:
708
703
  if (!record) {
709
704
  return textResult(`Failed to resume agent "${params.resume}".`);
710
705
  }
711
- 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));
712
707
  }
713
708
  // Background execution
714
709
  if (runInBackground) {
@@ -728,7 +723,7 @@ Guidelines:
728
723
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
729
724
  description: params.description,
730
725
  model,
731
- maxTurns: params.max_turns,
726
+ maxTurns: effectiveMaxTurns,
732
727
  isolated,
733
728
  inheritContext,
734
729
  thinkingLevel: thinking,
@@ -738,16 +733,16 @@ Guidelines:
738
733
  });
739
734
  // Set output file + join mode synchronously after spawn, before the
740
735
  // event loop yields — onSessionCreated is async so this is safe.
741
- const joinMode = params.join_mode ?? defaultJoinMode;
736
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
742
737
  const record = manager.getRecord(id);
743
- if (record) {
738
+ if (record && joinMode) {
744
739
  record.joinMode = joinMode;
745
740
  record.toolCallId = toolCallId;
746
741
  record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
747
742
  writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
748
743
  }
749
- if (joinMode === 'async') {
750
- // 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
751
746
  }
752
747
  else {
753
748
  // smart or group — add to current batch
@@ -823,7 +818,7 @@ Guidelines:
823
818
  const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
824
819
  description: params.description,
825
820
  model,
826
- maxTurns: params.max_turns,
821
+ maxTurns: effectiveMaxTurns,
827
822
  isolated,
828
823
  inheritContext,
829
824
  thinkingLevel: thinking,
@@ -850,7 +845,7 @@ Guidelines:
850
845
  if (tokenText)
851
846
  statsParts.push(tokenText);
852
847
  return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
853
- (record.result ?? "No output."), details);
848
+ (record.result?.trim() || "No output."), details);
854
849
  },
855
850
  });
856
851
  // ---- get_subagent_result tool ----
@@ -897,7 +892,7 @@ Guidelines:
897
892
  output += `Error: ${record.error}`;
898
893
  }
899
894
  else {
900
- output += record.result ?? "No output.";
895
+ output += record.result?.trim() || "No output.";
901
896
  }
902
897
  // Mark result as consumed — suppresses the completion notification
903
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 */
@@ -179,7 +179,7 @@ export class ConversationViewer {
179
179
  if (c.type === "text" && c.text)
180
180
  textParts.push(c.text);
181
181
  else if (c.type === "toolCall") {
182
- toolCalls.push(c.toolName ?? "unknown");
182
+ toolCalls.push(c.name ?? c.toolName ?? "unknown");
183
183
  }
184
184
  }
185
185
  if (needsSeparator)
@@ -159,7 +159,7 @@ describe("ConversationViewer", () => {
159
159
  role: "assistant",
160
160
  content: [
161
161
  { type: "text", text: "Let me check that." },
162
- { type: "toolCall", toolUseId: "t1", toolName: "very_long_tool_name_" + "x".repeat(200), input: {} },
162
+ { type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
163
163
  ],
164
164
  },
165
165
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.11",
3
+ "version": "0.5.1",
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.60.0",
25
- "@mariozechner/pi-coding-agent": "^0.60.0",
26
- "@mariozechner/pi-tui": "^0.60.0",
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": {
@@ -36,9 +36,9 @@
36
36
  "lint:fix": "biome check --fix src/ test/"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^20.0.0",
40
- "typescript": "^5.0.0",
41
39
  "@biomejs/biome": "^2.3.5",
40
+ "@types/node": "^25.5.0",
41
+ "typescript": "^5.0.0",
42
42
  "vitest": "^4.0.18"
43
43
  },
44
44
  "pi": {
@@ -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.
@@ -279,7 +296,7 @@ export async function runAgent(
279
296
 
280
297
  // Track turns for graceful max_turns enforcement
281
298
  let turnCount = 0;
282
- const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
299
+ const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
283
300
  let softLimitReached = false;
284
301
  let aborted = false;
285
302
 
@@ -333,7 +350,8 @@ export async function runAgent(
333
350
  cleanupAbort();
334
351
  }
335
352
 
336
- return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
353
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
354
+ return { responseText, session, aborted, steered: softLimitReached };
337
355
  }
338
356
 
339
357
  /**
@@ -362,7 +380,7 @@ export async function resumeAgent(
362
380
  cleanupAbort();
363
381
  }
364
382
 
365
- return collector.getText();
383
+ return collector.getText().trim() || getLastAssistantText(session);
366
384
  }
367
385
 
368
386
  /**
@@ -393,7 +411,7 @@ export function getAgentConversation(session: AgentSession): string {
393
411
  const toolCalls: string[] = [];
394
412
  for (const c of msg.content) {
395
413
  if (c.type === "text" && c.text) textParts.push(c.text);
396
- else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).toolName ?? "unknown"}`);
414
+ else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
397
415
  }
398
416
  if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
399
417
  if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping and spawn RPCs over the pi.events event bus,
4
+ * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
5
  * using per-request scoped reply channels.
6
+ *
7
+ * Reply envelope follows pi-mono convention:
8
+ * success → { success: true, data?: T }
9
+ * error → { success: false, error: string }
6
10
  */
7
11
 
8
12
  /** Minimal event bus interface needed by the RPC handlers. */
@@ -11,9 +15,18 @@ export interface EventBus {
11
15
  emit(event: string, data: unknown): void;
12
16
  }
13
17
 
14
- /** Minimal AgentManager interface needed by the spawn RPC. */
18
+ /** RPC reply envelope matches pi-mono's RpcResponse shape. */
19
+ export type RpcReply<T = void> =
20
+ | { success: true; data?: T }
21
+ | { success: false; error: string };
22
+
23
+ /** RPC protocol version — bumped when the envelope or method contracts change. */
24
+ export const PROTOCOL_VERSION = 2;
25
+
26
+ /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
15
27
  export interface SpawnCapable {
16
28
  spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
29
+ abort(id: string): boolean;
17
30
  }
18
31
 
19
32
  export interface RpcDeps {
@@ -26,36 +39,57 @@ export interface RpcDeps {
26
39
  export interface RpcHandle {
27
40
  unsubPing: () => void;
28
41
  unsubSpawn: () => void;
42
+ unsubStop: () => void;
29
43
  }
30
44
 
31
45
  /**
32
- * Register ping and spawn RPC handlers on the event bus.
46
+ * Wire a single RPC handler: listen on `channel`, run `fn(params)`,
47
+ * emit the reply envelope on `channel:reply:${requestId}`.
48
+ */
49
+ function handleRpc<P extends { requestId: string }>(
50
+ events: EventBus,
51
+ channel: string,
52
+ fn: (params: P) => unknown | Promise<unknown>,
53
+ ): () => void {
54
+ return events.on(channel, async (raw: unknown) => {
55
+ const params = raw as P;
56
+ try {
57
+ const data = await fn(params);
58
+ const reply: { success: true; data?: unknown } = { success: true };
59
+ if (data !== undefined) reply.data = data;
60
+ events.emit(`${channel}:reply:${params.requestId}`, reply);
61
+ } catch (err: any) {
62
+ events.emit(`${channel}:reply:${params.requestId}`, {
63
+ success: false, error: err?.message ?? String(err),
64
+ });
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Register ping, spawn, and stop RPC handlers on the event bus.
33
71
  * Returns unsub functions for cleanup.
34
72
  */
35
73
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
36
74
  const { events, pi, getCtx, manager } = deps;
37
75
 
38
- const unsubPing = events.on("subagents:rpc:ping", (raw: unknown) => {
39
- const { requestId } = raw as { requestId: string };
40
- events.emit(`subagents:rpc:ping:reply:${requestId}`, {});
76
+ const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
77
+ return { version: PROTOCOL_VERSION };
41
78
  });
42
79
 
43
- const unsubSpawn = events.on("subagents:rpc:spawn", async (raw: unknown) => {
44
- const { requestId, type, prompt, options } = raw as {
45
- requestId: string; type: string; prompt: string; options?: any;
46
- };
47
- const ctx = getCtx();
48
- if (!ctx) {
49
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: "No active session" });
50
- return;
51
- }
52
- try {
53
- const id = manager.spawn(pi, ctx, type, prompt, options ?? {});
54
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { id });
55
- } catch (err: any) {
56
- events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: err.message });
57
- }
58
- });
80
+ const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
81
+ events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
82
+ const ctx = getCtx();
83
+ if (!ctx) throw new Error("No active session");
84
+ return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
85
+ },
86
+ );
87
+
88
+ const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
89
+ events, "subagents:rpc:stop", ({ agentId }) => {
90
+ if (!manager.abort(agentId)) throw new Error("Agent not found");
91
+ },
92
+ );
59
93
 
60
- return { unsubPing, unsubSpawn };
94
+ return { unsubPing, unsubSpawn, unsubStop };
61
95
  }
@@ -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,
@@ -289,7 +290,7 @@ export default function (pi: ExtensionAPI) {
289
290
  content: notification + footer,
290
291
  display: true,
291
292
  details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
292
- }, { deliverAs: "followUp" });
293
+ }, { deliverAs: "followUp", triggerTurn: true });
293
294
  }
294
295
 
295
296
  function sendIndividualNudge(record: AgentRecord) {
@@ -326,7 +327,7 @@ export default function (pi: ExtensionAPI) {
326
327
  content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
327
328
  display: true,
328
329
  details,
329
- }, { deliverAs: "followUp" });
330
+ }, { deliverAs: "followUp", triggerTurn: true });
330
331
  });
331
332
  widget.update();
332
333
  },
@@ -431,7 +432,7 @@ export default function (pi: ExtensionAPI) {
431
432
 
432
433
  pi.on("session_switch", () => { manager.clearCompleted(); });
433
434
 
434
- const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc } = registerRpcHandlers({
435
+ const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
435
436
  events: pi.events,
436
437
  pi,
437
438
  getCtx: () => currentCtx,
@@ -445,6 +446,7 @@ export default function (pi: ExtensionAPI) {
445
446
  // If the session is going down, there's nothing left to consume agent results.
446
447
  pi.on("session_shutdown", async () => {
447
448
  unsubSpawnRpc();
449
+ unsubStopRpc();
448
450
  unsubPingRpc();
449
451
  currentCtx = undefined;
450
452
  delete (globalThis as any)[MANAGER_KEY];
@@ -571,8 +573,7 @@ Guidelines:
571
573
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
572
574
  - Use thinking to control extended thinking level.
573
575
  - Use inherit_context if the agent needs the parent conversation history.
574
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
575
- - 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).`,
576
577
  parameters: Type.Object({
577
578
  prompt: Type.String({
578
579
  description: "The task for the agent to perform.",
@@ -625,12 +626,6 @@ Guidelines:
625
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.',
626
627
  }),
627
628
  ),
628
- join_mode: Type.Optional(
629
- Type.Union([
630
- Type.Literal("async"),
631
- Type.Literal("group"),
632
- ], { 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)." }),
633
- ),
634
629
  }),
635
630
 
636
631
  // ---- Custom rendering: Claude Code style ----
@@ -742,27 +737,25 @@ Guidelines:
742
737
  // Get agent config (if any)
743
738
  const customConfig = getAgentConfig(subagentType);
744
739
 
745
- // 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.
746
743
  let model = ctx.model;
747
- const modelInput = params.model ?? customConfig?.model;
748
- if (modelInput) {
749
- const resolved = resolveModel(modelInput, ctx.modelRegistry);
744
+ if (resolvedConfig.modelInput) {
745
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
750
746
  if (typeof resolved === "string") {
751
- if (params.model) return textResult(resolved); // user-specified: error
747
+ if (resolvedConfig.modelFromParams) return textResult(resolved);
752
748
  // config-specified: silent fallback to parent
753
749
  } else {
754
750
  model = resolved;
755
751
  }
756
752
  }
757
753
 
758
- // Resolve thinking: explicit param > custom config > undefined
759
- const thinking = (params.thinking ?? customConfig?.thinking) as ThinkingLevel | undefined;
760
-
761
- // Resolve spawn-time defaults from custom config (caller overrides)
762
- const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
763
- const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
764
- const isolated = params.isolated ?? customConfig?.isolated ?? false;
765
- 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;
766
759
 
767
760
  // Build display tags for non-default config
768
761
  const parentModelId = ctx.model?.id;
@@ -776,7 +769,7 @@ Guidelines:
776
769
  if (thinking) agentTags.push(`thinking: ${thinking}`);
777
770
  if (isolated) agentTags.push("isolated");
778
771
  if (isolation === "worktree") agentTags.push("worktree");
779
- const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
772
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
780
773
  // Shared base fields for all AgentDetails in this call
781
774
  const detailBase = {
782
775
  displayName,
@@ -800,7 +793,7 @@ Guidelines:
800
793
  return textResult(`Failed to resume agent "${params.resume}".`);
801
794
  }
802
795
  return textResult(
803
- record.result ?? record.error ?? "No output.",
796
+ record.result?.trim() || record.error?.trim() || "No output.",
804
797
  buildDetails(detailBase, record),
805
798
  );
806
799
  }
@@ -825,7 +818,7 @@ Guidelines:
825
818
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
826
819
  description: params.description,
827
820
  model,
828
- maxTurns: params.max_turns,
821
+ maxTurns: effectiveMaxTurns,
829
822
  isolated,
830
823
  inheritContext,
831
824
  thinkingLevel: thinking,
@@ -836,17 +829,17 @@ Guidelines:
836
829
 
837
830
  // Set output file + join mode synchronously after spawn, before the
838
831
  // event loop yields — onSessionCreated is async so this is safe.
839
- const joinMode: JoinMode = params.join_mode ?? defaultJoinMode;
832
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
840
833
  const record = manager.getRecord(id);
841
- if (record) {
834
+ if (record && joinMode) {
842
835
  record.joinMode = joinMode;
843
836
  record.toolCallId = toolCallId;
844
837
  record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
845
838
  writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
846
839
  }
847
840
 
848
- if (joinMode === 'async') {
849
- // 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
850
843
  } else {
851
844
  // smart or group — add to current batch
852
845
  currentBatchAgents.push({ id, joinMode });
@@ -933,7 +926,7 @@ Guidelines:
933
926
  const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
934
927
  description: params.description,
935
928
  model,
936
- maxTurns: params.max_turns,
929
+ maxTurns: effectiveMaxTurns,
937
930
  isolated,
938
931
  inheritContext,
939
932
  thinkingLevel: thinking,
@@ -967,7 +960,7 @@ Guidelines:
967
960
  if (tokenText) statsParts.push(tokenText);
968
961
  return textResult(
969
962
  `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
970
- (record.result ?? "No output."),
963
+ (record.result?.trim() || "No output."),
971
964
  details,
972
965
  );
973
966
  },
@@ -1026,7 +1019,7 @@ Guidelines:
1026
1019
  } else if (record.status === "error") {
1027
1020
  output += `Error: ${record.error}`;
1028
1021
  } else {
1029
- output += record.result ?? "No output.";
1022
+ output += record.result?.trim() || "No output.";
1030
1023
  }
1031
1024
 
1032
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 */
@@ -191,7 +191,7 @@ export class ConversationViewer implements Component {
191
191
  for (const c of msg.content) {
192
192
  if (c.type === "text" && c.text) textParts.push(c.text);
193
193
  else if (c.type === "toolCall") {
194
- toolCalls.push((c as any).toolName ?? "unknown");
194
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
195
195
  }
196
196
  }
197
197
  if (needsSeparator) lines.push(th.fg("dim", "───"));