@esso0428/pi-subagents 0.17.18 → 0.17.20
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 +10 -0
- package/README.md +34 -2
- package/package.json +1 -1
- package/src/agent-manager.ts +9 -0
- package/src/agent-runner.ts +1 -0
- package/src/index.ts +192 -60
- package/src/types.ts +6 -0
- package/src/wait-group.ts +190 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.20] - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Removed blocking waits from `get_subagent_result`**: the legacy `wait` parameter is now a non-blocking compatibility flag. Running or queued agents return their current status immediately, while background and wait-group completion notifications remain the expandable output surface for finished results.
|
|
14
|
+
|
|
15
|
+
## [0.17.19] - 2026-09-30
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Added nonblocking subagent wait groups**: `Agent` now accepts `wait: true` for background notification grouping, with `wait_group` and `wait_group_done` for explicit multi-agent groups, plus the `subagent_wait_group` tool to create, rename, and seal groups. A sealed wait group emits one completion notification after all members reach a terminal state, avoiding blocking waits and notification spam.
|
|
19
|
+
|
|
10
20
|
## [0.17.18] - 2026-09-30
|
|
11
21
|
|
|
12
22
|
### Changed
|
package/README.md
CHANGED
|
@@ -61,6 +61,38 @@ Agent({
|
|
|
61
61
|
|
|
62
62
|
Foreground agents block until complete and return results inline. Background agents return an ID immediately and notify you on completion.
|
|
63
63
|
|
|
64
|
+
### Wait groups
|
|
65
|
+
|
|
66
|
+
Use `wait: true` on background agents to suppress individual completion notifications and receive one grouped notification instead:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
const group = subagent_wait_group({
|
|
70
|
+
action: "create",
|
|
71
|
+
summary: "Compare notification group API designs",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
Agent({
|
|
75
|
+
subagent_type: "Explore",
|
|
76
|
+
prompt: "Design a group-handle API",
|
|
77
|
+
description: "Design group handle",
|
|
78
|
+
run_in_background: true,
|
|
79
|
+
wait: true,
|
|
80
|
+
wait_group: group.group_id,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
Agent({
|
|
84
|
+
subagent_type: "Plan",
|
|
85
|
+
prompt: "Design a batch API",
|
|
86
|
+
description: "Design batch API",
|
|
87
|
+
run_in_background: true,
|
|
88
|
+
wait: true,
|
|
89
|
+
wait_group: group.group_id,
|
|
90
|
+
wait_group_done: true,
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`subagent_wait_group` supports `create`, `update`, and `seal`. A sealed group sends one notification after every member reaches a terminal state (`completed`, `steered`, `error`, `stopped`, or `aborted`). If `wait: true` is used without `wait_group`, the extension creates and seals a one-agent implicit group.
|
|
95
|
+
|
|
64
96
|
### Scheduling
|
|
65
97
|
|
|
66
98
|
Add a `schedule` field to register the agent to fire later instead of running now:
|
|
@@ -400,10 +432,10 @@ Check status and retrieve results from a background agent.
|
|
|
400
432
|
| Parameter | Type | Required | Description |
|
|
401
433
|
|-----------|------|----------|-------------|
|
|
402
434
|
| `agent_id` | string | yes | Agent ID to check |
|
|
403
|
-
| `wait` | boolean | no |
|
|
435
|
+
| `wait` | boolean | no | Deprecated compatibility flag; never blocks |
|
|
404
436
|
| `verbose` | boolean | no | Include full conversation log |
|
|
405
437
|
|
|
406
|
-
|
|
438
|
+
`get_subagent_result` never blocks. If an agent is still running or queued, the tool returns the current status immediately; wait for the background or wait-group completion notification for the expandable final output.
|
|
407
439
|
|
|
408
440
|
### `steer_subagent`
|
|
409
441
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.20",
|
|
4
4
|
"description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
|
|
5
5
|
"author": "ESSO0428",
|
|
6
6
|
"repository": {
|
package/src/agent-manager.ts
CHANGED
|
@@ -184,6 +184,8 @@ interface SpawnOptions {
|
|
|
184
184
|
cwd?: string;
|
|
185
185
|
/** Resolved invocation snapshot captured for UI display. */
|
|
186
186
|
invocation?: AgentInvocation;
|
|
187
|
+
/** Explicit nonblocking completion wait group for this background agent. */
|
|
188
|
+
waitGroupId?: string;
|
|
187
189
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
188
190
|
signal?: AbortSignal;
|
|
189
191
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
@@ -376,6 +378,7 @@ export class AgentManager {
|
|
|
376
378
|
// have no inline surface — stay visible instead of vanishing.
|
|
377
379
|
isBackground: options.isBackground,
|
|
378
380
|
invocation: options.invocation,
|
|
381
|
+
waitGroupId: options.waitGroupId,
|
|
379
382
|
};
|
|
380
383
|
this.agents.set(id, record);
|
|
381
384
|
this.recoveryCwds.set(id, ctx.cwd);
|
|
@@ -807,6 +810,9 @@ export class AgentManager {
|
|
|
807
810
|
record.status = "stopped";
|
|
808
811
|
record.completedAt = Date.now();
|
|
809
812
|
this.checkpoint(record);
|
|
813
|
+
if (record.waitGroupId) {
|
|
814
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
815
|
+
}
|
|
810
816
|
return true;
|
|
811
817
|
}
|
|
812
818
|
|
|
@@ -885,6 +891,9 @@ export class AgentManager {
|
|
|
885
891
|
record.status = "stopped";
|
|
886
892
|
record.completedAt = Date.now();
|
|
887
893
|
this.checkpoint(record);
|
|
894
|
+
if (record.waitGroupId) {
|
|
895
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
896
|
+
}
|
|
888
897
|
count++;
|
|
889
898
|
}
|
|
890
899
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
37
37
|
AGENT: "Agent",
|
|
38
38
|
GET_RESULT: "get_subagent_result",
|
|
39
39
|
STEER: "steer_subagent",
|
|
40
|
+
WAIT_GROUP: "subagent_wait_group",
|
|
40
41
|
} as const;
|
|
41
42
|
|
|
42
43
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Agent — LLM-callable: spawn a sub-agent
|
|
6
6
|
* get_subagent_result — LLM-callable: check background agent status/result
|
|
7
7
|
* steer_subagent — LLM-callable: send a steering message to a running agent
|
|
8
|
+
* subagent_wait_group — LLM-callable: create, update, or seal a wait group
|
|
8
9
|
*
|
|
9
10
|
* Commands:
|
|
10
11
|
* /agents — Interactive agent management menu
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
} from "./ui/agent-widget.js";
|
|
52
53
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
53
54
|
import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
55
|
+
import { WaitGroupManager } from "./wait-group.js";
|
|
54
56
|
|
|
55
57
|
// ---- Shared helpers ----
|
|
56
58
|
|
|
@@ -59,39 +61,6 @@ function textResult(msg: string, details?: AgentDetails) {
|
|
|
59
61
|
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
|
|
63
|
-
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
64
|
-
if (!signal) return promise;
|
|
65
|
-
if (signal.aborted) return Promise.reject(signal.reason);
|
|
66
|
-
|
|
67
|
-
return new Promise<T>((resolve, reject) => {
|
|
68
|
-
let settled = false;
|
|
69
|
-
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
70
|
-
const onAbort = () => {
|
|
71
|
-
if (settled) return;
|
|
72
|
-
settled = true;
|
|
73
|
-
cleanup();
|
|
74
|
-
reject(signal.reason);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
78
|
-
promise.then(
|
|
79
|
-
(value) => {
|
|
80
|
-
if (settled) return;
|
|
81
|
-
settled = true;
|
|
82
|
-
cleanup();
|
|
83
|
-
resolve(value);
|
|
84
|
-
},
|
|
85
|
-
(error: unknown) => {
|
|
86
|
-
if (settled) return;
|
|
87
|
-
settled = true;
|
|
88
|
-
cleanup();
|
|
89
|
-
reject(error);
|
|
90
|
-
},
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
64
|
export function renderRunningAgentStatus(
|
|
96
65
|
frame: string,
|
|
97
66
|
statsText: string,
|
|
@@ -310,7 +279,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
310
279
|
}
|
|
311
280
|
|
|
312
281
|
const all = [d, ...(d.others ?? [])];
|
|
313
|
-
|
|
282
|
+
const groupHeader = d.groupSummary
|
|
283
|
+
? theme.fg("dim", `Wait group: ${d.groupSummary} (${d.groupId ?? ""})`)
|
|
284
|
+
: undefined;
|
|
285
|
+
return new Text([groupHeader, all.map(renderOne).join("\n")].filter(Boolean).join("\n"), 0, 0);
|
|
314
286
|
}
|
|
315
287
|
);
|
|
316
288
|
|
|
@@ -332,9 +304,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
332
304
|
// before they reach pi.sendMessage (fire-and-forget).
|
|
333
305
|
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
334
306
|
const NUDGE_HOLD_MS = 200;
|
|
335
|
-
// A queued result wait must observe completion before its held notification
|
|
336
|
-
// can fire, so successful waits can still suppress that redundant nudge.
|
|
337
|
-
const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
|
|
338
307
|
|
|
339
308
|
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
340
309
|
cancelNudge(key);
|
|
@@ -411,6 +380,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
411
380
|
30_000,
|
|
412
381
|
);
|
|
413
382
|
|
|
383
|
+
// Explicit wait groups are separate from smart/group join mode: they never
|
|
384
|
+
// time out or partially deliver, and are released only when sealed.
|
|
385
|
+
const waitGroups = new WaitGroupManager((groupId, summary, records) => {
|
|
386
|
+
for (const record of records) {
|
|
387
|
+
agentActivity.delete(record.id);
|
|
388
|
+
widget.markFinished(record.id);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const groupKey = `wait-group:${groupId}`;
|
|
392
|
+
scheduleNudge(groupKey, () => {
|
|
393
|
+
const notifications = records.map(record => {
|
|
394
|
+
const consumed = record.resultConsumed ? "\n(Result already retrieved via get_subagent_result.)" : "";
|
|
395
|
+
return formatTaskNotification(record, 300) + consumed;
|
|
396
|
+
}).join("\n\n");
|
|
397
|
+
const consumedCount = records.filter(record => record.resultConsumed).length;
|
|
398
|
+
const consumedNote = consumedCount > 0
|
|
399
|
+
? ` ${consumedCount} result${consumedCount === 1 ? " was" : "s were"} already retrieved.`
|
|
400
|
+
: "";
|
|
401
|
+
const [first, ...rest] = records;
|
|
402
|
+
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
|
|
403
|
+
details.groupId = groupId;
|
|
404
|
+
details.groupSummary = summary;
|
|
405
|
+
if (rest.length > 0) {
|
|
406
|
+
details.others = rest.map(record => buildNotificationDetails(record, 300, agentActivity.get(record.id)));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
pi.sendMessage<NotificationDetails>({
|
|
410
|
+
customType: "subagent-notification",
|
|
411
|
+
content: `Background agent wait group completed: ${summary} (group ${groupId}).${consumedNote}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
412
|
+
display: true,
|
|
413
|
+
details,
|
|
414
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
415
|
+
});
|
|
416
|
+
widget.update();
|
|
417
|
+
});
|
|
418
|
+
|
|
414
419
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
415
420
|
function buildEventData(record: AgentRecord) {
|
|
416
421
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
@@ -463,6 +468,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
463
468
|
transcriptPath: record.transcriptPath,
|
|
464
469
|
});
|
|
465
470
|
|
|
471
|
+
// Explicit wait-group members never emit individual notifications. Result
|
|
472
|
+
// consumption does not remove membership; the sealed group still delivers
|
|
473
|
+
// exactly one notification for all terminal members.
|
|
474
|
+
if (record.waitGroupId) {
|
|
475
|
+
waitGroups.onAgentComplete(record);
|
|
476
|
+
agentActivity.delete(record.id);
|
|
477
|
+
widget.markFinished(record.id);
|
|
478
|
+
widget.update();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
466
482
|
// Skip notification if result was already consumed via get_subagent_result
|
|
467
483
|
if (record.resultConsumed) {
|
|
468
484
|
agentActivity.delete(record.id);
|
|
@@ -633,6 +649,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
633
649
|
manager.abortAll();
|
|
634
650
|
for (const timer of pendingNudges.values()) clearTimeout(timer);
|
|
635
651
|
pendingNudges.clear();
|
|
652
|
+
groupJoin.dispose();
|
|
653
|
+
waitGroups.dispose();
|
|
636
654
|
widget.dispose();
|
|
637
655
|
manager.dispose();
|
|
638
656
|
});
|
|
@@ -856,6 +874,7 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
|
|
|
856
874
|
Notes:
|
|
857
875
|
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
858
876
|
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
877
|
+
- For nonblocking grouped notification, add wait: true. Use subagent_wait_group to create/update/seal explicit groups; wait_group_done seals after the final spawn.
|
|
859
878
|
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
860
879
|
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
861
880
|
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
@@ -880,6 +899,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
880
899
|
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
881
900
|
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
|
|
882
901
|
- Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
|
|
902
|
+
- For nonblocking grouped notification, set wait: true with run_in_background: true. Omit wait_group for a one-agent implicit group, or create an explicit group with subagent_wait_group and seal it (or set wait_group_done: true on the final Agent call).
|
|
883
903
|
- Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
|
|
884
904
|
- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
|
|
885
905
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
@@ -959,6 +979,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
959
979
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
960
980
|
"For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
|
|
961
981
|
"When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
|
|
982
|
+
"For a nonblocking grouped notification, use wait: true with run_in_background: true; create/update/seal explicit groups with subagent_wait_group.",
|
|
962
983
|
"Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
|
|
963
984
|
],
|
|
964
985
|
parameters: Type.Object({
|
|
@@ -993,6 +1014,22 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
993
1014
|
description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
994
1015
|
}),
|
|
995
1016
|
),
|
|
1017
|
+
wait: Type.Optional(
|
|
1018
|
+
Type.Boolean({
|
|
1019
|
+
description: "With run_in_background: true, suppress individual completion notification and wait for a sealed group notification. This never blocks execution.",
|
|
1020
|
+
}),
|
|
1021
|
+
),
|
|
1022
|
+
wait_group: Type.Optional(
|
|
1023
|
+
Type.String({
|
|
1024
|
+
minLength: 1,
|
|
1025
|
+
description: "Existing explicit wait-group ID created by subagent_wait_group. Requires wait: true.",
|
|
1026
|
+
}),
|
|
1027
|
+
),
|
|
1028
|
+
wait_group_done: Type.Optional(
|
|
1029
|
+
Type.Boolean({
|
|
1030
|
+
description: "With wait: true, seal wait_group after this agent is spawned. Use on the final member.",
|
|
1031
|
+
}),
|
|
1032
|
+
),
|
|
996
1033
|
resume: Type.Optional(
|
|
997
1034
|
Type.String({
|
|
998
1035
|
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
@@ -1169,6 +1206,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1169
1206
|
const thinking = resolvedConfig.thinking;
|
|
1170
1207
|
const inheritContext = resolvedConfig.inheritContext;
|
|
1171
1208
|
const runInBackground = resolvedConfig.runInBackground;
|
|
1209
|
+
const wait = params.wait === true;
|
|
1210
|
+
const waitGroup = typeof params.wait_group === "string" ? params.wait_group.trim() : undefined;
|
|
1211
|
+
const waitGroupDone = params.wait_group_done === true;
|
|
1172
1212
|
const isolated = resolvedConfig.isolated;
|
|
1173
1213
|
const isolation = resolvedConfig.isolation;
|
|
1174
1214
|
// Whether this spawn writes its .output transcript. Per-agent
|
|
@@ -1228,6 +1268,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1228
1268
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
1229
1269
|
};
|
|
1230
1270
|
|
|
1271
|
+
if ((waitGroup || waitGroupDone) && !wait) {
|
|
1272
|
+
return textResult("wait_group and wait_group_done require wait: true.");
|
|
1273
|
+
}
|
|
1274
|
+
if (wait && !runInBackground) {
|
|
1275
|
+
return textResult("wait: true requires run_in_background: true; it controls nonblocking background notifications.");
|
|
1276
|
+
}
|
|
1277
|
+
if (wait && params.schedule) {
|
|
1278
|
+
return textResult("Cannot combine wait: true with schedule — scheduled jobs are separate future runs.");
|
|
1279
|
+
}
|
|
1280
|
+
if (wait && params.resume) {
|
|
1281
|
+
return textResult("Cannot combine wait: true with resume — wait groups apply to fresh background spawns.");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1231
1284
|
// ---- Schedule: register a job, don't spawn now ----
|
|
1232
1285
|
if (params.schedule) {
|
|
1233
1286
|
if (!isSchedulingEnabled()) {
|
|
@@ -1300,8 +1353,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1300
1353
|
// Wrap onSessionCreated to wire output file streaming.
|
|
1301
1354
|
// The callback reads the transcript paths installed synchronously by
|
|
1302
1355
|
// onSpawned before the agent can queue or start.
|
|
1303
|
-
let id
|
|
1304
|
-
|
|
1356
|
+
let id = "";
|
|
1357
|
+
let effectiveWaitGroupId: string | undefined;
|
|
1358
|
+
let implicitWaitGroupId: string | undefined;
|
|
1359
|
+
if (wait) {
|
|
1360
|
+
if (waitGroup) {
|
|
1361
|
+
if (!waitGroups.hasGroup(waitGroup)) {
|
|
1362
|
+
return textResult(`Wait group not found: "${waitGroup}". Create it with subagent_wait_group first.`);
|
|
1363
|
+
}
|
|
1364
|
+
effectiveWaitGroupId = waitGroup;
|
|
1365
|
+
} else {
|
|
1366
|
+
implicitWaitGroupId = waitGroups.create(params.description);
|
|
1367
|
+
effectiveWaitGroupId = implicitWaitGroupId;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const joinMode = wait ? undefined : resolveJoinMode(defaultJoinMode, true);
|
|
1305
1371
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
1306
1372
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
1307
1373
|
origBgOnSession(session);
|
|
@@ -1322,12 +1388,17 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1322
1388
|
isBackground: true,
|
|
1323
1389
|
isolation,
|
|
1324
1390
|
invocation: agentInvocation,
|
|
1391
|
+
waitGroupId: effectiveWaitGroupId,
|
|
1325
1392
|
onSpawned: (spawnedId) => {
|
|
1393
|
+
id = spawnedId;
|
|
1326
1394
|
attachTranscript(manager.getRecord(spawnedId), spawnedId);
|
|
1395
|
+
if (effectiveWaitGroupId) waitGroups.addAgent(effectiveWaitGroupId, spawnedId);
|
|
1327
1396
|
},
|
|
1328
1397
|
...bgCallbacks,
|
|
1329
1398
|
});
|
|
1330
1399
|
} catch (err) {
|
|
1400
|
+
if (effectiveWaitGroupId && id) waitGroups.removeAgent(effectiveWaitGroupId, id);
|
|
1401
|
+
if (implicitWaitGroupId) waitGroups.discard(implicitWaitGroupId);
|
|
1331
1402
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
1332
1403
|
}
|
|
1333
1404
|
|
|
@@ -1339,7 +1410,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1339
1410
|
record.toolCallId = toolCallId;
|
|
1340
1411
|
}
|
|
1341
1412
|
|
|
1342
|
-
if (
|
|
1413
|
+
if (effectiveWaitGroupId) {
|
|
1414
|
+
if (implicitWaitGroupId || waitGroupDone) waitGroups.seal(effectiveWaitGroupId);
|
|
1415
|
+
} else if (joinMode == null || joinMode === 'async') {
|
|
1343
1416
|
// Foreground/no join mode or explicit async — not part of any batch
|
|
1344
1417
|
} else {
|
|
1345
1418
|
// smart or group — add to current batch
|
|
@@ -1370,7 +1443,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1370
1443
|
`Description: ${params.description}\n` +
|
|
1371
1444
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1372
1445
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1373
|
-
|
|
1446
|
+
(effectiveWaitGroupId
|
|
1447
|
+
? `\nWait group: ${effectiveWaitGroupId}${implicitWaitGroupId || waitGroupDone ? " (sealed)" : " (open — seal it with subagent_wait_group)"}.\n` +
|
|
1448
|
+
`You will receive one grouped notification when the sealed wait group completes.\n`
|
|
1449
|
+
: `\nYou will be notified when this agent completes.\n`) +
|
|
1374
1450
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
1375
1451
|
`Do not duplicate this agent's work.`,
|
|
1376
1452
|
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
@@ -1494,6 +1570,75 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1494
1570
|
},
|
|
1495
1571
|
}));
|
|
1496
1572
|
|
|
1573
|
+
// ---- subagent_wait_group tool ----
|
|
1574
|
+
|
|
1575
|
+
pi.registerTool(defineTool({
|
|
1576
|
+
name: SUBAGENT_TOOL_NAMES.WAIT_GROUP,
|
|
1577
|
+
label: "Subagent Wait Group",
|
|
1578
|
+
description:
|
|
1579
|
+
"Create, update, or seal a nonblocking wait group for background Agent calls. " +
|
|
1580
|
+
"A sealed group sends one completion notification after all member agents finish.",
|
|
1581
|
+
promptSnippet: "Create, update, or seal a grouped subagent completion notification",
|
|
1582
|
+
parameters: Type.Object({
|
|
1583
|
+
action: Type.String({
|
|
1584
|
+
description: "Operation: create, update, or seal.",
|
|
1585
|
+
}),
|
|
1586
|
+
group_id: Type.Optional(Type.String({
|
|
1587
|
+
minLength: 1,
|
|
1588
|
+
description: "Wait group ID. Required for update and seal; optional custom ID for create.",
|
|
1589
|
+
})),
|
|
1590
|
+
summary: Type.Optional(Type.String({
|
|
1591
|
+
minLength: 1,
|
|
1592
|
+
description: "Human-readable group summary. Required for create and update; shown in the eventual notification.",
|
|
1593
|
+
})),
|
|
1594
|
+
}),
|
|
1595
|
+
execute: async (_toolCallId, params) => {
|
|
1596
|
+
const action = String(params.action).trim();
|
|
1597
|
+
const groupId = typeof params.group_id === "string" ? params.group_id.trim() : undefined;
|
|
1598
|
+
const summary = typeof params.summary === "string" ? params.summary.trim() : undefined;
|
|
1599
|
+
|
|
1600
|
+
try {
|
|
1601
|
+
if (action === "create") {
|
|
1602
|
+
if (!summary) return textResult("summary is required when creating a wait group.");
|
|
1603
|
+
const createdId = waitGroups.create(summary, groupId);
|
|
1604
|
+
return textResult(
|
|
1605
|
+
`Created subagent wait group.\n` +
|
|
1606
|
+
`Group ID: ${createdId}\n` +
|
|
1607
|
+
`Summary: ${summary}\n\n` +
|
|
1608
|
+
`Use Agent with run_in_background: true, wait: true, wait_group: "${createdId}". ` +
|
|
1609
|
+
`Seal the group after adding members.`,
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
if (action === "update") {
|
|
1613
|
+
if (!groupId) return textResult("group_id is required when updating a wait group.");
|
|
1614
|
+
if (!summary) return textResult("summary is required when updating a wait group.");
|
|
1615
|
+
waitGroups.update(groupId, summary);
|
|
1616
|
+
return textResult(`Updated subagent wait group ${groupId}.\nSummary: ${summary}`);
|
|
1617
|
+
}
|
|
1618
|
+
if (action === "seal") {
|
|
1619
|
+
if (!groupId) return textResult("group_id is required when sealing a wait group.");
|
|
1620
|
+
const snapshot = waitGroups.getGroup(groupId);
|
|
1621
|
+
const delivered = waitGroups.seal(groupId);
|
|
1622
|
+
if (!snapshot) return textResult(`Wait group not found: "${groupId}".`);
|
|
1623
|
+
if (snapshot.agentIds.length === 0) {
|
|
1624
|
+
return textResult(`Sealed empty subagent wait group ${groupId}. No completion notification will be sent.`);
|
|
1625
|
+
}
|
|
1626
|
+
return textResult(
|
|
1627
|
+
`Sealed subagent wait group ${groupId}.\n` +
|
|
1628
|
+
`Summary: ${snapshot.summary}\n` +
|
|
1629
|
+
`Members: ${snapshot.agentIds.length}\n` +
|
|
1630
|
+
(delivered
|
|
1631
|
+
? "All members were already complete; notification has been queued."
|
|
1632
|
+
: "You will receive one notification after all members finish."),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
return textResult(`Unknown action "${action}". Use create, update, or seal.`);
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
}));
|
|
1641
|
+
|
|
1497
1642
|
// ---- get_subagent_result tool ----
|
|
1498
1643
|
|
|
1499
1644
|
pi.registerTool(defineTool({
|
|
@@ -1508,7 +1653,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1508
1653
|
}),
|
|
1509
1654
|
wait: Type.Optional(
|
|
1510
1655
|
Type.Boolean({
|
|
1511
|
-
description: "
|
|
1656
|
+
description: "Deprecated compatibility flag. Results never block; running agents return current status immediately.",
|
|
1512
1657
|
}),
|
|
1513
1658
|
),
|
|
1514
1659
|
verbose: Type.Optional(
|
|
@@ -1517,27 +1662,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1517
1662
|
}),
|
|
1518
1663
|
),
|
|
1519
1664
|
}),
|
|
1520
|
-
execute: async (_toolCallId, params,
|
|
1665
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
1521
1666
|
const record = manager.getRecord(params.agent_id);
|
|
1522
1667
|
if (!record) {
|
|
1523
1668
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1524
1669
|
}
|
|
1525
1670
|
|
|
1526
|
-
// Wait for completion if requested. Cancellation stops only this tool
|
|
1527
|
-
// call; the background agent keeps running and remains unconsumed so its
|
|
1528
|
-
// completion notification can still be delivered.
|
|
1529
|
-
// Queued agents have no promise yet (it's created when the queue starts
|
|
1530
|
-
// them), so poll until they leave the queue, then await like a running one.
|
|
1531
|
-
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
1532
|
-
while (record.status === "queued") {
|
|
1533
|
-
await abortable(
|
|
1534
|
-
new Promise<void>((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)),
|
|
1535
|
-
signal,
|
|
1536
|
-
);
|
|
1537
|
-
}
|
|
1538
|
-
if (record.promise) await abortable(record.promise, signal);
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
1671
|
const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
|
|
1542
1672
|
? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
|
|
1543
1673
|
: undefined;
|
|
@@ -1556,8 +1686,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1556
1686
|
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1557
1687
|
`Description: ${record.description}\n\n`;
|
|
1558
1688
|
|
|
1559
|
-
if (record.status === "running") {
|
|
1560
|
-
output +=
|
|
1689
|
+
if (record.status === "running" || record.status === "queued") {
|
|
1690
|
+
output += params.wait
|
|
1691
|
+
? `Agent is still ${record.status}. wait:true is deprecated and no longer blocks; wait for the background completion notification or check again later.`
|
|
1692
|
+
: `Agent is still ${record.status}. Wait for the background completion notification or check again later.`;
|
|
1561
1693
|
} else if (record.status === "error") {
|
|
1562
1694
|
output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
|
|
1563
1695
|
} else {
|
package/src/types.ts
CHANGED
|
@@ -103,6 +103,8 @@ export interface AgentRecord {
|
|
|
103
103
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
104
104
|
/** The tool_use_id from the original Agent tool call. */
|
|
105
105
|
toolCallId?: string;
|
|
106
|
+
/** Explicit nonblocking completion wait group for this background agent. */
|
|
107
|
+
waitGroupId?: string;
|
|
106
108
|
/** Path to the streaming output transcript file. */
|
|
107
109
|
outputFile?: string;
|
|
108
110
|
/** Absolute path to the durable project-local transcript while live. */
|
|
@@ -164,6 +166,10 @@ export interface NotificationDetails {
|
|
|
164
166
|
resultPreview: string;
|
|
165
167
|
/** Additional agents in a group notification. */
|
|
166
168
|
others?: NotificationDetails[];
|
|
169
|
+
/** Stable ID for an explicit wait-group notification. */
|
|
170
|
+
groupId?: string;
|
|
171
|
+
/** Human-readable summary for an explicit wait-group notification. */
|
|
172
|
+
groupSummary?: string;
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
export interface EnvInfo {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait-group.ts — Explicit, nonblocking completion wait groups.
|
|
3
|
+
*
|
|
4
|
+
* Unlike GroupJoinManager, wait groups never time out or partially deliver.
|
|
5
|
+
* A group delivers exactly once after it has been sealed and every member has
|
|
6
|
+
* reached a terminal state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import type { AgentRecord } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const TERMINAL_AGENT_STATUSES = [
|
|
13
|
+
"completed",
|
|
14
|
+
"steered",
|
|
15
|
+
"error",
|
|
16
|
+
"stopped",
|
|
17
|
+
"aborted",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type TerminalAgentStatus = (typeof TERMINAL_AGENT_STATUSES)[number];
|
|
21
|
+
|
|
22
|
+
export interface WaitGroupSnapshot {
|
|
23
|
+
groupId: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
agentIds: readonly string[];
|
|
26
|
+
sealed: boolean;
|
|
27
|
+
delivered: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type WaitGroup = {
|
|
31
|
+
groupId: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
agentIds: Set<string>;
|
|
34
|
+
completedRecords: Map<string, AgentRecord>;
|
|
35
|
+
sealed: boolean;
|
|
36
|
+
delivered: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type WaitGroupDeliveryCallback = (
|
|
40
|
+
groupId: string,
|
|
41
|
+
summary: string,
|
|
42
|
+
records: AgentRecord[],
|
|
43
|
+
) => void;
|
|
44
|
+
|
|
45
|
+
function normalizeSummary(summary: string): string {
|
|
46
|
+
const normalized = summary.trim();
|
|
47
|
+
if (!normalized) throw new Error("Wait group summary must not be empty.");
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createGroupId(): string {
|
|
52
|
+
return `wait-${randomUUID().slice(0, 17)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isTerminalAgentStatus(status: AgentRecord["status"]): status is TerminalAgentStatus {
|
|
56
|
+
return (TERMINAL_AGENT_STATUSES as readonly string[]).includes(status);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class WaitGroupManager {
|
|
60
|
+
private groups = new Map<string, WaitGroup>();
|
|
61
|
+
private agentToGroup = new Map<string, string>();
|
|
62
|
+
|
|
63
|
+
constructor(private deliverCb: WaitGroupDeliveryCallback) {}
|
|
64
|
+
|
|
65
|
+
create(summary: string, requestedGroupId?: string): string {
|
|
66
|
+
const groupId = requestedGroupId?.trim() || createGroupId();
|
|
67
|
+
if (!groupId) throw new Error("Wait group ID must not be empty.");
|
|
68
|
+
if (this.groups.has(groupId)) throw new Error(`Wait group already exists: "${groupId}".`);
|
|
69
|
+
|
|
70
|
+
this.groups.set(groupId, {
|
|
71
|
+
groupId,
|
|
72
|
+
summary: normalizeSummary(summary),
|
|
73
|
+
agentIds: new Set(),
|
|
74
|
+
completedRecords: new Map(),
|
|
75
|
+
sealed: false,
|
|
76
|
+
delivered: false,
|
|
77
|
+
});
|
|
78
|
+
return groupId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
update(groupId: string, summary: string): void {
|
|
82
|
+
const group = this.requireGroup(groupId);
|
|
83
|
+
if (group.delivered) throw new Error(`Wait group "${groupId}" has already delivered.`);
|
|
84
|
+
group.summary = normalizeSummary(summary);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addAgent(groupId: string, agentId: string): void {
|
|
88
|
+
const group = this.requireGroup(groupId);
|
|
89
|
+
if (group.delivered) throw new Error(`Wait group "${groupId}" has already delivered.`);
|
|
90
|
+
if (group.sealed) throw new Error(`Wait group "${groupId}" is already sealed.`);
|
|
91
|
+
|
|
92
|
+
const existingGroupId = this.agentToGroup.get(agentId);
|
|
93
|
+
if (existingGroupId && existingGroupId !== groupId) {
|
|
94
|
+
throw new Error(`Agent "${agentId}" already belongs to wait group "${existingGroupId}".`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
group.agentIds.add(agentId);
|
|
98
|
+
this.agentToGroup.set(agentId, groupId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
removeAgent(groupId: string, agentId: string): void {
|
|
102
|
+
const group = this.groups.get(groupId);
|
|
103
|
+
if (!group || group.delivered) return;
|
|
104
|
+
group.agentIds.delete(agentId);
|
|
105
|
+
group.completedRecords.delete(agentId);
|
|
106
|
+
this.agentToGroup.delete(agentId);
|
|
107
|
+
if (group.agentIds.size === 0 && !group.sealed) this.groups.delete(groupId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Seal a group. Returns true when this call delivered the group, otherwise
|
|
112
|
+
* false when members are still running or it was already sealed.
|
|
113
|
+
*/
|
|
114
|
+
seal(groupId: string): boolean {
|
|
115
|
+
const group = this.requireGroup(groupId);
|
|
116
|
+
if (group.delivered) return false;
|
|
117
|
+
if (group.sealed) return false;
|
|
118
|
+
group.sealed = true;
|
|
119
|
+
return this.tryDeliver(group);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Register a terminal completion and deliver if the sealed group is ready. */
|
|
123
|
+
onAgentComplete(record: AgentRecord): "pass" | "held" | "delivered" {
|
|
124
|
+
const groupId = this.agentToGroup.get(record.id);
|
|
125
|
+
if (!groupId) return "pass";
|
|
126
|
+
|
|
127
|
+
const group = this.groups.get(groupId);
|
|
128
|
+
if (!group) return "pass";
|
|
129
|
+
if (!isTerminalAgentStatus(record.status)) return "held";
|
|
130
|
+
if (group.delivered) return "delivered";
|
|
131
|
+
|
|
132
|
+
group.completedRecords.set(record.id, record);
|
|
133
|
+
return this.tryDeliver(group) ? "delivered" : "held";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
hasGroup(groupId: string): boolean {
|
|
137
|
+
return this.groups.has(groupId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getGroup(groupId: string): WaitGroupSnapshot | undefined {
|
|
141
|
+
const group = this.groups.get(groupId);
|
|
142
|
+
if (!group) return undefined;
|
|
143
|
+
return {
|
|
144
|
+
groupId: group.groupId,
|
|
145
|
+
summary: group.summary,
|
|
146
|
+
agentIds: [...group.agentIds],
|
|
147
|
+
sealed: group.sealed,
|
|
148
|
+
delivered: group.delivered,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove an empty implicit group when its spawn failed before registration. */
|
|
153
|
+
discard(groupId: string): void {
|
|
154
|
+
const group = this.groups.get(groupId);
|
|
155
|
+
if (!group || group.agentIds.size > 0 || group.delivered) return;
|
|
156
|
+
this.groups.delete(groupId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
isGrouped(agentId: string): boolean {
|
|
160
|
+
return this.agentToGroup.has(agentId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private requireGroup(groupId: string): WaitGroup {
|
|
164
|
+
const group = this.groups.get(groupId);
|
|
165
|
+
if (!group) throw new Error(`Wait group not found: "${groupId}".`);
|
|
166
|
+
return group;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private tryDeliver(group: WaitGroup): boolean {
|
|
170
|
+
if (group.delivered || !group.sealed || group.agentIds.size === 0) return false;
|
|
171
|
+
if ([...group.agentIds].some(id => !group.completedRecords.has(id))) return false;
|
|
172
|
+
|
|
173
|
+
group.delivered = true;
|
|
174
|
+
// Preserve join order in the notification rather than completion order.
|
|
175
|
+
const records = [...group.agentIds]
|
|
176
|
+
.map(id => group.completedRecords.get(id))
|
|
177
|
+
.filter((record): record is AgentRecord => record !== undefined);
|
|
178
|
+
this.deliverCb(group.groupId, group.summary, records);
|
|
179
|
+
for (const id of group.agentIds) {
|
|
180
|
+
this.agentToGroup.delete(id);
|
|
181
|
+
}
|
|
182
|
+
this.groups.delete(group.groupId);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
dispose(): void {
|
|
187
|
+
this.groups.clear();
|
|
188
|
+
this.agentToGroup.clear();
|
|
189
|
+
}
|
|
190
|
+
}
|