@tmustier/pi-agent-teams 0.3.0 → 0.3.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/README.md CHANGED
@@ -124,7 +124,8 @@ All management commands live under `/team`.
124
124
  | `/team broadcast <msg>` | Message all teammates |
125
125
  | `/team stop <name> [reason]` | Abort current work (resets task to pending) |
126
126
  | `/team shutdown <name> [reason]` | Graceful shutdown (handshake) |
127
- | `/team shutdown` | Stop all teammates (leader session remains active) |
127
+ | `/team shutdown` | Stop all teammates (RPC + best-effort manual) (leader session remains active) |
128
+ | `/team prune [--all]` | Mark stale manual teammates offline (hides them in widget) |
128
129
  | `/team kill <name>` | Force-terminate |
129
130
  | `/team cleanup [--force]` | Delete team artifacts |
130
131
  | `/team id` | Print team/task-list IDs and paths |
@@ -112,6 +112,13 @@ Optional: stop all teammates without ending the leader session:
112
112
 
113
113
  **Expected:** all teammates stop; leader remains active until you exit it (e.g. ctrl+d).
114
114
 
115
+ If old/manual teammates still show as idle (stale config entries), prune them:
116
+
117
+ ```
118
+ /team prune
119
+ # or: /team prune --all
120
+ ```
121
+
115
122
  ## 4. Worker-side Smoke (verifying child process)
116
123
 
117
124
  To test the worker role directly:
@@ -6,7 +6,7 @@ import { sanitizeName } from "./names.js";
6
6
  import { getTeamDir, getTeamsRootDir } from "./paths.js";
7
7
  import { TEAM_MAILBOX_NS } from "./protocol.js";
8
8
  import { unassignTasksForAgent, type TeamTask } from "./task-store.js";
9
- import { setMemberStatus, setTeamStyle } from "./team-config.js";
9
+ import { setMemberStatus, setTeamStyle, type TeamConfig } from "./team-config.js";
10
10
  import { TEAMS_STYLES, type TeamsStyle, getTeamsStrings, formatMemberDisplayName } from "./teams-style.js";
11
11
  import type { TeammateRpc } from "./teammate-rpc.js";
12
12
 
@@ -141,14 +141,16 @@ export async function handleTeamShutdownCommand(opts: {
141
141
  ctx: ExtensionCommandContext;
142
142
  rest: string[];
143
143
  teammates: Map<string, TeammateRpc>;
144
+ getTeamConfig: () => TeamConfig | null;
144
145
  leadName: string;
145
146
  style: TeamsStyle;
146
147
  getCurrentCtx: () => ExtensionContext | null;
147
148
  stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
148
149
  refreshTasks: () => Promise<void>;
150
+ getTasks: () => TeamTask[];
149
151
  renderWidget: () => void;
150
152
  }): Promise<void> {
151
- const { ctx, rest, teammates, leadName, style, getCurrentCtx, stopAllTeammates, refreshTasks, renderWidget } = opts;
153
+ const { ctx, rest, teammates, getTeamConfig, leadName, style, getCurrentCtx, stopAllTeammates, refreshTasks, getTasks, renderWidget } = opts;
152
154
  const strings = getTeamsStrings(style);
153
155
  const nameRaw = rest[0];
154
156
 
@@ -220,7 +222,15 @@ export async function handleTeamShutdownCommand(opts: {
220
222
  }
221
223
 
222
224
  // /team shutdown (no args) = stop all teammates but keep the leader session alive
223
- if (teammates.size === 0) {
225
+ await refreshTasks();
226
+ const cfgBefore = getTeamConfig();
227
+ const cfgWorkersOnline = (cfgBefore?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
228
+
229
+ const activeNames = new Set<string>();
230
+ for (const name of teammates.keys()) activeNames.add(name);
231
+ for (const m of cfgWorkersOnline) activeNames.add(m.name);
232
+
233
+ if (activeNames.size === 0) {
224
234
  ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to shut down`, "info");
225
235
  return;
226
236
  }
@@ -229,7 +239,7 @@ export async function handleTeamShutdownCommand(opts: {
229
239
  const msg =
230
240
  style === "soviet"
231
241
  ? `Dismiss all ${strings.memberTitle.toLowerCase()}s from the ${strings.teamNoun}?`
232
- : `Stop all ${String(teammates.size)} teammate${teammates.size === 1 ? "" : "s"}?`;
242
+ : `Stop all ${String(activeNames.size)} teammate${activeNames.size === 1 ? "" : "s"}?`;
233
243
  const ok = await ctx.ui.confirm("Shutdown team", msg);
234
244
  if (!ok) return;
235
245
  }
@@ -238,8 +248,51 @@ export async function handleTeamShutdownCommand(opts: {
238
248
  style === "soviet"
239
249
  ? `The ${strings.teamNoun} is dissolved by the chairman`
240
250
  : "Stopped by /team shutdown";
251
+ // Stop RPC teammates we own
241
252
  await stopAllTeammates(ctx, reason);
253
+
254
+ // Best-effort: ask *manual* workers (persisted in config.json) to shut down too.
255
+ // Also mark them offline so they stop cluttering the UI if they were left behind from old runs.
242
256
  await refreshTasks();
257
+ const cfg = getTeamConfig();
258
+ const teamId = ctx.sessionManager.getSessionId();
259
+ const teamDir = getTeamDir(teamId);
260
+
261
+ const inProgressOwners = new Set<string>();
262
+ for (const t of getTasks()) {
263
+ if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
264
+ }
265
+
266
+ const manualWorkers = (cfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
267
+ for (const m of manualWorkers) {
268
+ // If it's an RPC teammate we already stopped above, skip mailbox request.
269
+ if (teammates.has(m.name)) continue;
270
+ // If a manual worker still owns an in-progress task, don't force it offline in the UI.
271
+ if (inProgressOwners.has(m.name)) continue;
272
+
273
+ const requestId = randomUUID();
274
+ const ts = new Date().toISOString();
275
+ try {
276
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
277
+ from: leadName,
278
+ text: JSON.stringify({
279
+ type: "shutdown_request",
280
+ requestId,
281
+ from: leadName,
282
+ timestamp: ts,
283
+ reason,
284
+ }),
285
+ timestamp: ts,
286
+ });
287
+ } catch {
288
+ // ignore mailbox errors
289
+ }
290
+
291
+ void setMemberStatus(teamDir, m.name, "offline", {
292
+ meta: { shutdownRequestedAt: ts, shutdownRequestId: requestId, stoppedReason: reason },
293
+ });
294
+ }
295
+
243
296
  renderWidget();
244
297
  ctx.ui.notify(
245
298
  `Team ended: all ${strings.memberTitle.toLowerCase()}s stopped (leader session remains active)`,
@@ -247,6 +300,77 @@ export async function handleTeamShutdownCommand(opts: {
247
300
  );
248
301
  }
249
302
 
303
+ export async function handleTeamPruneCommand(opts: {
304
+ ctx: ExtensionCommandContext;
305
+ rest: string[];
306
+ teammates: Map<string, TeammateRpc>;
307
+ getTeamConfig: () => TeamConfig | null;
308
+ refreshTasks: () => Promise<void>;
309
+ getTasks: () => TeamTask[];
310
+ style: TeamsStyle;
311
+ renderWidget: () => void;
312
+ }): Promise<void> {
313
+ const { ctx, rest, teammates, getTeamConfig, refreshTasks, getTasks, style, renderWidget } = opts;
314
+ const strings = getTeamsStrings(style);
315
+
316
+ const flags = rest.filter((a) => a.startsWith("--"));
317
+ const argsOnly = rest.filter((a) => !a.startsWith("--"));
318
+ const all = flags.includes("--all");
319
+ const unknownFlags = flags.filter((f) => f !== "--all");
320
+ if (unknownFlags.length) {
321
+ ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
322
+ return;
323
+ }
324
+ if (argsOnly.length) {
325
+ ctx.ui.notify("Usage: /team prune [--all]", "error");
326
+ return;
327
+ }
328
+
329
+ await refreshTasks();
330
+ const cfg = getTeamConfig();
331
+ const members = (cfg?.members ?? []).filter((m) => m.role === "worker");
332
+ if (!members.length) {
333
+ ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to prune`, "info");
334
+ renderWidget();
335
+ return;
336
+ }
337
+
338
+ const inProgressOwners = new Set<string>();
339
+ for (const t of getTasks()) {
340
+ if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
341
+ }
342
+
343
+ const cutoffMs = 60 * 60 * 1000; // 1h
344
+ const now = Date.now();
345
+
346
+ const pruned: string[] = [];
347
+ for (const m of members) {
348
+ if (teammates.has(m.name)) continue; // still tracked as RPC
349
+ if (inProgressOwners.has(m.name)) continue; // still actively working
350
+ if (!all) {
351
+ const lastSeen = m.lastSeenAt ? Date.parse(m.lastSeenAt) : NaN;
352
+ if (!Number.isFinite(lastSeen)) continue;
353
+ if (now - lastSeen < cutoffMs) continue;
354
+ }
355
+
356
+ const teamId = ctx.sessionManager.getSessionId();
357
+ const teamDir = getTeamDir(teamId);
358
+ await setMemberStatus(teamDir, m.name, "offline", {
359
+ meta: { prunedAt: new Date().toISOString(), prunedBy: "leader" },
360
+ });
361
+ pruned.push(m.name);
362
+ }
363
+
364
+ await refreshTasks();
365
+ renderWidget();
366
+ ctx.ui.notify(
367
+ pruned.length
368
+ ? `Pruned ${pruned.length} stale ${strings.memberTitle.toLowerCase()}(s): ${pruned.join(", ")}`
369
+ : `No stale ${strings.memberTitle.toLowerCase()}s to prune (use --all to force)`,
370
+ "info",
371
+ );
372
+ }
373
+
250
374
  export async function handleTeamStopCommand(opts: {
251
375
  ctx: ExtensionCommandContext;
252
376
  rest: string[];
@@ -9,6 +9,7 @@ import {
9
9
  handleTeamCleanupCommand,
10
10
  handleTeamDelegateCommand,
11
11
  handleTeamKillCommand,
12
+ handleTeamPruneCommand,
12
13
  handleTeamShutdownCommand,
13
14
  handleTeamStopCommand,
14
15
  handleTeamStyleCommand,
@@ -46,6 +47,7 @@ const TEAM_HELP_TEXT = [
46
47
  " /team plan approve <name>",
47
48
  " /team plan reject <name> [feedback...]",
48
49
  " /team cleanup [--force]",
50
+ " /team prune [--all] # hide stale manual teammates (mark offline)",
49
51
  " /team task add <text...>",
50
52
  " /team task assign <id> <agent>",
51
53
  " /team task unassign <id>",
@@ -163,6 +165,19 @@ export async function handleTeamCommand(opts: {
163
165
  });
164
166
  },
165
167
 
168
+ prune: async () => {
169
+ await handleTeamPruneCommand({
170
+ ctx,
171
+ rest,
172
+ teammates,
173
+ getTeamConfig,
174
+ refreshTasks,
175
+ getTasks,
176
+ style,
177
+ renderWidget,
178
+ });
179
+ },
180
+
166
181
  delegate: async () => {
167
182
  await handleTeamDelegateCommand({
168
183
  ctx,
@@ -178,11 +193,13 @@ export async function handleTeamCommand(opts: {
178
193
  ctx,
179
194
  rest,
180
195
  teammates,
196
+ getTeamConfig,
181
197
  leadName,
182
198
  style,
183
199
  getCurrentCtx,
184
200
  stopAllTeammates,
185
201
  refreshTasks,
202
+ getTasks,
186
203
  renderWidget,
187
204
  });
188
205
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-agent-teams",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Claude Code agent teams style workflow for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -95,8 +95,9 @@ Spawning with `plan` restricts the teammate to read-only tools. After producing
95
95
  ```
96
96
  /team panel # interactive overlay with teammate details
97
97
  /team list # show teammates and their state
98
- /team shutdown # stop all teammates (leader session remains active)
98
+ /team shutdown # stop all teammates (RPC + best-effort manual) (leader session remains active)
99
99
  /team shutdown <name> # graceful shutdown (teammate can reject if busy)
100
+ /team prune [--all] # hide stale manual teammates (mark offline in config)
100
101
  /team kill <name> # force-terminate one RPC teammate
101
102
  /team cleanup [--force] # delete team directory after all teammates stopped
102
103
  ```