@tmustier/pi-agent-teams 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **Pi 0.62 metadata compatibility** — updated tool metadata wiring for recent Pi releases so teams tools continue to render the right prompt snippets/guidelines and stay compatible with current core APIs.
|
|
8
|
+
- **Non-interactive exit hang** — leader polling timers now call `unref()` so print/json child sessions can exit cleanly instead of hanging after the agent finishes. This fixes subagent and other nested Pi flows that load the teams extension in the background.
|
|
9
|
+
|
|
10
|
+
## 0.5.1
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **Automatic startup GC** — on session start, silently removes stale team directories older than 24h (fire-and-forget, never blocks). Reuses the existing `gcStaleTeamDirs()` with age + state checks. (Thanks **@RensTillmann** — #8, #30)
|
|
15
|
+
- **Exit cleanup of empty team dirs** — on session shutdown, deletes the session's own team directory if it has no tasks in any namespace, no active teammates (RPC or manual), and no attach claim from another session. (Thanks **@RensTillmann** — #8, #30)
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
- Added `excludeTeamIds` parameter to `gcStaleTeamDirs()` to prevent startup GC from removing the current session's team (important for resumed sessions older than 24h).
|
|
20
|
+
|
|
3
21
|
## 0.5.0
|
|
4
22
|
|
|
5
23
|
### Features
|
|
@@ -79,13 +79,15 @@ export async function gcStaleTeamDirs(opts: {
|
|
|
79
79
|
maxAgeMs: number;
|
|
80
80
|
repoCwd?: string;
|
|
81
81
|
dryRun?: boolean;
|
|
82
|
+
/** Team IDs to skip (e.g. the current session's team). */
|
|
83
|
+
excludeTeamIds?: ReadonlySet<string>;
|
|
82
84
|
}): Promise<{
|
|
83
85
|
scanned: number;
|
|
84
86
|
removed: string[];
|
|
85
87
|
skipped: Array<{ teamId: string; reason: string }>;
|
|
86
88
|
warnings: string[];
|
|
87
89
|
}> {
|
|
88
|
-
const { teamsRootDir, maxAgeMs, repoCwd, dryRun } = opts;
|
|
90
|
+
const { teamsRootDir, maxAgeMs, repoCwd, dryRun, excludeTeamIds } = opts;
|
|
89
91
|
const teamsRootAbs = path.resolve(teamsRootDir);
|
|
90
92
|
const removed: string[] = [];
|
|
91
93
|
const skipped: Array<{ teamId: string; reason: string }> = [];
|
|
@@ -103,6 +105,10 @@ export async function gcStaleTeamDirs(opts: {
|
|
|
103
105
|
const now = Date.now();
|
|
104
106
|
|
|
105
107
|
for (const teamId of teamEntries) {
|
|
108
|
+
if (excludeTeamIds?.has(teamId)) {
|
|
109
|
+
skipped.push({ teamId, reason: "excluded" });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
106
112
|
const teamDir = path.join(teamsRootAbs, teamId);
|
|
107
113
|
let stat: fs.Stats;
|
|
108
114
|
try {
|
|
@@ -191,6 +191,11 @@ export function registerTeamsTool(opts: {
|
|
|
191
191
|
"Optional overrides: model='<provider>/<modelId>' and thinking (off|minimal|low|medium|high|xhigh).",
|
|
192
192
|
"For governance, the user can run /team delegate on (leader restricted to coordination) or /team spawn <name> plan (worker needs plan approval).",
|
|
193
193
|
].join(" "),
|
|
194
|
+
promptSnippet: "Delegate work across teammates, inspect member status, message workers, and manage team lifecycle/tasks.",
|
|
195
|
+
promptGuidelines: [
|
|
196
|
+
"Use this tool when the user wants parallel agent work, worker coordination, or team lifecycle/task management.",
|
|
197
|
+
"Prefer member_status before interrupting or reassigning active teammates when the current state is unclear.",
|
|
198
|
+
],
|
|
194
199
|
parameters: TeamsToolParamsSchema,
|
|
195
200
|
|
|
196
201
|
async execute(_toolCallId, params: TeamsToolParamsType, signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
|
|
@@ -9,8 +9,9 @@ import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js";
|
|
|
9
9
|
import { createTask, listTasks, unassignTasksForAgent, updateTask, type TeamTask } from "./task-store.js";
|
|
10
10
|
import { TeammateRpc } from "./teammate-rpc.js";
|
|
11
11
|
import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
|
|
12
|
-
import { getTeamDir } from "./paths.js";
|
|
13
|
-
import { heartbeatTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
|
|
12
|
+
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
13
|
+
import { assessAttachClaimFreshness, heartbeatTeamAttachClaim, readTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
|
|
14
|
+
import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js";
|
|
14
15
|
import { ensureWorktreeCwd, cleanupWorktrees } from "./worktree.js";
|
|
15
16
|
import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
|
|
16
17
|
import { openInteractiveWidget } from "./teams-panel.js";
|
|
@@ -106,6 +107,29 @@ async function createSessionForTeammate(
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** Check if a team dir has any task files across all task-list namespaces. */
|
|
111
|
+
async function teamDirHasAnyTasks(teamDir: string): Promise<boolean> {
|
|
112
|
+
const tasksDir = path.join(teamDir, "tasks");
|
|
113
|
+
let taskListDirs: string[];
|
|
114
|
+
try {
|
|
115
|
+
taskListDirs = await fs.promises.readdir(tasksDir);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
for (const listDir of taskListDirs) {
|
|
120
|
+
const listPath = path.join(tasksDir, listDir);
|
|
121
|
+
try {
|
|
122
|
+
const stat = await fs.promises.stat(listPath);
|
|
123
|
+
if (!stat.isDirectory()) continue;
|
|
124
|
+
const files = await fs.promises.readdir(listPath);
|
|
125
|
+
if (files.some((f) => f.endsWith(".json") && !f.startsWith("."))) return true;
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
109
133
|
// Message parsers are shared with the worker implementation.
|
|
110
134
|
export function runLeader(pi: ExtensionAPI): void {
|
|
111
135
|
const teammates = new Map<string, TeammateRpc>();
|
|
@@ -701,6 +725,15 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
701
725
|
style,
|
|
702
726
|
});
|
|
703
727
|
|
|
728
|
+
// Startup GC: silently remove stale team directories from previous sessions (24h age floor).
|
|
729
|
+
void gcStaleTeamDirs({
|
|
730
|
+
teamsRootDir: getTeamsRootDir(),
|
|
731
|
+
maxAgeMs: 24 * 60 * 60 * 1000,
|
|
732
|
+
excludeTeamIds: new Set([currentTeamId]),
|
|
733
|
+
}).catch(() => {
|
|
734
|
+
// Best-effort; never block the session.
|
|
735
|
+
});
|
|
736
|
+
|
|
704
737
|
await refreshTasks();
|
|
705
738
|
renderWidget();
|
|
706
739
|
|
|
@@ -717,6 +750,8 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
717
750
|
refreshInFlight = false;
|
|
718
751
|
}
|
|
719
752
|
}, 1000);
|
|
753
|
+
// Don't keep non-interactive/child pi processes alive just because leader polling exists.
|
|
754
|
+
refreshTimer.unref?.();
|
|
720
755
|
|
|
721
756
|
inboxTimer = setInterval(async () => {
|
|
722
757
|
if (isStopping) return;
|
|
@@ -728,6 +763,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
728
763
|
inboxInFlight = false;
|
|
729
764
|
}
|
|
730
765
|
}, 700);
|
|
766
|
+
inboxTimer.unref?.();
|
|
731
767
|
});
|
|
732
768
|
|
|
733
769
|
pi.on("session_switch", async (_event, ctx) => {
|
|
@@ -787,6 +823,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
787
823
|
refreshInFlight = false;
|
|
788
824
|
}
|
|
789
825
|
}, 1000);
|
|
826
|
+
refreshTimer.unref?.();
|
|
790
827
|
|
|
791
828
|
inboxTimer = setInterval(async () => {
|
|
792
829
|
if (isStopping) return;
|
|
@@ -798,12 +835,14 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
798
835
|
inboxInFlight = false;
|
|
799
836
|
}
|
|
800
837
|
}, 700);
|
|
838
|
+
inboxTimer.unref?.();
|
|
801
839
|
});
|
|
802
840
|
|
|
803
841
|
pi.on("session_shutdown", async () => {
|
|
804
842
|
if (!currentCtx) return;
|
|
805
843
|
await releaseActiveAttachClaim(currentCtx);
|
|
806
844
|
stopLoops();
|
|
845
|
+
const hadTeammates = teammates.size > 0;
|
|
807
846
|
const strings = getTeamsStrings(style);
|
|
808
847
|
await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
|
|
809
848
|
|
|
@@ -817,6 +856,30 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
817
856
|
} catch {
|
|
818
857
|
// Best-effort — don't block shutdown.
|
|
819
858
|
}
|
|
859
|
+
|
|
860
|
+
// Exit cleanup: delete own team directory if it's empty.
|
|
861
|
+
// Conservative: only if no RPC teammates were active, no online workers in
|
|
862
|
+
// config (manual/tmux), no tasks in ANY namespace, and no fresh attach claim.
|
|
863
|
+
// (Dirs with completed tasks are left for the 24h startup GC — intentionally
|
|
864
|
+
// asymmetric for safety.)
|
|
865
|
+
if (!hadTeammates) {
|
|
866
|
+
try {
|
|
867
|
+
const claim = await readTeamAttachClaim(teamDir);
|
|
868
|
+
const claimIsLive = claim !== null && !assessAttachClaimFreshness(claim).isStale;
|
|
869
|
+
if (claimIsLive) {
|
|
870
|
+
// Another session is using this team — don't delete.
|
|
871
|
+
} else {
|
|
872
|
+
// Also check config for online non-lead members (manual/tmux workers).
|
|
873
|
+
const cfg = await loadTeamConfig(teamDir);
|
|
874
|
+
const hasOnlineWorkers = cfg?.members.some((m) => m.role !== "lead" && m.status === "online") ?? false;
|
|
875
|
+
if (!hasOnlineWorkers && !(await teamDirHasAnyTasks(teamDir))) {
|
|
876
|
+
await cleanupTeamDir(getTeamsRootDir(), teamDir);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
} catch {
|
|
880
|
+
// Best-effort; never block shutdown.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
820
883
|
}
|
|
821
884
|
});
|
|
822
885
|
|
|
@@ -143,6 +143,11 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
143
143
|
name: "team_message",
|
|
144
144
|
label: "Team Message",
|
|
145
145
|
description: "Send a message to a comrade. Use this to coordinate with peers on related tasks. Set urgent=true to interrupt their active turn (use sparingly — only for time-sensitive coordination).",
|
|
146
|
+
promptSnippet: "Send a coordination message to another teammate, optionally as an urgent interruption.",
|
|
147
|
+
promptGuidelines: [
|
|
148
|
+
"Use this tool for teammate-to-teammate coordination instead of overloading task status fields with freeform messages.",
|
|
149
|
+
"Set urgent=true only when the recipient must be interrupted before finishing their current turn.",
|
|
150
|
+
],
|
|
146
151
|
parameters: TeamMessageToolParamsSchema,
|
|
147
152
|
async execute(
|
|
148
153
|
_toolCallId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmustier/pi-agent-teams",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Claude Code agent teams style workflow for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Mustier",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@eslint/js": "^9.39.2",
|
|
42
|
-
"@mariozechner/pi-agent-core": "^0.
|
|
43
|
-
"@mariozechner/pi-ai": "^0.
|
|
44
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
45
|
-
"@mariozechner/pi-tui": "^0.
|
|
42
|
+
"@mariozechner/pi-agent-core": "^0.62.0",
|
|
43
|
+
"@mariozechner/pi-ai": "^0.62.0",
|
|
44
|
+
"@mariozechner/pi-coding-agent": "^0.62.0",
|
|
45
|
+
"@mariozechner/pi-tui": "^0.62.0",
|
|
46
46
|
"@sinclair/typebox": "^0.34.48",
|
|
47
47
|
"eslint": "^9.39.2",
|
|
48
48
|
"tsx": "^4.20.5",
|
package/scripts/smoke-test.mts
CHANGED
|
@@ -554,14 +554,18 @@ console.log("\n7. Pi extension loading");
|
|
|
554
554
|
return typeof c === "string" ? c : undefined;
|
|
555
555
|
})();
|
|
556
556
|
|
|
557
|
+
const versionOutput = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
|
|
558
|
+
|
|
557
559
|
if (errCode === "ENOENT") {
|
|
558
560
|
console.log(" (skipped) pi CLI not found on PATH");
|
|
559
561
|
} else if (errCode === "ETIMEDOUT") {
|
|
560
562
|
console.log(" (skipped) pi --version timed out");
|
|
561
563
|
} else if (res.status !== 0) {
|
|
562
564
|
console.log(" (skipped) pi --version returned non-zero exit code");
|
|
565
|
+
} else if (versionOutput.length === 0) {
|
|
566
|
+
console.log(" (skipped) pi --version produced no output");
|
|
563
567
|
} else {
|
|
564
|
-
assert(
|
|
568
|
+
assert(versionOutput.length > 0, "pi --version works");
|
|
565
569
|
}
|
|
566
570
|
}
|
|
567
571
|
|