@tmustier/pi-agent-teams 0.5.0 → 0.5.1

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Features
6
+
7
+ - **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)
8
+ - **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)
9
+
10
+ ### Fixes
11
+
12
+ - Added `excludeTeamIds` parameter to `gcStaleTeamDirs()` to prevent startup GC from removing the current session's team (important for resumed sessions older than 24h).
13
+
3
14
  ## 0.5.0
4
15
 
5
16
  ### 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 {
@@ -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
 
@@ -804,6 +837,7 @@ export function runLeader(pi: ExtensionAPI): void {
804
837
  if (!currentCtx) return;
805
838
  await releaseActiveAttachClaim(currentCtx);
806
839
  stopLoops();
840
+ const hadTeammates = teammates.size > 0;
807
841
  const strings = getTeamsStrings(style);
808
842
  await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
809
843
 
@@ -817,6 +851,30 @@ export function runLeader(pi: ExtensionAPI): void {
817
851
  } catch {
818
852
  // Best-effort — don't block shutdown.
819
853
  }
854
+
855
+ // Exit cleanup: delete own team directory if it's empty.
856
+ // Conservative: only if no RPC teammates were active, no online workers in
857
+ // config (manual/tmux), no tasks in ANY namespace, and no fresh attach claim.
858
+ // (Dirs with completed tasks are left for the 24h startup GC — intentionally
859
+ // asymmetric for safety.)
860
+ if (!hadTeammates) {
861
+ try {
862
+ const claim = await readTeamAttachClaim(teamDir);
863
+ const claimIsLive = claim !== null && !assessAttachClaimFreshness(claim).isStale;
864
+ if (claimIsLive) {
865
+ // Another session is using this team — don't delete.
866
+ } else {
867
+ // Also check config for online non-lead members (manual/tmux workers).
868
+ const cfg = await loadTeamConfig(teamDir);
869
+ const hasOnlineWorkers = cfg?.members.some((m) => m.role !== "lead" && m.status === "online") ?? false;
870
+ if (!hasOnlineWorkers && !(await teamDirHasAnyTasks(teamDir))) {
871
+ await cleanupTeamDir(getTeamsRootDir(), teamDir);
872
+ }
873
+ }
874
+ } catch {
875
+ // Best-effort; never block shutdown.
876
+ }
877
+ }
820
878
  }
821
879
  });
822
880
 
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.1",
4
4
  "description": "Claude Code agent teams style workflow for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",