@ladbabynpm/picc-subagents 0.1.1 → 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.
Files changed (3) hide show
  1. package/package.json +69 -69
  2. package/src/group-join.ts +173 -141
  3. package/src/index.ts +31 -10
package/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "@ladbabynpm/picc-subagents",
3
- "version": "0.1.1",
4
- "description": "Claude Code style Agent tool for pi agent. Fork of tintinweb/pi-subagents, better aligned with the harness of Claude Code.",
5
- "author": "ladbabynpm",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/ladbaby/picc-subagents.git"
10
- },
11
- "homepage": "https://github.com/ladbaby/picc-subagents#readme",
12
- "bugs": {
13
- "url": "https://github.com/ladbaby/picc-subagents/issues"
14
- },
15
- "keywords": [
16
- "pi-package",
17
- "pi",
18
- "pi-extension",
19
- "subagent",
20
- "agent",
21
- "autonomous"
22
- ],
23
- "files": [
24
- "src/",
25
- "package.json",
26
- "tsconfig.json",
27
- "README.md",
28
- "LICENSE",
29
- "CHANGELOG.md",
30
- "biome.json"
31
- ],
32
- "peerDependencies": {
33
- "@earendil-works/pi-ai": ">=0.80.0",
34
- "@earendil-works/pi-coding-agent": ">=0.80.0",
35
- "@earendil-works/pi-tui": ">=0.80.0"
36
- },
37
- "dependencies": {
38
- "@sinclair/typebox": "^0.34.49",
39
- "croner": "^10.0.1",
40
- "nanoid": "^5.0.0"
41
- },
42
- "publishConfig": {
43
- "access": "public"
44
- },
45
- "scripts": {
46
- "build": "tsc",
47
- "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
48
- "test": "vitest run",
49
- "test:watch": "vitest",
50
- "test:e2e": "vitest run e2e --reporter=verbose",
51
- "typecheck": "tsc --noEmit",
52
- "lint": "biome check src/ test/",
53
- "lint:fix": "biome check --fix src/ test/"
54
- },
55
- "devDependencies": {
56
- "@biomejs/biome": "^2.4.14",
57
- "@earendil-works/pi-ai": "^0.80.6",
58
- "@earendil-works/pi-coding-agent": "^0.80.6",
59
- "@earendil-works/pi-tui": "^0.80.6",
60
- "@types/node": "^25.5.0",
61
- "typescript": "^6.0.0",
62
- "vitest": "^4.0.18"
63
- },
64
- "pi": {
65
- "extensions": [
66
- "./src/index.ts"
67
- ]
68
- }
69
- }
1
+ {
2
+ "name": "@ladbabynpm/picc-subagents",
3
+ "version": "0.1.3",
4
+ "description": "Claude Code style Agent tool for pi agent. Fork of tintinweb/pi-subagents, better aligned with the harness of Claude Code.",
5
+ "author": "ladbabynpm",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ladbaby/picc-subagents.git"
10
+ },
11
+ "homepage": "https://github.com/ladbaby/picc-subagents#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ladbaby/picc-subagents/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "pi-extension",
19
+ "subagent",
20
+ "agent",
21
+ "autonomous"
22
+ ],
23
+ "files": [
24
+ "src/",
25
+ "package.json",
26
+ "tsconfig.json",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md",
30
+ "biome.json"
31
+ ],
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-ai": ">=0.80.0",
34
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
35
+ "@earendil-works/pi-tui": ">=0.80.0",
36
+ "@sinclair/typebox": "*"
37
+ },
38
+ "dependencies": {
39
+ "croner": "^10.0.1",
40
+ "nanoid": "^5.0.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest",
50
+ "test:e2e": "vitest run e2e --reporter=verbose",
51
+ "typecheck": "tsc --noEmit",
52
+ "lint": "biome check src/ test/",
53
+ "lint:fix": "biome check --fix src/ test/"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.4.14",
57
+ "@earendil-works/pi-ai": "^0.80.6",
58
+ "@earendil-works/pi-coding-agent": "^0.80.6",
59
+ "@earendil-works/pi-tui": "^0.80.6",
60
+ "@types/node": "^25.5.0",
61
+ "typescript": "^6.0.0",
62
+ "vitest": "^4.0.18"
63
+ },
64
+ "pi": {
65
+ "extensions": [
66
+ "./src/index.ts"
67
+ ]
68
+ }
69
+ }
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
- 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
- }
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 is told about it without
317
- // being held up. pi's sendMessage routes by the agent's *current* streaming
318
- // state at call time, not by the options we pass: when we're mid-run it
319
- // queues a followUp and *silently ignores* `triggerTurn`; once the run ends
320
- // nothing re-checks the queue, so a report sent during the run only surfaces
321
- // on the next user prompt. So pick the delivery mode by liveness:
322
- // - busy -> queue a plain followUp (drains when the run finishes)
323
- // - idle -> deliver now with a real turn, so the report arrives at once
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
- busy ? { deliverAs: "followUp" } : { deliverAs: "followUp", triggerTurn: true },
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;