@tintinweb/pi-subagents 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +21 -3
- package/package.json +2 -2
- package/src/group-join.ts +141 -0
- package/src/index.ts +162 -8
- package/src/types.ts +6 -0
- package/src/ui/agent-widget.ts +9 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.7] - 2026-03-08
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Widget crash in narrow terminals** — agent widget lines were not truncated to terminal width, causing `doRender` to throw when the tmux pane was narrower than the rendered content. All widget lines are now truncated using `truncateToWidth()` with the actual terminal column count.
|
|
12
|
+
|
|
13
|
+
## [0.2.6] - 2026-03-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Background task join strategies** — smart grouping of background agent completion notifications
|
|
17
|
+
- `smart` (default): 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification instead of individual nudges
|
|
18
|
+
- `async`: each agent notifies individually on completion (previous behavior)
|
|
19
|
+
- `group`: force grouping even for solo agents
|
|
20
|
+
- 30s timeout after first completion delivers partial results; 15s straggler re-batch window for remaining agents
|
|
21
|
+
- **`join_mode` parameter** on the `Agent` tool — override join strategy per agent (`"async"` or `"group"`)
|
|
22
|
+
- **Join mode setting** in `/agents` → Settings — configure the default join mode at runtime
|
|
23
|
+
- New `src/group-join.ts` — `GroupJoinManager` class for batched completion notifications
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `AgentRecord` now includes optional `groupId`, `joinMode`, and `resultConsumed` fields
|
|
27
|
+
- Background agent completion routing refactored: individual nudge logic extracted to `sendIndividualNudge()`, group delivery via `GroupJoinManager`
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **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
|
|
31
|
+
- **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
|
|
32
|
+
- **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)
|
|
33
|
+
|
|
8
34
|
## [0.2.5] - 2026-03-06
|
|
9
35
|
|
|
10
36
|
### Added
|
|
@@ -104,6 +130,8 @@ Initial release.
|
|
|
104
130
|
- **Thinking level** — per-agent extended thinking control
|
|
105
131
|
- **`/agent` and `/agents` commands**
|
|
106
132
|
|
|
133
|
+
[0.2.7]: https://github.com/tintinweb/pi-subagents/compare/v0.2.6...v0.2.7
|
|
134
|
+
[0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6
|
|
107
135
|
[0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5
|
|
108
136
|
[0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4
|
|
109
137
|
[0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ https://github.com/user-attachments/assets/5d1331e8-6d02-420b-b30a-dcbf838b1660
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
|
|
16
|
-
- **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)
|
|
17
17
|
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
|
|
18
18
|
- **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
|
|
19
19
|
- **Mid-run steering** — inject messages into running agents to redirect their work without restarting
|
|
@@ -162,6 +162,7 @@ Launch a sub-agent.
|
|
|
162
162
|
| `resume` | string | no | Agent ID to resume a previous session |
|
|
163
163
|
| `isolated` | boolean | no | No extension/MCP tools |
|
|
164
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) |
|
|
165
166
|
|
|
166
167
|
### `get_subagent_result`
|
|
167
168
|
|
|
@@ -194,7 +195,7 @@ The `/agents` command opens an interactive menu:
|
|
|
194
195
|
Running agents (2) — 1 running, 1 done ← only shown when agents exist
|
|
195
196
|
Custom agents (3) ← submenu: edit or delete agents
|
|
196
197
|
Create new agent ← manual wizard or AI-generated
|
|
197
|
-
Settings ← max concurrency, max turns, grace turns
|
|
198
|
+
Settings ← max concurrency, max turns, grace turns, join mode
|
|
198
199
|
|
|
199
200
|
Built-in (always available):
|
|
200
201
|
general-purpose · inherit
|
|
@@ -205,7 +206,7 @@ Built-in (always available):
|
|
|
205
206
|
|
|
206
207
|
- **Custom agents submenu** — select an agent to edit (opens editor) or delete
|
|
207
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)
|
|
208
|
-
- **Settings** — configure max concurrency, default max turns,
|
|
209
|
+
- **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
|
|
209
210
|
|
|
210
211
|
## Graceful Max Turns
|
|
211
212
|
|
|
@@ -228,6 +229,22 @@ Background agents are subject to a configurable concurrency limit (default: 4).
|
|
|
228
229
|
|
|
229
230
|
Foreground agents bypass the queue — they block the parent anyway.
|
|
230
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
|
+
|
|
231
248
|
## Architecture
|
|
232
249
|
|
|
233
250
|
```
|
|
@@ -237,6 +254,7 @@ src/
|
|
|
237
254
|
agent-types.ts # Agent type registry (built-in + custom), tool factories
|
|
238
255
|
agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
|
|
239
256
|
agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
|
|
257
|
+
group-join.ts # Group join manager: batched completion notifications with timeout
|
|
240
258
|
custom-agents.ts # Load custom agents from .pi/agents/*.md
|
|
241
259
|
prompts.ts # System prompts per agent type
|
|
242
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.7",
|
|
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": {
|
|
@@ -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
|
@@ -18,7 +18,8 @@ import { Text } from "@mariozechner/pi-tui";
|
|
|
18
18
|
import { Type } from "@sinclair/typebox";
|
|
19
19
|
import { AgentManager } from "./agent-manager.js";
|
|
20
20
|
import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
|
|
21
|
-
import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig } from "./types.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";
|
|
22
23
|
import { getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
23
24
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
24
25
|
import {
|
|
@@ -202,13 +203,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
203
|
// ---- Agent activity tracking + widget ----
|
|
203
204
|
const agentActivity = new Map<string, AgentActivity>();
|
|
204
205
|
|
|
205
|
-
//
|
|
206
|
-
|
|
206
|
+
// ---- Individual nudge helper (async join mode) ----
|
|
207
|
+
function sendIndividualNudge(record: AgentRecord) {
|
|
207
208
|
const displayName = getDisplayName(record.type);
|
|
208
209
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
209
|
-
|
|
210
210
|
const status = getStatusLabel(record.status, record.error);
|
|
211
|
-
|
|
212
211
|
const resultPreview = record.result
|
|
213
212
|
? record.result.length > 500
|
|
214
213
|
? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
|
|
@@ -218,7 +217,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
217
|
agentActivity.delete(record.id);
|
|
219
218
|
widget.markFinished(record.id);
|
|
220
219
|
|
|
221
|
-
// Poke the main agent so it processes the result (queues as follow-up if busy)
|
|
222
220
|
pi.sendUserMessage(
|
|
223
221
|
`Background agent completed: ${displayName} (${record.description})\n` +
|
|
224
222
|
`Agent ID: ${record.id} | Status: ${status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n\n` +
|
|
@@ -226,11 +224,128 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
224
|
{ deliverAs: "followUp" },
|
|
227
225
|
);
|
|
228
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();
|
|
229
296
|
});
|
|
230
297
|
|
|
231
298
|
// Live widget: show running agents above editor
|
|
232
299
|
const widget = new AgentWidget(manager, agentActivity);
|
|
233
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
|
+
|
|
234
349
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
235
350
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
236
351
|
widget.setUICtx(ctx.ui as UICtx);
|
|
@@ -288,7 +403,8 @@ Guidelines:
|
|
|
288
403
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
289
404
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
290
405
|
- Use thinking to control extended thinking level.
|
|
291
|
-
- 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.`,
|
|
292
408
|
parameters: Type.Object({
|
|
293
409
|
prompt: Type.String({
|
|
294
410
|
description: "The task for the agent to perform.",
|
|
@@ -336,6 +452,12 @@ Guidelines:
|
|
|
336
452
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
337
453
|
}),
|
|
338
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
|
+
),
|
|
339
461
|
}),
|
|
340
462
|
|
|
341
463
|
// ---- Custom rendering: Claude Code style ----
|
|
@@ -520,10 +642,25 @@ Guidelines:
|
|
|
520
642
|
...bgCallbacks,
|
|
521
643
|
});
|
|
522
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
|
+
|
|
523
661
|
agentActivity.set(id, bgState);
|
|
524
662
|
widget.ensureTimer();
|
|
525
663
|
widget.update();
|
|
526
|
-
const record = manager.getRecord(id);
|
|
527
664
|
const isQueued = record?.status === "queued";
|
|
528
665
|
return textResult(
|
|
529
666
|
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
@@ -670,6 +807,11 @@ Guidelines:
|
|
|
670
807
|
output += record.result ?? "No output.";
|
|
671
808
|
}
|
|
672
809
|
|
|
810
|
+
// Mark result as consumed — suppresses the completion notification
|
|
811
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
812
|
+
record.resultConsumed = true;
|
|
813
|
+
}
|
|
814
|
+
|
|
673
815
|
// Verbose: include full conversation
|
|
674
816
|
if (params.verbose && record.session) {
|
|
675
817
|
const conversation = getAgentConversation(record.session);
|
|
@@ -1090,6 +1232,7 @@ ${systemPrompt}
|
|
|
1090
1232
|
`Max concurrency (current: ${manager.getMaxConcurrent()})`,
|
|
1091
1233
|
`Default max turns (current: ${getDefaultMaxTurns()})`,
|
|
1092
1234
|
`Grace turns (current: ${getGraceTurns()})`,
|
|
1235
|
+
`Join mode (current: ${getDefaultJoinMode()})`,
|
|
1093
1236
|
]);
|
|
1094
1237
|
if (!choice) return;
|
|
1095
1238
|
|
|
@@ -1126,6 +1269,17 @@ ${systemPrompt}
|
|
|
1126
1269
|
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1127
1270
|
}
|
|
1128
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
|
+
}
|
|
1129
1283
|
}
|
|
1130
1284
|
}
|
|
1131
1285
|
|
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 {
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Uses the callback form of setWidget for themed rendering.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
8
9
|
import type { AgentManager } from "../agent-manager.js";
|
|
9
10
|
import type { SubagentType } from "../types.js";
|
|
10
11
|
import { getConfig } from "../agent-types.js";
|
|
@@ -262,17 +263,19 @@ export class AgentWidget {
|
|
|
262
263
|
this.widgetFrame++;
|
|
263
264
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
264
265
|
|
|
265
|
-
this.uiCtx.setWidget("agents", (
|
|
266
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
267
|
+
const w = tui.terminal.columns;
|
|
268
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
266
269
|
const headingColor = hasActive ? "accent" : "dim";
|
|
267
270
|
const headingIcon = hasActive ? "●" : "○";
|
|
268
|
-
const lines: string[] = [theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents")];
|
|
271
|
+
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
|
|
269
272
|
|
|
270
273
|
// --- Finished agents (shown first, dimmed) ---
|
|
271
274
|
for (let i = 0; i < finished.length; i++) {
|
|
272
275
|
const a = finished[i];
|
|
273
276
|
const isLast = !hasActive && i === finished.length - 1;
|
|
274
277
|
const connector = isLast ? "└─" : "├─";
|
|
275
|
-
lines.push(theme.fg("dim", connector) + " " + this.renderFinishedLine(a, theme));
|
|
278
|
+
lines.push(truncate(theme.fg("dim", connector) + " " + this.renderFinishedLine(a, theme)));
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
// --- Running agents ---
|
|
@@ -299,14 +302,14 @@ export class AgentWidget {
|
|
|
299
302
|
|
|
300
303
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
|
|
301
304
|
|
|
302
|
-
lines.push(theme.fg("dim", connector) + ` ${theme.fg("accent", frame)} ${theme.bold(name)} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`);
|
|
305
|
+
lines.push(truncate(theme.fg("dim", connector) + ` ${theme.fg("accent", frame)} ${theme.bold(name)} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`));
|
|
303
306
|
const indent = isLast ? " " : "│ ";
|
|
304
|
-
lines.push(theme.fg("dim", indent) + theme.fg("dim", ` ⎿ ${activity}`));
|
|
307
|
+
lines.push(truncate(theme.fg("dim", indent) + theme.fg("dim", ` ⎿ ${activity}`)));
|
|
305
308
|
}
|
|
306
309
|
|
|
307
310
|
// --- Queued agents (collapsed) ---
|
|
308
311
|
if (queued.length > 0) {
|
|
309
|
-
lines.push(theme.fg("dim", "└─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`);
|
|
312
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`));
|
|
310
313
|
}
|
|
311
314
|
|
|
312
315
|
return { render: () => lines, invalidate: () => {} };
|