@ladbabynpm/picc-subagents 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/group-join.ts +173 -141
- package/src/index.ts +31 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ladbabynpm/picc-subagents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Claude Code style Agent tool for pi agent. Fork of tintinweb/pi-subagents, better aligned with the harness of Claude Code.",
|
|
5
5
|
"author": "ladbabynpm",
|
|
6
6
|
"license": "MIT",
|
package/src/group-join.ts
CHANGED
|
@@ -1,141 +1,173 @@
|
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
group.
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
group.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
* The hold is bounded by the main agent's own turn: `flush()` is called when
|
|
9
|
+
* that turn settles, because from that point on a held result is only latency —
|
|
10
|
+
* see the method for the full reasoning.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentRecord } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
|
|
16
|
+
|
|
17
|
+
interface AgentGroup {
|
|
18
|
+
groupId: string;
|
|
19
|
+
agentIds: Set<string>;
|
|
20
|
+
completedRecords: Map<string, AgentRecord>;
|
|
21
|
+
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
22
|
+
delivered: boolean;
|
|
23
|
+
/** Shorter timeout for stragglers after a partial delivery. */
|
|
24
|
+
isStraggler: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Default timeout: 30s after first completion in a group. */
|
|
28
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
29
|
+
/** Straggler re-batch timeout: 15s. */
|
|
30
|
+
const STRAGGLER_TIMEOUT = 15_000;
|
|
31
|
+
|
|
32
|
+
export class GroupJoinManager {
|
|
33
|
+
private groups = new Map<string, AgentGroup>();
|
|
34
|
+
private agentToGroup = new Map<string, string>();
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private deliverCb: DeliveryCallback,
|
|
38
|
+
private groupTimeout = DEFAULT_TIMEOUT,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/** Register a group of agent IDs that should be joined. */
|
|
42
|
+
registerGroup(groupId: string, agentIds: string[]): void {
|
|
43
|
+
const group: AgentGroup = {
|
|
44
|
+
groupId,
|
|
45
|
+
agentIds: new Set(agentIds),
|
|
46
|
+
completedRecords: new Map(),
|
|
47
|
+
delivered: false,
|
|
48
|
+
isStraggler: false,
|
|
49
|
+
};
|
|
50
|
+
this.groups.set(groupId, group);
|
|
51
|
+
for (const id of agentIds) {
|
|
52
|
+
this.agentToGroup.set(id, groupId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Called when an agent completes.
|
|
58
|
+
* Returns:
|
|
59
|
+
* - 'pass' — agent is not grouped, caller should send individual nudge
|
|
60
|
+
* - 'held' — result held, waiting for group completion
|
|
61
|
+
* - 'delivered' — this completion triggered the group notification
|
|
62
|
+
*/
|
|
63
|
+
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
|
|
64
|
+
const groupId = this.agentToGroup.get(record.id);
|
|
65
|
+
if (!groupId) return 'pass';
|
|
66
|
+
|
|
67
|
+
const group = this.groups.get(groupId);
|
|
68
|
+
if (!group || group.delivered) return 'pass';
|
|
69
|
+
|
|
70
|
+
group.completedRecords.set(record.id, record);
|
|
71
|
+
|
|
72
|
+
// All done — deliver immediately
|
|
73
|
+
if (group.completedRecords.size >= group.agentIds.size) {
|
|
74
|
+
this.deliver(group, false);
|
|
75
|
+
return 'delivered';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// First completion in this batch — start timeout
|
|
79
|
+
if (!group.timeoutHandle) {
|
|
80
|
+
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
|
|
81
|
+
group.timeoutHandle = setTimeout(() => {
|
|
82
|
+
this.onTimeout(group);
|
|
83
|
+
}, timeout);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return 'held';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private onTimeout(group: AgentGroup): void {
|
|
90
|
+
if (group.delivered) return;
|
|
91
|
+
group.timeoutHandle = undefined;
|
|
92
|
+
|
|
93
|
+
// Partial delivery — some agents still running
|
|
94
|
+
const remaining = new Set<string>();
|
|
95
|
+
for (const id of group.agentIds) {
|
|
96
|
+
if (!group.completedRecords.has(id)) remaining.add(id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Clean up agentToGroup for delivered agents (they won't complete again)
|
|
100
|
+
for (const id of group.completedRecords.keys()) {
|
|
101
|
+
this.agentToGroup.delete(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Deliver what we have
|
|
105
|
+
this.deliverCb([...group.completedRecords.values()], true);
|
|
106
|
+
|
|
107
|
+
// Set up straggler group for remaining agents
|
|
108
|
+
group.completedRecords.clear();
|
|
109
|
+
group.agentIds = remaining;
|
|
110
|
+
group.isStraggler = true;
|
|
111
|
+
// Timeout will be started when the next straggler completes
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private deliver(group: AgentGroup, partial: boolean): void {
|
|
115
|
+
if (group.timeoutHandle) {
|
|
116
|
+
clearTimeout(group.timeoutHandle);
|
|
117
|
+
group.timeoutHandle = undefined;
|
|
118
|
+
}
|
|
119
|
+
group.delivered = true;
|
|
120
|
+
this.deliverCb([...group.completedRecords.values()], partial);
|
|
121
|
+
this.cleanupGroup(group.groupId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private cleanupGroup(groupId: string): void {
|
|
125
|
+
const group = this.groups.get(groupId);
|
|
126
|
+
if (!group) return;
|
|
127
|
+
for (const id of group.agentIds) {
|
|
128
|
+
this.agentToGroup.delete(id);
|
|
129
|
+
}
|
|
130
|
+
this.groups.delete(groupId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Deliver every held record immediately and release the remaining members so
|
|
135
|
+
* each notifies on its own.
|
|
136
|
+
*
|
|
137
|
+
* Called when the main agent's turn settles. At that point a held result is
|
|
138
|
+
* pure added latency: nothing consumes it until a new turn starts, so waiting
|
|
139
|
+
* for siblings or for the join timeout buys nothing. This mirrors Claude Code,
|
|
140
|
+
* which drains whichever task notifications are already queued when the turn
|
|
141
|
+
* ends and never blocks on unfinished siblings.
|
|
142
|
+
*
|
|
143
|
+
* Records are delivered as partial, because by definition siblings were still
|
|
144
|
+
* running when the turn ended. `deliver()` clears the timer and drops every
|
|
145
|
+
* agentToGroup entry for the group, so still-running members become ungrouped
|
|
146
|
+
* and report individually — the hold is deliberately not re-armed for them.
|
|
147
|
+
*
|
|
148
|
+
* @returns the number of records delivered.
|
|
149
|
+
*/
|
|
150
|
+
flush(): number {
|
|
151
|
+
// deliver() deletes from this.groups, so iterate over a snapshot.
|
|
152
|
+
let delivered = 0;
|
|
153
|
+
for (const group of [...this.groups.values()]) {
|
|
154
|
+
if (group.delivered || group.completedRecords.size === 0) continue;
|
|
155
|
+
delivered += group.completedRecords.size;
|
|
156
|
+
this.deliver(group, true);
|
|
157
|
+
}
|
|
158
|
+
return delivered;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Check if an agent is in a group. */
|
|
162
|
+
isGrouped(agentId: string): boolean {
|
|
163
|
+
return this.agentToGroup.has(agentId);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
dispose(): void {
|
|
167
|
+
for (const group of this.groups.values()) {
|
|
168
|
+
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
|
|
169
|
+
}
|
|
170
|
+
this.groups.clear();
|
|
171
|
+
this.agentToGroup.clear();
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -313,19 +313,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
// Deliver a completion report so the main agent
|
|
317
|
-
//
|
|
318
|
-
// state at call time,
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// -
|
|
323
|
-
//
|
|
316
|
+
// Deliver a completion report so the main agent learns about it without being
|
|
317
|
+
// interrupted mid-work. `triggerTurn` must ALWAYS be set: pi routes by the
|
|
318
|
+
// session's live state at call time, and without it an idle session takes the
|
|
319
|
+
// final branch of `sendCustomMessage` — appended to the session with no turn
|
|
320
|
+
// and never queued — so the report would wait for the next user prompt.
|
|
321
|
+
// With it, both states work without inspecting liveness ourselves:
|
|
322
|
+
// - streaming -> queued as followUp, drained when the run's tool loop ends
|
|
323
|
+
// (equivalent to Claude Code's `later` priority: end-of-turn, no interrupt)
|
|
324
|
+
// - idle -> starts a turn immediately, so the report arrives at once
|
|
325
|
+
// Deriving this from `ctx.isIdle()` is a trap: `currentCtx` is captured at
|
|
326
|
+
// tool_execution_start, so it can report "busy" after the run already ended.
|
|
324
327
|
function deliverReport(content: string, details: NotificationDetails) {
|
|
325
|
-
const busy = currentCtx ? !currentCtx.isIdle() : false;
|
|
326
328
|
pi.sendMessage<NotificationDetails>(
|
|
327
329
|
{ customType: "subagent-notification", content, display: true, details },
|
|
328
|
-
|
|
330
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
329
331
|
);
|
|
330
332
|
}
|
|
331
333
|
|
|
@@ -376,6 +378,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
378
|
30_000,
|
|
377
379
|
);
|
|
378
380
|
|
|
381
|
+
// Flush held group results the moment the main agent's turn settles.
|
|
382
|
+
//
|
|
383
|
+
// Without this, a finished agent in a smart/group batch waits for its siblings
|
|
384
|
+
// or for the 30s join timeout even though the main agent has gone idle and
|
|
385
|
+
// nothing can consume the result until a new turn starts — the hold is pure
|
|
386
|
+
// added latency. Claude Code behaves the same way: it drains whichever task
|
|
387
|
+
// notifications are already queued when the turn ends, and never blocks on
|
|
388
|
+
// unfinished siblings.
|
|
389
|
+
//
|
|
390
|
+
// `agent_settled` (not `agent_end`) is the correct boundary: agent_end can
|
|
391
|
+
// still be followed by an automatic retry, auto-compaction, or a queued
|
|
392
|
+
// follow-up continuation, during which the main agent is still working and the
|
|
393
|
+
// group hold is still doing its job. Flushing here cannot loop — our own
|
|
394
|
+
// notification triggers a further run, and that run's settle finds nothing left
|
|
395
|
+
// to flush.
|
|
396
|
+
pi.on("agent_settled", async () => {
|
|
397
|
+
groupJoin.flush();
|
|
398
|
+
});
|
|
399
|
+
|
|
379
400
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
380
401
|
function buildEventData(record: AgentRecord) {
|
|
381
402
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|