@tintinweb/pi-subagents 0.2.3 → 0.2.6

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,6 +5,49 @@ 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.2.6] - 2026-03-07
9
+
10
+ ### Added
11
+ - **Background task join strategies** — smart grouping of background agent completion notifications
12
+ - `smart` (default): 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification instead of individual nudges
13
+ - `async`: each agent notifies individually on completion (previous behavior)
14
+ - `group`: force grouping even for solo agents
15
+ - 30s timeout after first completion delivers partial results; 15s straggler re-batch window for remaining agents
16
+ - **`join_mode` parameter** on the `Agent` tool — override join strategy per agent (`"async"` or `"group"`)
17
+ - **Join mode setting** in `/agents` → Settings — configure the default join mode at runtime
18
+ - New `src/group-join.ts` — `GroupJoinManager` class for batched completion notifications
19
+
20
+ ### Changed
21
+ - `AgentRecord` now includes optional `groupId`, `joinMode`, and `resultConsumed` fields
22
+ - Background agent completion routing refactored: individual nudge logic extracted to `sendIndividualNudge()`, group delivery via `GroupJoinManager`
23
+
24
+ ### Fixed
25
+ - **Debounce window race** — agents that complete during the 100ms batch debounce window are now deferred and retroactively fed into the group once it's registered, preventing split notifications (one individual + one partial group) and zombie groups
26
+ - **Solo agent swallowed notification** — if only one agent was spawned (no group formed) but it completed during the debounce window, its deferred notification is now sent when the batch finalizes
27
+ - **Duplicate notifications after polling** — calling `get_subagent_result` on a completed agent now marks its result as consumed, suppressing the subsequent completion notification (both individual and group)
28
+
29
+ ## [0.2.5] - 2026-03-06
30
+
31
+ ### Added
32
+ - **Interactive `/agents` menu** — single command replaces `/agent` and `/agents` with a full management wizard
33
+ - Browse and manage running agents
34
+ - Custom agents submenu — edit or delete existing agents
35
+ - Create new custom agents via manual wizard or AI-generated (with comprehensive frontmatter documentation for the generator)
36
+ - Settings: configure max concurrency, default max turns, and grace turns at runtime
37
+ - Built-in agent types shown with model info (e.g. `Explore · haiku`)
38
+ - Aligned formatting for agent lists
39
+ - **Configurable turn limits** — `defaultMaxTurns` and `graceTurns` are now runtime-adjustable via `/agents` → Settings
40
+ - Sub-menus return to main menu instead of exiting
41
+
42
+ ### Removed
43
+ - `/agent <type> <prompt>` command (use `Agent` tool directly, or create custom agents via `/agents`)
44
+
45
+ ## [0.2.4] - 2026-03-06
46
+
47
+ ### Added
48
+ - **Global custom agents** — agents in `~/.pi/agent/agents/*.md` are now discovered automatically and available across all projects
49
+ - Two-tier discovery hierarchy: project-level (`.pi/agents/`) overrides global (`~/.pi/agent/agents/`)
50
+
8
51
  ## [0.2.3] - 2026-03-05
9
52
 
10
53
  ### Added
@@ -82,6 +125,9 @@ Initial release.
82
125
  - **Thinking level** — per-agent extended thinking control
83
126
  - **`/agent` and `/agents` commands**
84
127
 
128
+ [0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6
129
+ [0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5
130
+ [0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4
85
131
  [0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3
86
132
  [0.2.2]: https://github.com/tintinweb/pi-subagents/compare/v0.2.1...v0.2.2
87
133
  [0.2.1]: https://github.com/tintinweb/pi-subagents/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -6,10 +6,14 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
6
6
 
7
7
  <img width="600" alt="pi-subagents screenshot" src="https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" />
8
8
 
9
+
10
+ https://github.com/user-attachments/assets/5d1331e8-6d02-420b-b30a-dcbf838b1660
11
+
12
+
9
13
  ## Features
10
14
 
11
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
12
- - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4)
16
+ - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
13
17
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
14
18
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
15
19
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
@@ -83,7 +87,16 @@ Completed results can be expanded (ctrl+o in pi) to show the full agent output i
83
87
 
84
88
  ## Custom Agents
85
89
 
86
- Define custom agent types by creating `.pi/agents/<name>.md` files. The filename becomes the agent type name.
90
+ Define custom agent types by creating `.md` files. The filename becomes the agent type name.
91
+
92
+ Custom agents are discovered from two locations (higher priority wins):
93
+
94
+ | Priority | Location | Scope |
95
+ |----------|----------|-------|
96
+ | 1 (highest) | `.pi/agents/<name>.md` | Project — per-repo agents |
97
+ | 2 | `~/.pi/agent/agents/<name>.md` | Global — available everywhere |
98
+
99
+ Project-level agents override global ones with the same name, so you can customize a global agent for a specific project.
87
100
 
88
101
  ### Example: `.pi/agents/auditor.md`
89
102
 
@@ -149,6 +162,7 @@ Launch a sub-agent.
149
162
  | `resume` | string | no | Agent ID to resume a previous session |
150
163
  | `isolated` | boolean | no | No extension/MCP tools |
151
164
  | `inherit_context` | boolean | no | Fork parent conversation into agent |
165
+ | `join_mode` | `"async"` \| `"group"` | no | Override join strategy for background completion notifications (default: smart) |
152
166
 
153
167
  ### `get_subagent_result`
154
168
 
@@ -173,15 +187,27 @@ Send a steering message to a running agent. The message interrupts after the cur
173
187
 
174
188
  | Command | Description |
175
189
  |---------|-------------|
176
- | `/agent <type> <prompt>` | Spawn a sub-agent interactively |
177
- | `/agents` | List all agents with status tree |
190
+ | `/agents` | Interactive agent management menu |
191
+
192
+ The `/agents` command opens an interactive menu:
178
193
 
179
194
  ```
180
- /agent Explore Find all TypeScript files that handle authentication
181
- /agent Plan Design a caching layer for the API
182
- /agent auditor Review the payment processing module
195
+ Running agents (2) 1 running, 1 done ← only shown when agents exist
196
+ Custom agents (3) ← submenu: edit or delete agents
197
+ Create new agent ← manual wizard or AI-generated
198
+ Settings ← max concurrency, max turns, grace turns, join mode
199
+
200
+ Built-in (always available):
201
+ general-purpose · inherit
202
+ Explore · haiku
203
+ Plan · inherit
204
+ ...
183
205
  ```
184
206
 
207
+ - **Custom agents submenu** — select an agent to edit (opens editor) or delete
208
+ - **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file)
209
+ - **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
210
+
185
211
  ## Graceful Max Turns
186
212
 
187
213
  Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
@@ -203,6 +229,22 @@ Background agents are subject to a configurable concurrency limit (default: 4).
203
229
 
204
230
  Foreground agents bypass the queue — they block the parent anyway.
205
231
 
232
+ ## Join Strategies
233
+
234
+ When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered:
235
+
236
+ | Mode | Behavior |
237
+ |------|----------|
238
+ | `smart` (default) | 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification. Solo agents notify individually. |
239
+ | `async` | Each agent sends its own notification on completion (original behavior). Best when results need incremental processing. |
240
+ | `group` | Force grouping even when spawning a single agent. Useful when you know more agents will follow. |
241
+
242
+ **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.
243
+
244
+ **Configuration:**
245
+ - Per-call: `Agent({ ..., join_mode: "async" })` overrides for that agent
246
+ - Global default: `/agents` → Settings → Join mode
247
+
206
248
  ## Architecture
207
249
 
208
250
  ```
@@ -212,6 +254,7 @@ src/
212
254
  agent-types.ts # Agent type registry (built-in + custom), tool factories
213
255
  agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
214
256
  agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
257
+ group-join.ts # Group join manager: batched completion notifications with timeout
215
258
  custom-agents.ts # Load custom agents from .pi/agents/*.md
216
259
  prompts.ts # System prompts per agent type
217
260
  context.ts # Parent conversation context for inherit_context
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.2.3",
4
- "description": "A pi extension providing autonomous sub-agents with Claude Code-style UI",
3
+ "version": "0.2.6",
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",
7
7
  "repository": {
@@ -23,10 +23,20 @@ import type { SubagentType, ThinkingLevel } from "./types.js";
23
23
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
24
24
 
25
25
  /** Default max turns to prevent subagents from looping indefinitely. */
26
- const DEFAULT_MAX_TURNS = 50;
26
+ let defaultMaxTurns = 50;
27
+
28
+ /** Get the default max turns value. */
29
+ export function getDefaultMaxTurns(): number { return defaultMaxTurns; }
30
+ /** Set the default max turns value (minimum 1). */
31
+ export function setDefaultMaxTurns(n: number): void { defaultMaxTurns = Math.max(1, n); }
27
32
 
28
33
  /** Additional turns allowed after the soft limit steer message. */
29
- const GRACE_TURNS = 5;
34
+ let graceTurns = 5;
35
+
36
+ /** Get the grace turns value. */
37
+ export function getGraceTurns(): number { return graceTurns; }
38
+ /** Set the grace turns value (minimum 1). */
39
+ export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
30
40
 
31
41
  /** Haiku model IDs to try for Explore agents (in preference order). */
32
42
  const HAIKU_MODEL_IDS = [
@@ -215,7 +225,7 @@ export async function runAgent(
215
225
 
216
226
  // Track turns for graceful max_turns enforcement
217
227
  let turnCount = 0;
218
- const maxTurns = options.maxTurns ?? customConfig?.maxTurns ?? DEFAULT_MAX_TURNS;
228
+ const maxTurns = options.maxTurns ?? customConfig?.maxTurns ?? defaultMaxTurns;
219
229
  let softLimitReached = false;
220
230
  let aborted = false;
221
231
 
@@ -226,7 +236,7 @@ export async function runAgent(
226
236
  if (!softLimitReached && turnCount >= maxTurns) {
227
237
  softLimitReached = true;
228
238
  session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
229
- } else if (softLimitReached && turnCount >= maxTurns + GRACE_TURNS) {
239
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
230
240
  aborted = true;
231
241
  session.abort();
232
242
  }
@@ -1,30 +1,43 @@
1
1
  /**
2
- * custom-agents.ts — Load user-defined agents from .pi/agents/*.md files.
2
+ * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global (~/.pi/agent/agents/) locations.
3
3
  */
4
4
 
5
5
  import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, basename } from "node:path";
8
+ import { homedir } from "node:os";
8
9
  import { SUBAGENT_TYPES, type CustomAgentConfig, type ThinkingLevel } from "./types.js";
9
10
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
11
 
11
12
  /**
12
- * Scan .pi/agents/*.md and return a map of custom agent configs.
13
- * Filename (without .md) becomes the agent name.
13
+ * Scan for custom agent .md files from multiple locations.
14
+ * Discovery hierarchy (higher priority wins):
15
+ * 1. Project: <cwd>/.pi/agents/*.md
16
+ * 2. Global: ~/.pi/agent/agents/*.md
17
+ *
18
+ * Project-level agents override global ones with the same name.
14
19
  */
15
20
  export function loadCustomAgents(cwd: string): Map<string, CustomAgentConfig> {
16
- const dir = join(cwd, ".pi", "agents");
17
- if (!existsSync(dir)) return new Map();
21
+ const globalDir = join(homedir(), ".pi", "agent", "agents");
22
+ const projectDir = join(cwd, ".pi", "agents");
23
+
24
+ const agents = new Map<string, CustomAgentConfig>();
25
+ loadFromDir(globalDir, agents); // lower priority
26
+ loadFromDir(projectDir, agents); // higher priority (overwrites)
27
+ return agents;
28
+ }
29
+
30
+ /** Load agent configs from a directory into the map. */
31
+ function loadFromDir(dir: string, agents: Map<string, CustomAgentConfig>): void {
32
+ if (!existsSync(dir)) return;
18
33
 
19
34
  let files: string[];
20
35
  try {
21
36
  files = readdirSync(dir).filter(f => f.endsWith(".md"));
22
37
  } catch {
23
- return new Map();
38
+ return;
24
39
  }
25
40
 
26
- const agents = new Map<string, CustomAgentConfig>();
27
-
28
41
  for (const file of files) {
29
42
  const name = basename(file, ".md");
30
43
  if ((SUBAGENT_TYPES as readonly string[]).includes(name)) continue;
@@ -54,8 +67,6 @@ export function loadCustomAgents(cwd: string): Map<string, CustomAgentConfig> {
54
67
  isolated: fm.isolated === true,
55
68
  });
56
69
  }
57
-
58
- return agents;
59
70
  }
60
71
 
61
72
  // ---- Field parsers ----
@@ -0,0 +1,141 @@
1
+ /**
2
+ * group-join.ts — Manages grouped background agent completion notifications.
3
+ *
4
+ * Instead of each agent individually nudging the main agent on completion,
5
+ * agents in a group are held until all complete (or a timeout fires),
6
+ * then a single consolidated notification is sent.
7
+ */
8
+
9
+ import type { AgentRecord } from "./types.js";
10
+
11
+ export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
12
+
13
+ interface AgentGroup {
14
+ groupId: string;
15
+ agentIds: Set<string>;
16
+ completedRecords: Map<string, AgentRecord>;
17
+ timeoutHandle?: ReturnType<typeof setTimeout>;
18
+ delivered: boolean;
19
+ /** Shorter timeout for stragglers after a partial delivery. */
20
+ isStraggler: boolean;
21
+ }
22
+
23
+ /** Default timeout: 30s after first completion in a group. */
24
+ const DEFAULT_TIMEOUT = 30_000;
25
+ /** Straggler re-batch timeout: 15s. */
26
+ const STRAGGLER_TIMEOUT = 15_000;
27
+
28
+ export class GroupJoinManager {
29
+ private groups = new Map<string, AgentGroup>();
30
+ private agentToGroup = new Map<string, string>();
31
+
32
+ constructor(
33
+ private deliverCb: DeliveryCallback,
34
+ private groupTimeout = DEFAULT_TIMEOUT,
35
+ ) {}
36
+
37
+ /** Register a group of agent IDs that should be joined. */
38
+ registerGroup(groupId: string, agentIds: string[]): void {
39
+ const group: AgentGroup = {
40
+ groupId,
41
+ agentIds: new Set(agentIds),
42
+ completedRecords: new Map(),
43
+ delivered: false,
44
+ isStraggler: false,
45
+ };
46
+ this.groups.set(groupId, group);
47
+ for (const id of agentIds) {
48
+ this.agentToGroup.set(id, groupId);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Called when an agent completes.
54
+ * Returns:
55
+ * - 'pass' — agent is not grouped, caller should send individual nudge
56
+ * - 'held' — result held, waiting for group completion
57
+ * - 'delivered' — this completion triggered the group notification
58
+ */
59
+ onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
60
+ const groupId = this.agentToGroup.get(record.id);
61
+ if (!groupId) return 'pass';
62
+
63
+ const group = this.groups.get(groupId);
64
+ if (!group || group.delivered) return 'pass';
65
+
66
+ group.completedRecords.set(record.id, record);
67
+
68
+ // All done — deliver immediately
69
+ if (group.completedRecords.size >= group.agentIds.size) {
70
+ this.deliver(group, false);
71
+ return 'delivered';
72
+ }
73
+
74
+ // First completion in this batch — start timeout
75
+ if (!group.timeoutHandle) {
76
+ const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
77
+ group.timeoutHandle = setTimeout(() => {
78
+ this.onTimeout(group);
79
+ }, timeout);
80
+ }
81
+
82
+ return 'held';
83
+ }
84
+
85
+ private onTimeout(group: AgentGroup): void {
86
+ if (group.delivered) return;
87
+ group.timeoutHandle = undefined;
88
+
89
+ // Partial delivery — some agents still running
90
+ const remaining = new Set<string>();
91
+ for (const id of group.agentIds) {
92
+ if (!group.completedRecords.has(id)) remaining.add(id);
93
+ }
94
+
95
+ // Clean up agentToGroup for delivered agents (they won't complete again)
96
+ for (const id of group.completedRecords.keys()) {
97
+ this.agentToGroup.delete(id);
98
+ }
99
+
100
+ // Deliver what we have
101
+ this.deliverCb([...group.completedRecords.values()], true);
102
+
103
+ // Set up straggler group for remaining agents
104
+ group.completedRecords.clear();
105
+ group.agentIds = remaining;
106
+ group.isStraggler = true;
107
+ // Timeout will be started when the next straggler completes
108
+ }
109
+
110
+ private deliver(group: AgentGroup, partial: boolean): void {
111
+ if (group.timeoutHandle) {
112
+ clearTimeout(group.timeoutHandle);
113
+ group.timeoutHandle = undefined;
114
+ }
115
+ group.delivered = true;
116
+ this.deliverCb([...group.completedRecords.values()], partial);
117
+ this.cleanupGroup(group.groupId);
118
+ }
119
+
120
+ private cleanupGroup(groupId: string): void {
121
+ const group = this.groups.get(groupId);
122
+ if (!group) return;
123
+ for (const id of group.agentIds) {
124
+ this.agentToGroup.delete(id);
125
+ }
126
+ this.groups.delete(groupId);
127
+ }
128
+
129
+ /** Check if an agent is in a group. */
130
+ isGrouped(agentId: string): boolean {
131
+ return this.agentToGroup.has(agentId);
132
+ }
133
+
134
+ dispose(): void {
135
+ for (const group of this.groups.values()) {
136
+ if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
137
+ }
138
+ this.groups.clear();
139
+ this.agentToGroup.clear();
140
+ }
141
+ }
package/src/index.ts CHANGED
@@ -7,17 +7,20 @@
7
7
  * steer_subagent — LLM-callable: send a steering message to a running agent
8
8
  *
9
9
  * Commands:
10
- * /agent <type> <prompt> User-invocable agent spawning
11
- * /agents — List all agents with status
10
+ * /agents Interactive agent management menu
12
11
  */
13
12
 
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
14
+ import { existsSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
15
17
  import { Text } from "@mariozechner/pi-tui";
16
18
  import { Type } from "@sinclair/typebox";
17
19
  import { AgentManager } from "./agent-manager.js";
18
- import { steerAgent, getAgentConversation } from "./agent-runner.js";
19
- import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig } from "./types.js";
20
- import { getConfig, getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents } from "./agent-types.js";
20
+ import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
21
+ import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig, type JoinMode, type AgentRecord } from "./types.js";
22
+ import { GroupJoinManager } from "./group-join.js";
23
+ import { getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
21
24
  import { loadCustomAgents } from "./custom-agents.js";
22
25
  import {
23
26
  AgentWidget,
@@ -200,13 +203,11 @@ export default function (pi: ExtensionAPI) {
200
203
  // ---- Agent activity tracking + widget ----
201
204
  const agentActivity = new Map<string, AgentActivity>();
202
205
 
203
- // Background completion: push notification into conversation
204
- const manager = new AgentManager((record) => {
206
+ // ---- Individual nudge helper (async join mode) ----
207
+ function sendIndividualNudge(record: AgentRecord) {
205
208
  const displayName = getDisplayName(record.type);
206
209
  const duration = formatDuration(record.startedAt, record.completedAt);
207
-
208
210
  const status = getStatusLabel(record.status, record.error);
209
-
210
211
  const resultPreview = record.result
211
212
  ? record.result.length > 500
212
213
  ? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
@@ -216,7 +217,6 @@ export default function (pi: ExtensionAPI) {
216
217
  agentActivity.delete(record.id);
217
218
  widget.markFinished(record.id);
218
219
 
219
- // Poke the main agent so it processes the result (queues as follow-up if busy)
220
220
  pi.sendUserMessage(
221
221
  `Background agent completed: ${displayName} (${record.description})\n` +
222
222
  `Agent ID: ${record.id} | Status: ${status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n\n` +
@@ -224,11 +224,128 @@ export default function (pi: ExtensionAPI) {
224
224
  { deliverAs: "followUp" },
225
225
  );
226
226
  widget.update();
227
+ }
228
+
229
+ /** Format a single agent's summary for grouped notification. */
230
+ function formatAgentSummary(record: AgentRecord): string {
231
+ const displayName = getDisplayName(record.type);
232
+ const duration = formatDuration(record.startedAt, record.completedAt);
233
+ const status = getStatusLabel(record.status, record.error);
234
+ const resultPreview = record.result
235
+ ? record.result.length > 300
236
+ ? record.result.slice(0, 300) + "\n...(truncated)"
237
+ : record.result
238
+ : "No output.";
239
+ return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | Tools: ${record.toolUses} | Duration: ${duration}\n ${resultPreview}`;
240
+ }
241
+
242
+ // ---- Group join manager ----
243
+ const groupJoin = new GroupJoinManager(
244
+ (records, partial) => {
245
+ // Filter out agents whose results were already consumed via get_subagent_result
246
+ const unconsumed = records.filter(r => !r.resultConsumed);
247
+
248
+ for (const r of records) {
249
+ agentActivity.delete(r.id);
250
+ widget.markFinished(r.id);
251
+ }
252
+
253
+ // If all results were already consumed, skip the notification entirely
254
+ if (unconsumed.length === 0) {
255
+ widget.update();
256
+ return;
257
+ }
258
+
259
+ const total = unconsumed.length;
260
+ const label = partial ? `${total} agent(s) finished (partial — others still running)` : `${total} agent(s) finished`;
261
+ const summary = unconsumed.map(r => formatAgentSummary(r)).join("\n\n");
262
+
263
+ pi.sendUserMessage(
264
+ `Background agent group completed: ${label}\n\n${summary}\n\nUse get_subagent_result for full output.`,
265
+ { deliverAs: "followUp" },
266
+ );
267
+ widget.update();
268
+ },
269
+ 30_000,
270
+ );
271
+
272
+ // Background completion: route through group join or send individual nudge
273
+ const manager = new AgentManager((record) => {
274
+ // Skip notification if result was already consumed via get_subagent_result
275
+ if (record.resultConsumed) {
276
+ agentActivity.delete(record.id);
277
+ widget.markFinished(record.id);
278
+ widget.update();
279
+ return;
280
+ }
281
+
282
+ // If this agent is pending batch finalization (debounce window still open),
283
+ // don't send an individual nudge — finalizeBatch will pick it up retroactively.
284
+ if (currentBatchAgents.some(a => a.id === record.id)) {
285
+ widget.update();
286
+ return;
287
+ }
288
+
289
+ const result = groupJoin.onAgentComplete(record);
290
+ if (result === 'pass') {
291
+ sendIndividualNudge(record);
292
+ }
293
+ // 'held' → do nothing, group will fire later
294
+ // 'delivered' → group callback already fired
295
+ widget.update();
227
296
  });
228
297
 
229
298
  // Live widget: show running agents above editor
230
299
  const widget = new AgentWidget(manager, agentActivity);
231
300
 
301
+ // ---- Join mode configuration ----
302
+ let defaultJoinMode: JoinMode = 'smart';
303
+ function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
304
+ function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
305
+
306
+ // ---- Batch tracking for smart join mode ----
307
+ // Collects background agent IDs spawned in the current turn for smart grouping.
308
+ // Uses a debounced timer: each new agent resets the 100ms window so that all
309
+ // parallel tool calls (which may be dispatched across multiple microtasks by the
310
+ // framework) are captured in the same batch.
311
+ let currentBatchAgents: { id: string; joinMode: JoinMode }[] = [];
312
+ let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined;
313
+ let batchCounter = 0;
314
+
315
+ /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
316
+ function finalizeBatch() {
317
+ batchFinalizeTimer = undefined;
318
+ const batchAgents = [...currentBatchAgents];
319
+ currentBatchAgents = [];
320
+
321
+ const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
322
+ if (smartAgents.length >= 2) {
323
+ const groupId = `batch-${++batchCounter}`;
324
+ const ids = smartAgents.map(a => a.id);
325
+ groupJoin.registerGroup(groupId, ids);
326
+ // Retroactively process agents that already completed during the debounce window.
327
+ // Their onComplete fired but was deferred (agent was in currentBatchAgents),
328
+ // so we feed them into the group now.
329
+ for (const id of ids) {
330
+ const record = manager.getRecord(id);
331
+ if (!record) continue;
332
+ record.groupId = groupId;
333
+ if (record.completedAt != null && !record.resultConsumed) {
334
+ groupJoin.onAgentComplete(record);
335
+ }
336
+ }
337
+ } else {
338
+ // No group formed — send individual nudges for any agents that completed
339
+ // during the debounce window and had their notification deferred.
340
+ for (const { id } of batchAgents) {
341
+ const record = manager.getRecord(id);
342
+ if (record?.completedAt != null && !record.resultConsumed) {
343
+ sendIndividualNudge(record);
344
+ }
345
+ }
346
+ }
347
+ }
348
+
232
349
  // Grab UI context from first tool execution + clear lingering widget on new turn
233
350
  pi.on("tool_execution_start", async (_event, ctx) => {
234
351
  widget.setUICtx(ctx.ui as UICtx);
@@ -256,7 +373,7 @@ export default function (pi: ExtensionAPI) {
256
373
  ...builtinDescs,
257
374
  ...(customDescs.length > 0 ? ["", "Custom types:", ...customDescs] : []),
258
375
  "",
259
- "Custom agents can be defined in .pi/agents/<name>.md — they are picked up automatically.",
376
+ "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones.",
260
377
  ].join("\n");
261
378
  };
262
379
 
@@ -286,7 +403,8 @@ Guidelines:
286
403
  - Use steer_subagent to send mid-run messages to a running background agent.
287
404
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
288
405
  - Use thinking to control extended thinking level.
289
- - Use inherit_context if the agent needs the parent conversation history.`,
406
+ - Use inherit_context if the agent needs the parent conversation history.
407
+ - 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.`,
290
408
  parameters: Type.Object({
291
409
  prompt: Type.String({
292
410
  description: "The task for the agent to perform.",
@@ -295,7 +413,7 @@ Guidelines:
295
413
  description: "A short (3-5 word) description of the task (shown in UI).",
296
414
  }),
297
415
  subagent_type: Type.String({
298
- description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md are also available.`,
416
+ description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
299
417
  }),
300
418
  model: Type.Optional(
301
419
  Type.String({
@@ -334,6 +452,12 @@ Guidelines:
334
452
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
335
453
  }),
336
454
  ),
455
+ join_mode: Type.Optional(
456
+ Type.Union([
457
+ Type.Literal("async"),
458
+ Type.Literal("group"),
459
+ ], { 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)." }),
460
+ ),
337
461
  }),
338
462
 
339
463
  // ---- Custom rendering: Claude Code style ----
@@ -518,10 +642,25 @@ Guidelines:
518
642
  ...bgCallbacks,
519
643
  });
520
644
 
645
+ // Determine join mode and track for batching
646
+ const joinMode: JoinMode = params.join_mode ?? defaultJoinMode;
647
+ const record = manager.getRecord(id);
648
+ if (record) record.joinMode = joinMode;
649
+
650
+ if (joinMode === 'async') {
651
+ // Explicit async — not part of any batch
652
+ } else {
653
+ // smart or group — add to current batch
654
+ currentBatchAgents.push({ id, joinMode });
655
+ // Debounce: reset timer on each new agent so parallel tool calls
656
+ // dispatched across multiple event loop ticks are captured together
657
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
658
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
659
+ }
660
+
521
661
  agentActivity.set(id, bgState);
522
662
  widget.ensureTimer();
523
663
  widget.update();
524
- const record = manager.getRecord(id);
525
664
  const isQueued = record?.status === "queued";
526
665
  return textResult(
527
666
  `Agent ${isQueued ? "queued" : "started"} in background.\n` +
@@ -668,6 +807,11 @@ Guidelines:
668
807
  output += record.result ?? "No output.";
669
808
  }
670
809
 
810
+ // Mark result as consumed — suppresses the completion notification
811
+ if (record.status !== "running" && record.status !== "queued") {
812
+ record.resultConsumed = true;
813
+ }
814
+
671
815
  // Verbose: include full conversation
672
816
  if (params.verbose && record.session) {
673
817
  const conversation = getAgentConversation(record.session);
@@ -717,139 +861,430 @@ Guidelines:
717
861
  },
718
862
  });
719
863
 
720
- // ---- /agent command ----
721
-
722
- pi.registerCommand("agent", {
723
- description: "Spawn a sub-agent: /agent <type> <prompt>",
724
- handler: async (args, ctx) => {
725
- const trimmed = args?.trim() ?? "";
726
-
727
- if (!trimmed) {
728
- const lines = [
729
- "Usage: /agent <type> <prompt>",
730
- "",
731
- "Agent types:",
732
- ...getAvailableTypes().map(
733
- (t) => ` ${t.padEnd(20)} ${getConfig(t).description}`,
734
- ),
735
- "",
736
- "Examples:",
737
- " /agent Explore Find all TypeScript files that handle authentication",
738
- " /agent Plan Design a caching layer for the API",
739
- " /agent general-purpose Refactor the auth module to use JWT",
740
- ];
741
- ctx.ui.notify(lines.join("\n"), "info");
742
- return;
743
- }
864
+ // ---- /agents interactive menu ----
744
865
 
745
- // Parse: first word is type, rest is prompt
746
- const spaceIdx = trimmed.indexOf(" ");
747
- if (spaceIdx === -1) {
748
- ctx.ui.notify(
749
- `Missing prompt. Usage: /agent <type> <prompt>\nTypes: ${getAvailableTypes().join(", ")}`,
750
- "warning",
751
- );
752
- return;
753
- }
866
+ const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
867
+ const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
754
868
 
755
- const typeName = trimmed.slice(0, spaceIdx);
756
- const prompt = trimmed.slice(spaceIdx + 1).trim();
869
+ /** Find the file path of a custom agent by name (project first, then global). */
870
+ function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined {
871
+ const projectPath = join(projectAgentsDir(), `${name}.md`);
872
+ if (existsSync(projectPath)) return { path: projectPath, location: "project" };
873
+ const personalPath = join(personalAgentsDir(), `${name}.md`);
874
+ if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
875
+ return undefined;
876
+ }
757
877
 
758
- if (!isValidType(typeName)) {
759
- ctx.ui.notify(
760
- `Unknown agent type: "${typeName}"\nValid types: ${getAvailableTypes().join(", ")}`,
761
- "warning",
762
- );
763
- return;
764
- }
878
+ /** Model label for display: built-in types have known defaults, custom agents show their config. */
879
+ const BUILTIN_MODEL_LABELS: Record<string, string> = {
880
+ "general-purpose": "inherit",
881
+ "Explore": "haiku",
882
+ "Plan": "inherit",
883
+ "statusline-setup": "inherit",
884
+ "claude-code-guide": "inherit",
885
+ };
765
886
 
766
- if (!prompt) {
767
- ctx.ui.notify("Missing prompt.", "warning");
768
- return;
769
- }
887
+ function getModelLabel(type: string): string {
888
+ const builtin = BUILTIN_MODEL_LABELS[type];
889
+ if (builtin) return builtin;
890
+ const custom = getCustomAgentConfig(type);
891
+ if (custom?.model) {
892
+ // Show short form: "anthropic/claude-haiku-4-5-20251001" → "haiku"
893
+ const id = custom.model.toLowerCase();
894
+ if (id.includes("haiku")) return "haiku";
895
+ if (id.includes("sonnet")) return "sonnet";
896
+ if (id.includes("opus")) return "opus";
897
+ return custom.model;
898
+ }
899
+ return "inherit";
900
+ }
770
901
 
771
- const displayName = getDisplayName(typeName);
772
- ctx.ui.notify(`Spawning ${displayName} agent...`, "info");
902
+ async function showAgentsMenu(ctx: ExtensionCommandContext) {
903
+ reloadCustomAgents();
904
+ const customNames = getCustomAgentNames();
773
905
 
774
- const customConfig = getCustomAgentConfig(typeName);
775
- const { systemPromptOverride, systemPromptAppend } = resolveCustomPrompt(customConfig);
906
+ // Build select options
907
+ const options: string[] = [];
776
908
 
777
- const record = await manager.spawnAndWait(pi, ctx, typeName, prompt, {
778
- description: prompt.slice(0, 40),
779
- thinkingLevel: customConfig?.thinking,
780
- systemPromptOverride,
781
- systemPromptAppend,
782
- });
909
+ // Running agents entry (only if there are active agents)
910
+ const agents = manager.listAgents();
911
+ if (agents.length > 0) {
912
+ const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
913
+ const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
914
+ options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
915
+ }
783
916
 
784
- if (record.status === "error") {
785
- ctx.ui.notify(`Agent failed: ${record.error}`, "warning");
786
- return;
787
- }
917
+ // Custom agents submenu (only if there are custom agents)
918
+ if (customNames.length > 0) {
919
+ options.push(`Custom agents (${customNames.length})`);
920
+ }
788
921
 
789
- const duration = formatDuration(record.startedAt, record.completedAt);
790
- const statusNote = getStatusNote(record.status);
791
-
792
- // Send the result as a message so it appears in the conversation
793
- pi.sendMessage(
794
- {
795
- customType: "agent-result",
796
- content: [
797
- {
798
- type: "text",
799
- text:
800
- `**${displayName}** agent completed in ${duration} (${record.toolUses} tool uses)${statusNote}\n\n` +
801
- (record.result ?? "No output."),
802
- },
803
- ],
804
- display: true,
805
- },
806
- { triggerTurn: false },
807
- );
808
- },
809
- });
922
+ // Actions
923
+ options.push("Create new agent");
924
+ options.push("Settings");
810
925
 
811
- // ---- /agents command ----
926
+ // Show built-in types below the select as informational text (like Claude does)
927
+ const maxBuiltin = Math.max(...SUBAGENT_TYPES.map(t => t.length));
928
+ const builtinLines = SUBAGENT_TYPES.map(t => {
929
+ const model = BUILTIN_MODEL_LABELS[t] ?? "inherit";
930
+ return ` ${t.padEnd(maxBuiltin)} · ${model}`;
931
+ });
812
932
 
813
- pi.registerCommand("agents", {
814
- description: "List all agents with status",
815
- handler: async (_args, ctx) => {
816
- const agents = manager.listAgents();
933
+ const noAgentsMsg = customNames.length === 0 && agents.length === 0
934
+ ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
935
+ "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
936
+ "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
937
+ : "";
817
938
 
818
- if (agents.length === 0) {
819
- ctx.ui.notify("No agents have been spawned yet.", "info");
820
- return;
939
+ ctx.ui.notify(
940
+ `${noAgentsMsg}Built-in (always available):\n${builtinLines.join("\n")}`,
941
+ "info",
942
+ );
943
+
944
+ const choice = await ctx.ui.select("Agents", options);
945
+ if (!choice) return;
946
+
947
+ if (choice.startsWith("Running agents (")) {
948
+ await showRunningAgents(ctx);
949
+ await showAgentsMenu(ctx);
950
+ } else if (choice.startsWith("Custom agents (")) {
951
+ await showCustomAgentsList(ctx);
952
+ await showAgentsMenu(ctx);
953
+ } else if (choice === "Create new agent") {
954
+ await showCreateWizard(ctx);
955
+ } else if (choice === "Settings") {
956
+ await showSettings(ctx);
957
+ await showAgentsMenu(ctx);
958
+ }
959
+ }
960
+
961
+ async function showCustomAgentsList(ctx: ExtensionCommandContext) {
962
+ const customNames = getCustomAgentNames();
963
+ if (customNames.length === 0) {
964
+ ctx.ui.notify("No custom agents.", "info");
965
+ return;
966
+ }
967
+
968
+ // Compute max width of "name · model" for alignment
969
+ const entries = customNames.map(name => {
970
+ const cfg = getCustomAgentConfig(name);
971
+ const model = getModelLabel(name);
972
+ const prefix = `${name} · ${model}`;
973
+ return { prefix, desc: cfg?.description ?? name };
974
+ });
975
+ const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
976
+
977
+ const options = entries.map(({ prefix, desc }) =>
978
+ `${prefix.padEnd(maxPrefix)} — ${desc}`,
979
+ );
980
+
981
+ const choice = await ctx.ui.select("Custom agents", options);
982
+ if (!choice) return;
983
+
984
+ const agentName = choice.split(" · ")[0];
985
+ if (getCustomAgentConfig(agentName)) {
986
+ await showAgentDetail(ctx, agentName);
987
+ }
988
+ }
989
+
990
+ async function showRunningAgents(ctx: ExtensionCommandContext) {
991
+ const agents = manager.listAgents();
992
+ if (agents.length === 0) {
993
+ ctx.ui.notify("No agents.", "info");
994
+ return;
995
+ }
996
+
997
+ // Show as a selectable list for potential future actions
998
+ const options = agents.map(a => {
999
+ const dn = getDisplayName(a.type);
1000
+ const dur = formatDuration(a.startedAt, a.completedAt);
1001
+ return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
1002
+ });
1003
+
1004
+ await ctx.ui.select("Running agents", options);
1005
+ }
1006
+
1007
+ async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
1008
+ const file = findAgentFile(name);
1009
+ if (!file) {
1010
+ ctx.ui.notify(`Agent file not found for "${name}".`, "warning");
1011
+ return;
1012
+ }
1013
+
1014
+ const choice = await ctx.ui.select(name, ["Edit", "Delete", "Back"]);
1015
+ if (!choice || choice === "Back") return;
1016
+
1017
+ if (choice === "Edit") {
1018
+ const content = readFileSync(file.path, "utf-8");
1019
+ const edited = await ctx.ui.editor(`Edit ${name}`, content);
1020
+ if (edited !== undefined && edited !== content) {
1021
+ const { writeFileSync } = await import("node:fs");
1022
+ writeFileSync(file.path, edited, "utf-8");
1023
+ reloadCustomAgents();
1024
+ ctx.ui.notify(`Updated ${file.path}`, "info");
821
1025
  }
1026
+ } else if (choice === "Delete") {
1027
+ const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
1028
+ if (confirmed) {
1029
+ unlinkSync(file.path);
1030
+ reloadCustomAgents();
1031
+ ctx.ui.notify(`Deleted ${file.path}`, "info");
1032
+ }
1033
+ }
1034
+ }
822
1035
 
823
- const lines: string[] = [];
824
- const counts: Record<string, number> = {};
825
- for (const a of agents) counts[a.status] = (counts[a.status] ?? 0) + 1;
1036
+ async function showCreateWizard(ctx: ExtensionCommandContext) {
1037
+ const location = await ctx.ui.select("Choose location", [
1038
+ "Project (.pi/agents/)",
1039
+ "Personal (~/.pi/agent/agents/)",
1040
+ ]);
1041
+ if (!location) return;
1042
+
1043
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1044
+
1045
+ const method = await ctx.ui.select("Creation method", [
1046
+ "Generate with Claude (recommended)",
1047
+ "Manual configuration",
1048
+ ]);
1049
+ if (!method) return;
1050
+
1051
+ if (method.startsWith("Generate")) {
1052
+ await showGenerateWizard(ctx, targetDir);
1053
+ } else {
1054
+ await showManualWizard(ctx, targetDir);
1055
+ }
1056
+ }
826
1057
 
827
- lines.push(
828
- `${agents.length} agent(s): ${counts.running ?? 0} running, ${(counts.completed ?? 0) + (counts.steered ?? 0)} completed, ${counts.stopped ?? 0} stopped, ${counts.aborted ?? 0} aborted, ${counts.error ?? 0} errored`,
829
- );
830
- lines.push("");
1058
+ async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
1059
+ const description = await ctx.ui.input("Describe what this agent should do");
1060
+ if (!description) return;
831
1061
 
832
- for (let i = 0; i < agents.length; i++) {
833
- const a = agents[i];
834
- const connector = i === agents.length - 1 ? "└─" : "├─";
835
- const displayName = getDisplayName(a.type);
836
- const duration = formatDuration(a.startedAt, a.completedAt);
1062
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
1063
+ if (!name) return;
837
1064
 
838
- lines.push(
839
- `${connector} ${displayName} (${a.description}) · ${a.toolUses} tool uses · ${a.status} · ${duration}`,
840
- );
1065
+ // Validate name
1066
+ if (isValidType(name) && !getCustomAgentConfig(name)) {
1067
+ ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
1068
+ return;
1069
+ }
1070
+
1071
+ if (!mkdirSync(targetDir, { recursive: true }) && !existsSync(targetDir)) {
1072
+ mkdirSync(targetDir, { recursive: true });
1073
+ }
1074
+
1075
+ const targetPath = join(targetDir, `${name}.md`);
1076
+ if (existsSync(targetPath)) {
1077
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1078
+ if (!overwrite) return;
1079
+ }
1080
+
1081
+ ctx.ui.notify("Generating agent definition...", "info");
1082
+
1083
+ const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
1084
+
1085
+ Write a markdown file to: ${targetPath}
1086
+
1087
+ The file format is a markdown file with YAML frontmatter and a system prompt body:
1088
+
1089
+ \`\`\`markdown
1090
+ ---
1091
+ description: <one-line description shown in UI>
1092
+ tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1093
+ model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
1094
+ thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
1095
+ max_turns: <optional max agentic turns, default 50. Omit for default>
1096
+ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
1097
+ extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
1098
+ skills: <true (inherit all), false (none). Default: true>
1099
+ inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
1100
+ run_in_background: <true to run in background by default. Default: false>
1101
+ isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
1102
+ ---
1103
+
1104
+ <system prompt body — instructions for the agent>
1105
+ \`\`\`
1106
+
1107
+ Guidelines for choosing settings:
1108
+ - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
1109
+ - For code modification tasks: include edit, write
1110
+ - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
1111
+ - Use prompt_mode: replace for fully custom agents with their own personality/instructions
1112
+ - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
1113
+ - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
1114
+ - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
1115
+
1116
+ Write the file using the write tool. Only write the file, nothing else.`;
1117
+
1118
+ const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1119
+ description: `Generate ${name} agent`,
1120
+ maxTurns: 5,
1121
+ });
1122
+
1123
+ if (record.status === "error") {
1124
+ ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
1125
+ return;
1126
+ }
1127
+
1128
+ reloadCustomAgents();
1129
+
1130
+ if (existsSync(targetPath)) {
1131
+ ctx.ui.notify(`Created ${targetPath}`, "info");
1132
+ } else {
1133
+ ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
1134
+ }
1135
+ }
1136
+
1137
+ async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
1138
+ // 1. Name
1139
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
1140
+ if (!name) return;
1141
+
1142
+ if (isValidType(name) && !getCustomAgentConfig(name)) {
1143
+ ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
1144
+ return;
1145
+ }
1146
+
1147
+ // 2. Description
1148
+ const description = await ctx.ui.input("Description (one line)");
1149
+ if (!description) return;
1150
+
1151
+ // 3. Tools
1152
+ const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
1153
+ if (!toolChoice) return;
1154
+
1155
+ let tools: string;
1156
+ if (toolChoice === "all") {
1157
+ tools = BUILTIN_TOOL_NAMES.join(", ");
1158
+ } else if (toolChoice === "none") {
1159
+ tools = "none";
1160
+ } else if (toolChoice.startsWith("read-only")) {
1161
+ tools = "read, bash, grep, find, ls";
1162
+ } else {
1163
+ const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
1164
+ if (!customTools) return;
1165
+ tools = customTools;
1166
+ }
1167
+
1168
+ // 4. Model
1169
+ const modelChoice = await ctx.ui.select("Model", [
1170
+ "inherit (parent model)",
1171
+ "haiku",
1172
+ "sonnet",
1173
+ "opus",
1174
+ "custom...",
1175
+ ]);
1176
+ if (!modelChoice) return;
1177
+
1178
+ let modelLine = "";
1179
+ if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
1180
+ else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
1181
+ else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
1182
+ else if (modelChoice === "custom...") {
1183
+ const customModel = await ctx.ui.input("Model (provider/modelId)");
1184
+ if (customModel) modelLine = `\nmodel: ${customModel}`;
1185
+ }
1186
+
1187
+ // 5. Thinking
1188
+ const thinkingChoice = await ctx.ui.select("Thinking level", [
1189
+ "inherit",
1190
+ "off",
1191
+ "minimal",
1192
+ "low",
1193
+ "medium",
1194
+ "high",
1195
+ "xhigh",
1196
+ ]);
1197
+ if (!thinkingChoice) return;
1198
+
1199
+ let thinkingLine = "";
1200
+ if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
1201
+
1202
+ // 6. System prompt
1203
+ const systemPrompt = await ctx.ui.editor("System prompt", "");
1204
+ if (systemPrompt === undefined) return;
1205
+
1206
+ // Build the file
1207
+ const content = `---
1208
+ description: ${description}
1209
+ tools: ${tools}${modelLine}${thinkingLine}
1210
+ prompt_mode: replace
1211
+ ---
1212
+
1213
+ ${systemPrompt}
1214
+ `;
1215
+
1216
+ mkdirSync(targetDir, { recursive: true });
1217
+ const targetPath = join(targetDir, `${name}.md`);
1218
+
1219
+ if (existsSync(targetPath)) {
1220
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1221
+ if (!overwrite) return;
1222
+ }
841
1223
 
842
- if (a.status === "error" && a.error) {
843
- const indent = i === agents.length - 1 ? " " : "│ ";
844
- lines.push(`${indent} ⎿ Error: ${a.error.slice(0, 100)}`);
1224
+ const { writeFileSync } = await import("node:fs");
1225
+ writeFileSync(targetPath, content, "utf-8");
1226
+ reloadCustomAgents();
1227
+ ctx.ui.notify(`Created ${targetPath}`, "info");
1228
+ }
1229
+
1230
+ async function showSettings(ctx: ExtensionCommandContext) {
1231
+ const choice = await ctx.ui.select("Settings", [
1232
+ `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1233
+ `Default max turns (current: ${getDefaultMaxTurns()})`,
1234
+ `Grace turns (current: ${getGraceTurns()})`,
1235
+ `Join mode (current: ${getDefaultJoinMode()})`,
1236
+ ]);
1237
+ if (!choice) return;
1238
+
1239
+ if (choice.startsWith("Max concurrency")) {
1240
+ const val = await ctx.ui.input("Max concurrent background agents", String(manager.getMaxConcurrent()));
1241
+ if (val) {
1242
+ const n = parseInt(val, 10);
1243
+ if (n >= 1) {
1244
+ manager.setMaxConcurrent(n);
1245
+ ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1246
+ } else {
1247
+ ctx.ui.notify("Must be a positive integer.", "warning");
1248
+ }
1249
+ }
1250
+ } else if (choice.startsWith("Default max turns")) {
1251
+ const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
1252
+ if (val) {
1253
+ const n = parseInt(val, 10);
1254
+ if (n >= 1) {
1255
+ setDefaultMaxTurns(n);
1256
+ ctx.ui.notify(`Default max turns set to ${n}`, "info");
1257
+ } else {
1258
+ ctx.ui.notify("Must be a positive integer.", "warning");
845
1259
  }
846
- if (a.session) {
847
- const indent = i === agents.length - 1 ? " " : "│ ";
848
- lines.push(`${indent} ⎿ ID: ${a.id} (resumable)`);
1260
+ }
1261
+ } else if (choice.startsWith("Grace turns")) {
1262
+ const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns()));
1263
+ if (val) {
1264
+ const n = parseInt(val, 10);
1265
+ if (n >= 1) {
1266
+ setGraceTurns(n);
1267
+ ctx.ui.notify(`Grace turns set to ${n}`, "info");
1268
+ } else {
1269
+ ctx.ui.notify("Must be a positive integer.", "warning");
849
1270
  }
850
1271
  }
1272
+ } else if (choice.startsWith("Join mode")) {
1273
+ const val = await ctx.ui.select("Default join mode for background agents", [
1274
+ "smart — auto-group 2+ agents in same turn (default)",
1275
+ "async — always notify individually",
1276
+ "group — always group background agents",
1277
+ ]);
1278
+ if (val) {
1279
+ const mode = val.split(" ")[0] as JoinMode;
1280
+ setDefaultJoinMode(mode);
1281
+ ctx.ui.notify(`Default join mode set to ${mode}`, "info");
1282
+ }
1283
+ }
1284
+ }
851
1285
 
852
- ctx.ui.notify(lines.join("\n"), "info");
853
- },
1286
+ pi.registerCommand("agents", {
1287
+ description: "Manage agents",
1288
+ handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
854
1289
  });
855
1290
  }
package/src/types.ts CHANGED
@@ -62,6 +62,8 @@ export interface CustomAgentConfig {
62
62
  isolated: boolean;
63
63
  }
64
64
 
65
+ export type JoinMode = 'async' | 'group' | 'smart';
66
+
65
67
  export interface AgentRecord {
66
68
  id: string;
67
69
  type: SubagentType;
@@ -75,6 +77,10 @@ export interface AgentRecord {
75
77
  session?: AgentSession;
76
78
  abortController?: AbortController;
77
79
  promise?: Promise<string>;
80
+ groupId?: string;
81
+ joinMode?: JoinMode;
82
+ /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
83
+ resultConsumed?: boolean;
78
84
  }
79
85
 
80
86
  export interface EnvInfo {