@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.0",
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.52.8",
43
- "@mariozechner/pi-ai": "^0.52.8",
44
- "@mariozechner/pi-coding-agent": "^0.52.8",
45
- "@mariozechner/pi-tui": "^0.52.8",
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",
@@ -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((res.stdout ?? "").trim().length > 0, "pi --version works");
568
+ assert(versionOutput.length > 0, "pi --version works");
565
569
  }
566
570
  }
567
571