@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 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, and grace turns at runtime
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.5",
4
- "description": "A pi extension providing autonomous sub-agents with Claude Code-style UI",
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
- // Background completion: push notification into conversation
206
- const manager = new AgentManager((record) => {
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 {
@@ -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", (_tui, theme) => {
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: () => {} };