@esso0428/pi-subagents 0.17.17 → 0.17.19
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 +32 -0
- package/ROADMAP.md +9 -9
- package/docs/post-0.17.6-agents-roster-viewer-spec.md +1 -1
- package/docs/post-0.17.6-feature-specs.md +1 -1
- package/docs/post-0.17.6-nested-agents-spec.md +1 -1
- package/package.json +1 -1
- package/src/agent-manager.ts +9 -0
- package/src/agent-runner.ts +1 -0
- package/src/index.ts +186 -5
- 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.19] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
## [0.17.18] - 2026-09-30
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Republished the intentional v0.17.6 runtime/UI rollback** because npm reserved but did not expose the 0.17.17 version slot. v0.17.17 remains the Git and documentation milestone; v0.17.18 contains the same rollback runtime, with future feature restoration beginning at v0.17.19.
|
|
19
|
+
|
|
10
20
|
## [0.17.17] - 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:
|
package/ROADMAP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# v0.17.17
|
|
1
|
+
# v0.17.17 回退、v0.17.18 registry recovery 與 v0.17.19+ 恢復路線圖
|
|
2
2
|
|
|
3
|
-
本文件是 v0.17.17 的回退邊界與後續恢復決策,不是 runtime
|
|
3
|
+
本文件是 v0.17.17 的回退邊界與後續恢復決策,不是 runtime 實作計畫,也不承諾任何日曆日期。v0.17.18 只因 npm 保留但未公開 v0.17.17 而重發相同 rollback runtime;v0.17.17 仍是 Git/文件里程碑,第一個未來功能恢復版本從 v0.17.19 開始。主要規格索引是 [`docs/post-0.17.6-feature-specs.md`](docs/post-0.17.6-feature-specs.md);各階段都必須以該索引及其保存的原始 implementation、測試與 commit 證據為準。
|
|
4
4
|
|
|
5
5
|
## 目標與不可變邊界
|
|
6
6
|
|
|
@@ -38,15 +38,15 @@
|
|
|
38
38
|
- [ ] 若證據不足、行為與 archived spec 不一致,或人工操作出現可見卡頓,該功能停留在待恢復狀態,不與其他功能合併。
|
|
39
39
|
- [ ] 本文件本身不執行 Vitest、E2E 或 build;恢復工作是否需要這些檢查,依當時授權與 `AGENTS.md` 的資源政策處理。
|
|
40
40
|
|
|
41
|
-
## v0.17.
|
|
41
|
+
## v0.17.19 的決策:只恢復 durable history 基礎層
|
|
42
42
|
|
|
43
|
-
- [ ] 將 v0.17.
|
|
43
|
+
- [ ] 將 v0.17.19 限定為第一個、可獨立驗證的 durable history/recovery data plane:先處理 transcript 格式、project-local path safety、checkpoint metadata 與 flush/attach seam。
|
|
44
44
|
- [ ] 以 [`docs/post-0.17.6-durable-history-spec.md`](docs/post-0.17.6-durable-history-spec.md) 和 [`docs/post-0.17.6-recovery-shutdown-spec.md`](docs/post-0.17.6-recovery-shutdown-spec.md) 為證據邊界;不得順便恢復 nested agents、Workflow/RPC、FleetView 或整套 Agents UI。
|
|
45
45
|
- [ ] 確保 `output_transcript: false` 不會關閉 `.pi-subagents` durable history,且 GC/session cleanup 不會刪除仍可讀的 terminal record;壞 locator、壞 JSON 或 traversal input 必須 fail safely。
|
|
46
|
-
- [ ] 在接受 v0.17.
|
|
47
|
-
- [ ] v0.17.
|
|
46
|
+
- [ ] 在接受 v0.17.19 前,人工確認既有 v0.17.6 viewer interaction 沒有 layout/focus/scroll regression;若此基礎層不改 UI,也仍要記錄 baseline 操作結果。
|
|
47
|
+
- [ ] v0.17.19 不承諾任何日曆日期;版本成立條件是上述單一功能的證據完整,而不是時間到期或功能數量達標。
|
|
48
48
|
|
|
49
|
-
## v0.17.
|
|
49
|
+
## v0.17.19 之後的證據驅動順序
|
|
50
50
|
|
|
51
51
|
每一列都是獨立候選恢復項;「後續版本」只代表通過前一項 acceptance 後再決定的版本,不預設版本號或日期。
|
|
52
52
|
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
- [ ] **Render path**:人工觀察長歷史與頻繁事件時沒有隨歷史長度惡化的可見卡頓;同時以規格列出的 perf guards 驗證 render 不做 filesystem I/O,並記錄證據而非臆測數字門檻。
|
|
72
72
|
- [ ] **失敗處理**:任何可見延遲、focus/layout 變化、重複 redraw、stale row 或 history 消失都代表該單一功能未接受;先隔離或回退該變更,不得進入組合 release。
|
|
73
73
|
|
|
74
|
-
## 0.17.
|
|
74
|
+
## 0.17.19 與後續 release 的組合決策
|
|
75
75
|
|
|
76
|
-
- [ ] **v0.17.
|
|
76
|
+
- [ ] **v0.17.19**:只接受 durable history/recovery 基礎層;不把 Agents UI、nested、RPC 或 Workflow 當作同一 release 的附帶恢復。
|
|
77
77
|
- [ ] **後續版本**:只有已在獨立變更中完成 spec、targeted evidence 與人工 UI latency acceptance 的功能,才可在後續 release 與另一個已接受功能組合。
|
|
78
78
|
- [ ] 組合前要重新執行人工回歸矩陣,特別是 viewer baseline、selector input ownership、長 transcript render 與 resource-policy metadata;單項通過不等於組合後通過。
|
|
79
79
|
- [ ] 若組合回歸失敗,拆回最後一個已接受的功能邊界;不得為了湊版本內容而放寬 acceptance 或重新引入 duplicate UI。
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 基準與目標
|
|
4
4
|
|
|
5
|
-
本規格保存 `64bbbb0`(v0.17.6)後的 Agents UI 演進,供 v0.17.
|
|
5
|
+
本規格保存 `64bbbb0`(v0.17.6)後的 Agents UI 演進,供 v0.17.19 起逐項恢復。v0.17.6 的 ConversationViewer scrollbar/preview 是不可回退的 interaction baseline;Agents panel 則以 v0.17.0 的 single navigator 為前提,不恢復已移除的 duplicate FleetView。
|
|
6
6
|
|
|
7
7
|
## UI 契約
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 文件用途
|
|
4
4
|
|
|
5
|
-
這組文件是 **v0.17.17 的文件交付物**:保存 `v0.17.6` 之後、程式碼可能回退前的功能契約,讓日後可以按功能選擇性恢復。它不是新的 runtime 設計,也不取代原始測試。`v0.17.17`
|
|
5
|
+
這組文件是 **v0.17.17 的文件交付物**:保存 `v0.17.6` 之後、程式碼可能回退前的功能契約,讓日後可以按功能選擇性恢復。它不是新的 runtime 設計,也不取代原始測試。`v0.17.17` 保留為 Git/文件里程碑;npm 未公開該保留版本槽後,v0.17.18 僅重發相同 rollback runtime,第一個未來功能恢復版本從 v0.17.19 開始。
|
|
6
6
|
|
|
7
7
|
- **基準**:`64bbbb0`(`chore: release 0.17.6`,v0.17.6);其父提交 `77de83f` 是 scrollbar 實作。
|
|
8
8
|
- **保存範圍**:基準之後至目前 `bad54e4` 的功能;上游整合參考 `upstream/master` 的 `e955e29`。
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 目的與來源
|
|
4
4
|
|
|
5
|
-
保存 `v0.17.6` 後 nested delegation 的行為,供 v0.17.
|
|
5
|
+
保存 `v0.17.6` 後 nested delegation 的行為,供 v0.17.19 起選擇性恢復。基準是 `64bbbb0`;主要實作由 `8976c63`(#164)開始,後續由 `8d4d4a7` 與 background/workflow 整合延伸。
|
|
6
6
|
|
|
7
7
|
## 必須保留的行為
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.19",
|
|
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
|
|
|
@@ -310,7 +312,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
310
312
|
}
|
|
311
313
|
|
|
312
314
|
const all = [d, ...(d.others ?? [])];
|
|
313
|
-
|
|
315
|
+
const groupHeader = d.groupSummary
|
|
316
|
+
? theme.fg("dim", `Wait group: ${d.groupSummary} (${d.groupId ?? ""})`)
|
|
317
|
+
: undefined;
|
|
318
|
+
return new Text([groupHeader, all.map(renderOne).join("\n")].filter(Boolean).join("\n"), 0, 0);
|
|
314
319
|
}
|
|
315
320
|
);
|
|
316
321
|
|
|
@@ -411,6 +416,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
411
416
|
30_000,
|
|
412
417
|
);
|
|
413
418
|
|
|
419
|
+
// Explicit wait groups are separate from smart/group join mode: they never
|
|
420
|
+
// time out or partially deliver, and are released only when sealed.
|
|
421
|
+
const waitGroups = new WaitGroupManager((groupId, summary, records) => {
|
|
422
|
+
for (const record of records) {
|
|
423
|
+
agentActivity.delete(record.id);
|
|
424
|
+
widget.markFinished(record.id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const groupKey = `wait-group:${groupId}`;
|
|
428
|
+
scheduleNudge(groupKey, () => {
|
|
429
|
+
const notifications = records.map(record => {
|
|
430
|
+
const consumed = record.resultConsumed ? "\n(Result already retrieved via get_subagent_result.)" : "";
|
|
431
|
+
return formatTaskNotification(record, 300) + consumed;
|
|
432
|
+
}).join("\n\n");
|
|
433
|
+
const consumedCount = records.filter(record => record.resultConsumed).length;
|
|
434
|
+
const consumedNote = consumedCount > 0
|
|
435
|
+
? ` ${consumedCount} result${consumedCount === 1 ? " was" : "s were"} already retrieved.`
|
|
436
|
+
: "";
|
|
437
|
+
const [first, ...rest] = records;
|
|
438
|
+
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
|
|
439
|
+
details.groupId = groupId;
|
|
440
|
+
details.groupSummary = summary;
|
|
441
|
+
if (rest.length > 0) {
|
|
442
|
+
details.others = rest.map(record => buildNotificationDetails(record, 300, agentActivity.get(record.id)));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
pi.sendMessage<NotificationDetails>({
|
|
446
|
+
customType: "subagent-notification",
|
|
447
|
+
content: `Background agent wait group completed: ${summary} (group ${groupId}).${consumedNote}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
448
|
+
display: true,
|
|
449
|
+
details,
|
|
450
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
451
|
+
});
|
|
452
|
+
widget.update();
|
|
453
|
+
});
|
|
454
|
+
|
|
414
455
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
415
456
|
function buildEventData(record: AgentRecord) {
|
|
416
457
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
@@ -463,6 +504,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
463
504
|
transcriptPath: record.transcriptPath,
|
|
464
505
|
});
|
|
465
506
|
|
|
507
|
+
// Explicit wait-group members never emit individual notifications. Result
|
|
508
|
+
// consumption does not remove membership; the sealed group still delivers
|
|
509
|
+
// exactly one notification for all terminal members.
|
|
510
|
+
if (record.waitGroupId) {
|
|
511
|
+
waitGroups.onAgentComplete(record);
|
|
512
|
+
agentActivity.delete(record.id);
|
|
513
|
+
widget.markFinished(record.id);
|
|
514
|
+
widget.update();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
466
518
|
// Skip notification if result was already consumed via get_subagent_result
|
|
467
519
|
if (record.resultConsumed) {
|
|
468
520
|
agentActivity.delete(record.id);
|
|
@@ -633,6 +685,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
633
685
|
manager.abortAll();
|
|
634
686
|
for (const timer of pendingNudges.values()) clearTimeout(timer);
|
|
635
687
|
pendingNudges.clear();
|
|
688
|
+
groupJoin.dispose();
|
|
689
|
+
waitGroups.dispose();
|
|
636
690
|
widget.dispose();
|
|
637
691
|
manager.dispose();
|
|
638
692
|
});
|
|
@@ -856,6 +910,7 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
|
|
|
856
910
|
Notes:
|
|
857
911
|
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
858
912
|
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
913
|
+
- 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
914
|
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
860
915
|
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
861
916
|
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
@@ -880,6 +935,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
880
935
|
- 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
936
|
- 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
937
|
- 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.
|
|
938
|
+
- 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
939
|
- 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
940
|
- 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
941
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
@@ -959,6 +1015,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
959
1015
|
"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
1016
|
"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
1017
|
"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.",
|
|
1018
|
+
"For a nonblocking grouped notification, use wait: true with run_in_background: true; create/update/seal explicit groups with subagent_wait_group.",
|
|
962
1019
|
"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
1020
|
],
|
|
964
1021
|
parameters: Type.Object({
|
|
@@ -993,6 +1050,22 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
993
1050
|
description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
994
1051
|
}),
|
|
995
1052
|
),
|
|
1053
|
+
wait: Type.Optional(
|
|
1054
|
+
Type.Boolean({
|
|
1055
|
+
description: "With run_in_background: true, suppress individual completion notification and wait for a sealed group notification. This never blocks execution.",
|
|
1056
|
+
}),
|
|
1057
|
+
),
|
|
1058
|
+
wait_group: Type.Optional(
|
|
1059
|
+
Type.String({
|
|
1060
|
+
minLength: 1,
|
|
1061
|
+
description: "Existing explicit wait-group ID created by subagent_wait_group. Requires wait: true.",
|
|
1062
|
+
}),
|
|
1063
|
+
),
|
|
1064
|
+
wait_group_done: Type.Optional(
|
|
1065
|
+
Type.Boolean({
|
|
1066
|
+
description: "With wait: true, seal wait_group after this agent is spawned. Use on the final member.",
|
|
1067
|
+
}),
|
|
1068
|
+
),
|
|
996
1069
|
resume: Type.Optional(
|
|
997
1070
|
Type.String({
|
|
998
1071
|
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
@@ -1169,6 +1242,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1169
1242
|
const thinking = resolvedConfig.thinking;
|
|
1170
1243
|
const inheritContext = resolvedConfig.inheritContext;
|
|
1171
1244
|
const runInBackground = resolvedConfig.runInBackground;
|
|
1245
|
+
const wait = params.wait === true;
|
|
1246
|
+
const waitGroup = typeof params.wait_group === "string" ? params.wait_group.trim() : undefined;
|
|
1247
|
+
const waitGroupDone = params.wait_group_done === true;
|
|
1172
1248
|
const isolated = resolvedConfig.isolated;
|
|
1173
1249
|
const isolation = resolvedConfig.isolation;
|
|
1174
1250
|
// Whether this spawn writes its .output transcript. Per-agent
|
|
@@ -1228,6 +1304,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1228
1304
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
1229
1305
|
};
|
|
1230
1306
|
|
|
1307
|
+
if ((waitGroup || waitGroupDone) && !wait) {
|
|
1308
|
+
return textResult("wait_group and wait_group_done require wait: true.");
|
|
1309
|
+
}
|
|
1310
|
+
if (wait && !runInBackground) {
|
|
1311
|
+
return textResult("wait: true requires run_in_background: true; it controls nonblocking background notifications.");
|
|
1312
|
+
}
|
|
1313
|
+
if (wait && params.schedule) {
|
|
1314
|
+
return textResult("Cannot combine wait: true with schedule — scheduled jobs are separate future runs.");
|
|
1315
|
+
}
|
|
1316
|
+
if (wait && params.resume) {
|
|
1317
|
+
return textResult("Cannot combine wait: true with resume — wait groups apply to fresh background spawns.");
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1231
1320
|
// ---- Schedule: register a job, don't spawn now ----
|
|
1232
1321
|
if (params.schedule) {
|
|
1233
1322
|
if (!isSchedulingEnabled()) {
|
|
@@ -1300,8 +1389,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1300
1389
|
// Wrap onSessionCreated to wire output file streaming.
|
|
1301
1390
|
// The callback reads the transcript paths installed synchronously by
|
|
1302
1391
|
// onSpawned before the agent can queue or start.
|
|
1303
|
-
let id
|
|
1304
|
-
|
|
1392
|
+
let id = "";
|
|
1393
|
+
let effectiveWaitGroupId: string | undefined;
|
|
1394
|
+
let implicitWaitGroupId: string | undefined;
|
|
1395
|
+
if (wait) {
|
|
1396
|
+
if (waitGroup) {
|
|
1397
|
+
if (!waitGroups.hasGroup(waitGroup)) {
|
|
1398
|
+
return textResult(`Wait group not found: "${waitGroup}". Create it with subagent_wait_group first.`);
|
|
1399
|
+
}
|
|
1400
|
+
effectiveWaitGroupId = waitGroup;
|
|
1401
|
+
} else {
|
|
1402
|
+
implicitWaitGroupId = waitGroups.create(params.description);
|
|
1403
|
+
effectiveWaitGroupId = implicitWaitGroupId;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const joinMode = wait ? undefined : resolveJoinMode(defaultJoinMode, true);
|
|
1305
1407
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
1306
1408
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
1307
1409
|
origBgOnSession(session);
|
|
@@ -1322,12 +1424,17 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1322
1424
|
isBackground: true,
|
|
1323
1425
|
isolation,
|
|
1324
1426
|
invocation: agentInvocation,
|
|
1427
|
+
waitGroupId: effectiveWaitGroupId,
|
|
1325
1428
|
onSpawned: (spawnedId) => {
|
|
1429
|
+
id = spawnedId;
|
|
1326
1430
|
attachTranscript(manager.getRecord(spawnedId), spawnedId);
|
|
1431
|
+
if (effectiveWaitGroupId) waitGroups.addAgent(effectiveWaitGroupId, spawnedId);
|
|
1327
1432
|
},
|
|
1328
1433
|
...bgCallbacks,
|
|
1329
1434
|
});
|
|
1330
1435
|
} catch (err) {
|
|
1436
|
+
if (effectiveWaitGroupId && id) waitGroups.removeAgent(effectiveWaitGroupId, id);
|
|
1437
|
+
if (implicitWaitGroupId) waitGroups.discard(implicitWaitGroupId);
|
|
1331
1438
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
1332
1439
|
}
|
|
1333
1440
|
|
|
@@ -1339,7 +1446,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1339
1446
|
record.toolCallId = toolCallId;
|
|
1340
1447
|
}
|
|
1341
1448
|
|
|
1342
|
-
if (
|
|
1449
|
+
if (effectiveWaitGroupId) {
|
|
1450
|
+
if (implicitWaitGroupId || waitGroupDone) waitGroups.seal(effectiveWaitGroupId);
|
|
1451
|
+
} else if (joinMode == null || joinMode === 'async') {
|
|
1343
1452
|
// Foreground/no join mode or explicit async — not part of any batch
|
|
1344
1453
|
} else {
|
|
1345
1454
|
// smart or group — add to current batch
|
|
@@ -1370,7 +1479,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1370
1479
|
`Description: ${params.description}\n` +
|
|
1371
1480
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1372
1481
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1373
|
-
|
|
1482
|
+
(effectiveWaitGroupId
|
|
1483
|
+
? `\nWait group: ${effectiveWaitGroupId}${implicitWaitGroupId || waitGroupDone ? " (sealed)" : " (open — seal it with subagent_wait_group)"}.\n` +
|
|
1484
|
+
`You will receive one grouped notification when the sealed wait group completes.\n`
|
|
1485
|
+
: `\nYou will be notified when this agent completes.\n`) +
|
|
1374
1486
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
1375
1487
|
`Do not duplicate this agent's work.`,
|
|
1376
1488
|
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
@@ -1494,6 +1606,75 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1494
1606
|
},
|
|
1495
1607
|
}));
|
|
1496
1608
|
|
|
1609
|
+
// ---- subagent_wait_group tool ----
|
|
1610
|
+
|
|
1611
|
+
pi.registerTool(defineTool({
|
|
1612
|
+
name: SUBAGENT_TOOL_NAMES.WAIT_GROUP,
|
|
1613
|
+
label: "Subagent Wait Group",
|
|
1614
|
+
description:
|
|
1615
|
+
"Create, update, or seal a nonblocking wait group for background Agent calls. " +
|
|
1616
|
+
"A sealed group sends one completion notification after all member agents finish.",
|
|
1617
|
+
promptSnippet: "Create, update, or seal a grouped subagent completion notification",
|
|
1618
|
+
parameters: Type.Object({
|
|
1619
|
+
action: Type.String({
|
|
1620
|
+
description: "Operation: create, update, or seal.",
|
|
1621
|
+
}),
|
|
1622
|
+
group_id: Type.Optional(Type.String({
|
|
1623
|
+
minLength: 1,
|
|
1624
|
+
description: "Wait group ID. Required for update and seal; optional custom ID for create.",
|
|
1625
|
+
})),
|
|
1626
|
+
summary: Type.Optional(Type.String({
|
|
1627
|
+
minLength: 1,
|
|
1628
|
+
description: "Human-readable group summary. Required for create and update; shown in the eventual notification.",
|
|
1629
|
+
})),
|
|
1630
|
+
}),
|
|
1631
|
+
execute: async (_toolCallId, params) => {
|
|
1632
|
+
const action = String(params.action).trim();
|
|
1633
|
+
const groupId = typeof params.group_id === "string" ? params.group_id.trim() : undefined;
|
|
1634
|
+
const summary = typeof params.summary === "string" ? params.summary.trim() : undefined;
|
|
1635
|
+
|
|
1636
|
+
try {
|
|
1637
|
+
if (action === "create") {
|
|
1638
|
+
if (!summary) return textResult("summary is required when creating a wait group.");
|
|
1639
|
+
const createdId = waitGroups.create(summary, groupId);
|
|
1640
|
+
return textResult(
|
|
1641
|
+
`Created subagent wait group.\n` +
|
|
1642
|
+
`Group ID: ${createdId}\n` +
|
|
1643
|
+
`Summary: ${summary}\n\n` +
|
|
1644
|
+
`Use Agent with run_in_background: true, wait: true, wait_group: "${createdId}". ` +
|
|
1645
|
+
`Seal the group after adding members.`,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
if (action === "update") {
|
|
1649
|
+
if (!groupId) return textResult("group_id is required when updating a wait group.");
|
|
1650
|
+
if (!summary) return textResult("summary is required when updating a wait group.");
|
|
1651
|
+
waitGroups.update(groupId, summary);
|
|
1652
|
+
return textResult(`Updated subagent wait group ${groupId}.\nSummary: ${summary}`);
|
|
1653
|
+
}
|
|
1654
|
+
if (action === "seal") {
|
|
1655
|
+
if (!groupId) return textResult("group_id is required when sealing a wait group.");
|
|
1656
|
+
const snapshot = waitGroups.getGroup(groupId);
|
|
1657
|
+
const delivered = waitGroups.seal(groupId);
|
|
1658
|
+
if (!snapshot) return textResult(`Wait group not found: "${groupId}".`);
|
|
1659
|
+
if (snapshot.agentIds.length === 0) {
|
|
1660
|
+
return textResult(`Sealed empty subagent wait group ${groupId}. No completion notification will be sent.`);
|
|
1661
|
+
}
|
|
1662
|
+
return textResult(
|
|
1663
|
+
`Sealed subagent wait group ${groupId}.\n` +
|
|
1664
|
+
`Summary: ${snapshot.summary}\n` +
|
|
1665
|
+
`Members: ${snapshot.agentIds.length}\n` +
|
|
1666
|
+
(delivered
|
|
1667
|
+
? "All members were already complete; notification has been queued."
|
|
1668
|
+
: "You will receive one notification after all members finish."),
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
return textResult(`Unknown action "${action}". Use create, update, or seal.`);
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
}));
|
|
1677
|
+
|
|
1497
1678
|
// ---- get_subagent_result tool ----
|
|
1498
1679
|
|
|
1499
1680
|
pi.registerTool(defineTool({
|
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
|
+
}
|