@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 +46 -0
- package/README.md +50 -7
- package/package.json +2 -2
- package/src/agent-runner.ts +14 -4
- package/src/custom-agents.ts +21 -10
- package/src/group-join.ts +141 -0
- package/src/index.ts +562 -127
- package/src/types.ts +6 -0
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 `.
|
|
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
|
-
| `/
|
|
177
|
-
|
|
190
|
+
| `/agents` | Interactive agent management menu |
|
|
191
|
+
|
|
192
|
+
The `/agents` command opens an interactive menu:
|
|
178
193
|
|
|
179
194
|
```
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
4
|
-
"description": "A pi extension
|
|
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": {
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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 +
|
|
239
|
+
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
230
240
|
aborted = true;
|
|
231
241
|
session.abort();
|
|
232
242
|
}
|
package/src/custom-agents.ts
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* custom-agents.ts — Load user-defined agents from .pi/agents
|
|
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
|
|
13
|
-
*
|
|
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
|
|
17
|
-
|
|
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
|
|
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
|
-
* /
|
|
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 {
|
|
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
|
-
//
|
|
204
|
-
|
|
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
|
-
// ---- /
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
772
|
-
|
|
902
|
+
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
903
|
+
reloadCustomAgents();
|
|
904
|
+
const customNames = getCustomAgentNames();
|
|
773
905
|
|
|
774
|
-
|
|
775
|
-
|
|
906
|
+
// Build select options
|
|
907
|
+
const options: string[] = [];
|
|
776
908
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
790
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
828
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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 {
|