@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.0

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 (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -9
  3. package/WORKFLOW.md +110 -0
  4. package/docs/claude-parity.md +18 -13
  5. package/docs/hook-contract.md +183 -0
  6. package/docs/smoke-test-plan.md +26 -7
  7. package/extensions/teams/activity-tracker.ts +296 -8
  8. package/extensions/teams/cleanup.ts +216 -3
  9. package/extensions/teams/hooks.ts +57 -5
  10. package/extensions/teams/leader-attach-commands.ts +8 -4
  11. package/extensions/teams/leader-inbox.ts +162 -4
  12. package/extensions/teams/leader-info-commands.ts +105 -3
  13. package/extensions/teams/leader-lifecycle-commands.ts +205 -3
  14. package/extensions/teams/leader-messaging-commands.ts +19 -7
  15. package/extensions/teams/leader-spawn-command.ts +5 -1
  16. package/extensions/teams/leader-team-command.ts +51 -2
  17. package/extensions/teams/leader-teams-tool.ts +387 -11
  18. package/extensions/teams/leader.ts +126 -52
  19. package/extensions/teams/mailbox.ts +6 -1
  20. package/extensions/teams/model-policy.ts +117 -0
  21. package/extensions/teams/spawn-types.ts +4 -0
  22. package/extensions/teams/teammate-rpc.ts +14 -0
  23. package/extensions/teams/teams-panel.ts +117 -19
  24. package/extensions/teams/teams-ui-shared.ts +205 -2
  25. package/extensions/teams/teams-widget.ts +67 -14
  26. package/extensions/teams/worker.ts +18 -6
  27. package/extensions/teams/worktree.ts +143 -0
  28. package/package.json +4 -2
  29. package/scripts/integration-cleanup-test.mts +419 -0
  30. package/scripts/integration-hooks-remediation-test.mts +382 -0
  31. package/scripts/integration-spawn-overrides-test.mts +10 -0
  32. package/scripts/smoke-test.mts +701 -3
  33. package/skills/agent-teams/SKILL.md +28 -7
@@ -30,6 +30,7 @@ export async function handleTeamAttachCommand(opts: {
30
30
  setTaskListId: (id: string) => void;
31
31
  refreshTasks: () => Promise<void>;
32
32
  renderWidget: () => void;
33
+ restoreWidget: () => void;
33
34
  }): Promise<void> {
34
35
  const {
35
36
  ctx,
@@ -41,7 +42,7 @@ export async function handleTeamAttachCommand(opts: {
41
42
  setStyle,
42
43
  setTaskListId,
43
44
  refreshTasks,
44
- renderWidget,
45
+ restoreWidget,
45
46
  } = opts;
46
47
 
47
48
  const tokens = rest.map((t) => t.trim()).filter((t) => t.length > 0);
@@ -163,7 +164,8 @@ export async function handleTeamAttachCommand(opts: {
163
164
  setTaskListId(cfg.taskListId);
164
165
  setStyle(cfg.style ?? "normal");
165
166
  await refreshTasks();
166
- renderWidget();
167
+ // Clear any /team done suppression — attaching to a team is explicit intent to work.
168
+ restoreWidget();
167
169
 
168
170
  const lines: string[] = [
169
171
  `Attached to team: ${cfg.teamId}`,
@@ -185,8 +187,9 @@ export async function handleTeamDetachCommand(opts: {
185
187
  setTaskListId: (id: string) => void;
186
188
  refreshTasks: () => Promise<void>;
187
189
  renderWidget: () => void;
190
+ restoreWidget: () => void;
188
191
  }): Promise<void> {
189
- const { ctx, defaultTeamId, teammates, getActiveTeamId, setActiveTeamId, setTaskListId, refreshTasks, renderWidget } = opts;
192
+ const { ctx, defaultTeamId, teammates, getActiveTeamId, setActiveTeamId, setTaskListId, refreshTasks, restoreWidget } = opts;
190
193
 
191
194
  const activeTeamId = getActiveTeamId();
192
195
  if (activeTeamId === defaultTeamId) {
@@ -207,7 +210,8 @@ export async function handleTeamDetachCommand(opts: {
207
210
  setActiveTeamId(defaultTeamId);
208
211
  setTaskListId(defaultTeamId);
209
212
  await refreshTasks();
210
- renderWidget();
213
+ // Clear any /team done suppression — returning to own team.
214
+ restoreWidget();
211
215
 
212
216
  if (releaseResult === "not_owner") {
213
217
  ctx.ui.notify(
@@ -10,12 +10,78 @@ import {
10
10
  isShutdownRejected,
11
11
  } from "./protocol.js";
12
12
  import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
13
- import { getTask } from "./task-store.js";
13
+ import { getTask, listTasks } from "./task-store.js";
14
14
 
15
15
  import type { TeamsHookInvocation } from "./hooks.js";
16
16
  import type { TeamsStyle } from "./teams-style.js";
17
17
  import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
18
18
 
19
+ /** Callback to inject a message into the leader LLM conversation. */
20
+ export type SendLeaderLlmMessage = (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
21
+
22
+ /**
23
+ * Event-driven tracker for delegation batches.
24
+ *
25
+ * Tracks task IDs from delegate() calls. Tasks are only marked done
26
+ * when an idle_notification with completedTaskId is received — NOT
27
+ * by polling task file status. This avoids race conditions where
28
+ * listTasks() returns stale or premature data.
29
+ */
30
+ export class DelegationTracker {
31
+ private batches: Array<{
32
+ taskIds: Set<string>;
33
+ completedIds: Set<string>;
34
+ notified: boolean;
35
+ }> = [];
36
+
37
+ /** Register a new batch of delegated task IDs. */
38
+ addBatch(taskIds: string[]): void {
39
+ if (taskIds.length === 0) return;
40
+ this.batches.push({
41
+ taskIds: new Set(taskIds),
42
+ completedIds: new Set(),
43
+ notified: false,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Mark a task as completed (called when idle_notification with
49
+ * completedTaskId is received). Returns any batches that became
50
+ * fully complete as a result.
51
+ */
52
+ markCompleted(taskId: string): Array<{ taskIds: string[] }> {
53
+ const newlyComplete: Array<{ taskIds: string[] }> = [];
54
+
55
+ for (const batch of this.batches) {
56
+ if (batch.notified) continue;
57
+ if (!batch.taskIds.has(taskId)) continue;
58
+
59
+ batch.completedIds.add(taskId);
60
+
61
+ const allDone = [...batch.taskIds].every((id) => batch.completedIds.has(id));
62
+ if (allDone) {
63
+ batch.notified = true;
64
+ newlyComplete.push({ taskIds: [...batch.taskIds] });
65
+ }
66
+ }
67
+
68
+ // Prune notified batches
69
+ this.batches = this.batches.filter((b) => !b.notified);
70
+ return newlyComplete;
71
+ }
72
+
73
+ /** Clear all tracked batches (e.g. on session switch). */
74
+ clear(): void {
75
+ this.batches = [];
76
+ }
77
+ }
78
+
79
+ /** Truncate a result string to stay within token budget. */
80
+ function truncateResult(text: string, maxLen: number): string {
81
+ if (text.length <= maxLen) return text;
82
+ return text.slice(0, maxLen) + "…";
83
+ }
84
+
19
85
  export async function pollLeaderInbox(opts: {
20
86
  ctx: ExtensionContext;
21
87
  teamId: string;
@@ -25,8 +91,11 @@ export async function pollLeaderInbox(opts: {
25
91
  style: TeamsStyle;
26
92
  pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
27
93
  enqueueHook?: (invocation: TeamsHookInvocation) => void;
94
+ sendLeaderLlmMessage?: SendLeaderLlmMessage;
95
+ /** Batch delegation tracker for all-tasks-complete auto-notify. */
96
+ delegationTracker?: DelegationTracker;
28
97
  }): Promise<void> {
29
- const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook } = opts;
98
+ const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, sendLeaderLlmMessage, delegationTracker } = opts;
30
99
  const strings = getTeamsStrings(style);
31
100
 
32
101
  let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
@@ -38,6 +107,10 @@ export async function pollLeaderInbox(opts: {
38
107
  }
39
108
  if (!msgs.length) return;
40
109
 
110
+ // Collect batch completions across all messages in this poll cycle,
111
+ // then fire notifications once at the end (avoids duplicate triggers).
112
+ const batchCompletions: Array<{ taskIds: string[] }> = [];
113
+
41
114
  for (const m of msgs) {
42
115
  const approved = isShutdownApproved(m.text);
43
116
  if (approved) {
@@ -132,6 +205,13 @@ export async function pollLeaderInbox(opts: {
132
205
  } catch {
133
206
  // ignore hook enqueue errors
134
207
  }
208
+
209
+ // Event-driven batch tracking: mark this task done and
210
+ // collect any batches that became fully complete.
211
+ if (delegationTracker && idle.completedStatus !== "failed") {
212
+ const completed = delegationTracker.markCompleted(idle.completedTaskId);
213
+ batchCompletions.push(...completed);
214
+ }
135
215
  }
136
216
 
137
217
  if (idle.failureReason) {
@@ -200,8 +280,59 @@ export async function pollLeaderInbox(opts: {
200
280
 
201
281
  if (idle.completedTaskId && idle.completedStatus === "failed") {
202
282
  ctx.ui.notify(`${name} aborted task #${idle.completedTaskId}`, "warning");
283
+
284
+ // Inject failure notification into leader LLM conversation
285
+ if (sendLeaderLlmMessage) {
286
+ const task = await getTask(teamDir, taskListId, idle.completedTaskId);
287
+ const subject = task?.subject ? `: ${task.subject}` : "";
288
+ // Failed tasks store abort details, not the success-only `result` field.
289
+ const abortReasonRaw = task?.metadata?.["abortReason"];
290
+ const partialResultRaw = task?.metadata?.["partialResult"];
291
+ const abortReason = typeof abortReasonRaw === "string" ? truncateResult(abortReasonRaw, 300) : undefined;
292
+ const partialResult = typeof partialResultRaw === "string" ? truncateResult(partialResultRaw, 300) : undefined;
293
+ const lines = [
294
+ `[Team] ${formatMemberDisplayName(style, name)} failed task #${idle.completedTaskId}${subject}`,
295
+ ];
296
+ if (abortReason) lines.push(`Reason: ${abortReason}`);
297
+ if (partialResult) lines.push(`Partial result: ${partialResult}`);
298
+ sendLeaderLlmMessage(lines.join("\n"), { deliverAs: "followUp" });
299
+ }
203
300
  } else if (idle.completedTaskId) {
204
- ctx.ui.notify(`${name} is idle task #${idle.completedTaskId}`, "info");
301
+ ctx.ui.notify(`${name} completed task #${idle.completedTaskId}`, "info");
302
+
303
+ // Inject completion notification into leader LLM conversation
304
+ if (sendLeaderLlmMessage) {
305
+ const task = await getTask(teamDir, taskListId, idle.completedTaskId);
306
+ const subject = task?.subject ? `: ${task.subject}` : "";
307
+ const resultRaw = task?.metadata?.["result"];
308
+ const result = typeof resultRaw === "string" ? truncateResult(resultRaw, 500) : undefined;
309
+ const lines = [
310
+ `[Team] ${formatMemberDisplayName(style, name)} completed task #${idle.completedTaskId}${subject}`,
311
+ ];
312
+ if (result) lines.push(`Result: ${result}`);
313
+
314
+ // Check if all tasks are now completed
315
+ const allTasks = await listTasks(teamDir, taskListId);
316
+ const totalTasks = allTasks.length;
317
+ const completedTasks = allTasks.filter((t) => t.status === "completed");
318
+ const allDone = totalTasks > 0 && completedTasks.length === totalTasks;
319
+
320
+ if (allDone) {
321
+ lines.push("");
322
+ if (enqueueHook) {
323
+ // Hooks run asynchronously and may reopen tasks or create follow-ups.
324
+ lines.push(`All ${totalTasks} task(s) show completed — quality gates are still running and may change task states.`);
325
+ } else {
326
+ lines.push(`All ${totalTasks} task(s) are now completed. Review results and determine next steps.`);
327
+ }
328
+ } else {
329
+ const pending = allTasks.filter((t) => t.status === "pending").length;
330
+ const inProgress = allTasks.filter((t) => t.status === "in_progress").length;
331
+ lines.push(`Progress: ${completedTasks.length}/${totalTasks} done (${pending} pending, ${inProgress} in progress)`);
332
+ }
333
+
334
+ sendLeaderLlmMessage(lines.join("\n"), { deliverAs: "followUp" });
335
+ }
205
336
  } else {
206
337
  ctx.ui.notify(`${name} is idle`, "info");
207
338
  }
@@ -209,6 +340,33 @@ export async function pollLeaderInbox(opts: {
209
340
  continue;
210
341
  }
211
342
 
212
- ctx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
343
+ // Unrecognized message = teammate DM → route to leader LLM context
344
+ if (sendLeaderLlmMessage) {
345
+ sendLeaderLlmMessage(`[Team DM] ${m.from}: ${m.text}`, { deliverAs: "followUp" });
346
+ } else {
347
+ ctx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
348
+ }
349
+ }
350
+
351
+ // Fire batch-complete notifications (deduplicated across this poll cycle).
352
+ // Uses sendLeaderLlmMessage directly (without deliverAs) when idle so it
353
+ // triggers a new LLM turn, waking the leader to review and continue.
354
+ if (sendLeaderLlmMessage) {
355
+ for (const batch of batchCompletions) {
356
+ const taskRefs = batch.taskIds.map((id) => `#${id}`).join(", ");
357
+ const suffix = enqueueHook
358
+ ? "Quality gates are still running and may change task states."
359
+ : "Review the results and continue.";
360
+ const msg = `[Team] All delegated tasks completed (${taskRefs}). ${suffix}`;
361
+ try {
362
+ if (ctx.isIdle()) {
363
+ sendLeaderLlmMessage(msg);
364
+ } else {
365
+ sendLeaderLlmMessage(msg, { deliverAs: "followUp" });
366
+ }
367
+ } catch {
368
+ ctx.ui.notify(`✅ ${msg}`, "info");
369
+ }
370
+ }
213
371
  }
214
372
  }
@@ -5,16 +5,20 @@ import type { TeammateRpc } from "./teammate-rpc.js";
5
5
  import type { TeamConfig, TeamMember } from "./team-config.js";
6
6
  import type { TeamsStyle } from "./teams-style.js";
7
7
  import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
8
+ import { resolveDisplayStatus, formatElapsed, formatTokens, lastMessageSummary, toolActivity } from "./teams-ui-shared.js";
9
+ import type { ActivityTracker } from "./activity-tracker.js";
10
+ import { listTasks } from "./task-store.js";
8
11
 
9
12
  export async function handleTeamListCommand(opts: {
10
13
  ctx: ExtensionCommandContext;
11
14
  teammates: Map<string, TeammateRpc>;
12
15
  getTeamConfig: () => TeamConfig | null;
16
+ getTracker: () => ActivityTracker;
13
17
  style: TeamsStyle;
14
18
  refreshTasks: () => Promise<void>;
15
19
  renderWidget: () => void;
16
20
  }): Promise<void> {
17
- const { ctx, teammates, getTeamConfig, style, refreshTasks, renderWidget } = opts;
21
+ const { ctx, teammates, getTeamConfig, getTracker, style, refreshTasks, renderWidget } = opts;
18
22
  const strings = getTeamsStrings(style);
19
23
 
20
24
  await refreshTasks();
@@ -34,13 +38,19 @@ export async function handleTeamListCommand(opts: {
34
38
  return;
35
39
  }
36
40
 
41
+ const tracker = getTracker();
37
42
  const lines: string[] = [];
38
43
  for (const name of Array.from(names).sort()) {
39
44
  const rpc = teammates.get(name);
40
45
  const cfg = cfgByName.get(name);
41
- const status = rpc ? rpc.status : cfg?.status ?? "offline";
46
+ const displayStatus = resolveDisplayStatus(rpc, cfg);
42
47
  const kind = rpc ? "rpc" : cfg ? "manual" : "unknown";
43
- lines.push(`${formatMemberDisplayName(style, name)}: ${status} (${kind})`);
48
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
49
+ const activity = tracker.get(name);
50
+ const tool = toolActivity(activity.currentToolName);
51
+ const elapsedTag = elapsed ? ` ${elapsed}` : "";
52
+ const toolTag = tool ? ` (${tool})` : "";
53
+ lines.push(`${formatMemberDisplayName(style, name)}: ${displayStatus}${elapsedTag}${toolTag} [${kind}]`);
44
54
  }
45
55
 
46
56
  ctx.ui.notify(lines.join("\n"), "info");
@@ -138,3 +148,95 @@ export async function handleTeamEnvCommand(opts: {
138
148
  "info",
139
149
  );
140
150
  }
151
+
152
+ export async function handleTeamStatusCommand(opts: {
153
+ ctx: ExtensionCommandContext;
154
+ rest: string[];
155
+ teammates: Map<string, TeammateRpc>;
156
+ getTeamConfig: () => TeamConfig | null;
157
+ getTracker: () => ActivityTracker;
158
+ teamId: string;
159
+ taskListId: string | null;
160
+ style: TeamsStyle;
161
+ }): Promise<void> {
162
+ const { ctx, rest, teammates, getTeamConfig, getTracker, teamId, taskListId, style } = opts;
163
+ const strings = getTeamsStrings(style);
164
+ const tracker = getTracker();
165
+ const teamConfig = getTeamConfig();
166
+ const teamDir = getTeamDir(teamId);
167
+ const effectiveTlId = taskListId ?? teamId;
168
+
169
+ const nameRaw = rest[0];
170
+
171
+ // If no name, show summary of all workers (same as member_status with no name).
172
+ if (!nameRaw) {
173
+ const cfgMembers = teamConfig?.members ?? [];
174
+ const cfgByName = new Map<string, TeamMember>();
175
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
176
+
177
+ const workerNames = new Set<string>();
178
+ for (const n of teammates.keys()) workerNames.add(n);
179
+ for (const m of cfgMembers) {
180
+ if (m.role === "worker" && m.status === "online") workerNames.add(m.name);
181
+ }
182
+
183
+ if (workerNames.size === 0) {
184
+ ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s`, "info");
185
+ return;
186
+ }
187
+
188
+ const lines: string[] = [];
189
+ for (const n of Array.from(workerNames).sort()) {
190
+ const rpc = teammates.get(n);
191
+ const cfg = cfgByName.get(n);
192
+ const displayStatus = resolveDisplayStatus(rpc, cfg);
193
+ const activity = tracker.get(n);
194
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
195
+ const tool = toolActivity(activity.currentToolName);
196
+ const toolTag = tool ? ` (${tool})` : "";
197
+ const stalledTag = displayStatus === "stalled" ? " ⚠ STALLED" : "";
198
+ lines.push(`${formatMemberDisplayName(style, n)}: ${displayStatus} ${elapsed}${toolTag} · ${formatTokens(activity.totalTokens)} tokens${stalledTag}`);
199
+ }
200
+ ctx.ui.notify(lines.join("\n"), "info");
201
+ return;
202
+ }
203
+
204
+ // Single worker detail view.
205
+ const name = sanitizeName(nameRaw);
206
+ const rpc = teammates.get(name);
207
+ const memberCfg = (teamConfig?.members ?? []).find((m) => m.name === name);
208
+ if (!rpc && !memberCfg) {
209
+ ctx.ui.notify(`Unknown ${strings.memberTitle.toLowerCase()}: ${name}`, "error");
210
+ return;
211
+ }
212
+
213
+ const displayStatus = resolveDisplayStatus(rpc, memberCfg);
214
+ const activity = tracker.get(name);
215
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
216
+ const noEventFor = rpc ? formatElapsed(Date.now() - rpc.lastEventAt) : "";
217
+ const currentTool = toolActivity(activity.currentToolName);
218
+ const msgPreview = lastMessageSummary(rpc, 120);
219
+ const allTasks = await listTasks(teamDir, effectiveTlId);
220
+ const owned = allTasks.filter((t) => t.owner === name);
221
+ const activeTask = owned.find((t) => t.status === "in_progress");
222
+ const model = memberCfg?.meta?.["model"];
223
+ const cwd = memberCfg?.cwd;
224
+
225
+ const lines: string[] = [
226
+ `${formatMemberDisplayName(style, name)}: ${displayStatus}`,
227
+ `time in state: ${elapsed || "(unknown)"}`,
228
+ `last event: ${noEventFor || "(unknown)"} ago`,
229
+ `current activity: ${currentTool || "(none)"}`,
230
+ `tool calls: ${activity.toolUseCount} · turns: ${activity.turnCount} · tokens: ${formatTokens(activity.totalTokens)}`,
231
+ ];
232
+ if (typeof model === "string" && model) lines.push(`model: ${model}`);
233
+ if (cwd) lines.push(`cwd: ${cwd}`);
234
+ if (activeTask) lines.push(`active task: #${activeTask.id} ${activeTask.subject}`);
235
+ lines.push(`tasks: ${owned.filter((t) => t.status === "pending").length} pending · ${owned.filter((t) => t.status === "in_progress").length} in-progress · ${owned.filter((t) => t.status === "completed").length} completed`);
236
+ if (msgPreview) lines.push(`last message: ${msgPreview}`);
237
+ if (displayStatus === "stalled") {
238
+ lines.push(`⚠ WARNING: no agent events for ${noEventFor} — worker may be stalled`);
239
+ }
240
+
241
+ ctx.ui.notify(lines.join("\n"), "info");
242
+ }
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
- import { cleanupTeamDir } from "./cleanup.js";
5
+ import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js";
6
6
  import { writeToMailbox } from "./mailbox.js";
7
7
  import { sanitizeName } from "./names.js";
8
8
  import { getTeamDir, getTeamsRootDir, getTeamsStylesDir } from "./paths.js";
@@ -220,13 +220,23 @@ export async function handleTeamCleanupCommand(opts: {
220
220
  }
221
221
 
222
222
  try {
223
- await cleanupTeamDir(teamsRoot, teamDir);
223
+ const result = await cleanupTeamDir(teamsRoot, teamDir, { teamId, repoCwd: ctx.cwd });
224
+ const parts: string[] = [`Cleaned up team directory: ${teamDir}`];
225
+ if (result.worktreeResult.removedWorktrees.length > 0) {
226
+ parts.push(`Removed ${result.worktreeResult.removedWorktrees.length} worktree(s)`);
227
+ }
228
+ if (result.worktreeResult.removedBranches.length > 0) {
229
+ parts.push(`Removed ${result.worktreeResult.removedBranches.length} branch(es)`);
230
+ }
231
+ for (const w of result.warnings) {
232
+ parts.push(`⚠ ${w}`);
233
+ }
234
+ ctx.ui.notify(parts.join("\n"), "warning");
224
235
  } catch (err) {
225
236
  ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
226
237
  return;
227
238
  }
228
239
 
229
- ctx.ui.notify(`Cleaned up team directory: ${teamDir}`, "warning");
230
240
  await refreshTasks();
231
241
  renderWidget();
232
242
  }
@@ -519,6 +529,116 @@ export async function handleTeamStopCommand(opts: {
519
529
  renderWidget();
520
530
  }
521
531
 
532
+ /**
533
+ * `/team done` — end-of-run cleanup.
534
+ *
535
+ * Stops all teammates, hides the widget, and optionally cleans up team artifacts.
536
+ * This is the "team is finished" ergonomic counterpart to `/team cleanup`.
537
+ *
538
+ * Behavior:
539
+ * 1. Stops all RPC teammates (graceful, no confirmation prompt).
540
+ * 2. Marks all config-only workers offline.
541
+ * 3. Hides the Teams widget.
542
+ * 4. Notifies the user with a summary.
543
+ *
544
+ * Use `/team cleanup [--force]` afterward if you also want to delete task/mailbox files.
545
+ */
546
+ export async function handleTeamDoneCommand(opts: {
547
+ ctx: ExtensionCommandContext;
548
+ rest: string[];
549
+ teamId: string;
550
+ teammates: Map<string, TeammateRpc>;
551
+ getTeamConfig: () => TeamConfig | null;
552
+ leadName: string;
553
+ style: TeamsStyle;
554
+ stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
555
+ refreshTasks: () => Promise<void>;
556
+ getTasks: () => TeamTask[];
557
+ hideWidget: () => void;
558
+ }): Promise<void> {
559
+ const { ctx, rest, teamId, teammates, getTeamConfig, leadName, style, stopAllTeammates, refreshTasks, getTasks, hideWidget } = opts;
560
+ const strings = getTeamsStrings(style);
561
+
562
+ const flags = rest.filter((a) => a.startsWith("--"));
563
+ const unknownFlags = flags.filter((f) => f !== "--force");
564
+ if (unknownFlags.length) {
565
+ ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
566
+ return;
567
+ }
568
+
569
+ await refreshTasks();
570
+ const tasks = getTasks();
571
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
572
+
573
+ if (inProgress.length > 0 && !flags.includes("--force")) {
574
+ ctx.ui.notify(
575
+ `${inProgress.length} task(s) still in progress. Use /team done --force to end anyway.`,
576
+ "error",
577
+ );
578
+ return;
579
+ }
580
+
581
+ // Stop all RPC teammates
582
+ const reason = formatTeamsTemplate(strings.teamEndedAllStopped, {
583
+ members: `${strings.memberTitle.toLowerCase()}s`,
584
+ count: String(teammates.size),
585
+ });
586
+ await stopAllTeammates(ctx, reason);
587
+
588
+ // Mark manual/config workers offline
589
+ const cfg = getTeamConfig();
590
+ const teamDir = getTeamDir(teamId);
591
+ const manualWorkers = (cfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
592
+ for (const m of manualWorkers) {
593
+ if (teammates.has(m.name)) continue;
594
+ const ts = new Date().toISOString();
595
+ try {
596
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
597
+ from: leadName,
598
+ text: JSON.stringify({
599
+ type: "shutdown_request",
600
+ requestId: randomUUID(),
601
+ from: leadName,
602
+ timestamp: ts,
603
+ reason: "Team done",
604
+ }),
605
+ timestamp: ts,
606
+ });
607
+ } catch {
608
+ // ignore
609
+ }
610
+ await setMemberStatus(teamDir, m.name, "offline", {
611
+ meta: { stoppedReason: "team-done", stoppedAt: ts },
612
+ });
613
+ }
614
+
615
+ // Unassign any in-progress tasks (force mode)
616
+ if (inProgress.length > 0) {
617
+ for (const task of inProgress) {
618
+ if (task.owner) {
619
+ await unassignTasksForAgent(teamDir, cfg?.taskListId ?? teamId, task.owner, "team done");
620
+ }
621
+ }
622
+ }
623
+
624
+ await refreshTasks();
625
+
626
+ // Hide the widget
627
+ hideWidget();
628
+
629
+ // Summary
630
+ const completed = tasks.filter((t) => t.status === "completed").length;
631
+ const pending = tasks.filter((t) => t.status === "pending").length;
632
+ const total = tasks.length;
633
+
634
+ const summaryParts = [`Team done. ${total} task(s): ${completed} completed`];
635
+ if (pending > 0) summaryParts.push(`${pending} pending`);
636
+ if (inProgress.length > 0) summaryParts.push(`${inProgress.length} were in-progress (unassigned)`);
637
+ summaryParts.push("Widget hidden. Use /team cleanup to remove artifacts.");
638
+
639
+ ctx.ui.notify(summaryParts.join(", ") + ".", "info");
640
+ }
641
+
522
642
  export async function handleTeamKillCommand(opts: {
523
643
  ctx: ExtensionCommandContext;
524
644
  rest: string[];
@@ -557,3 +677,85 @@ export async function handleTeamKillCommand(opts: {
557
677
  await refreshTasks();
558
678
  renderWidget();
559
679
  }
680
+
681
+ const DEFAULT_GC_MAX_AGE_HOURS = 24;
682
+
683
+ export async function handleTeamGcCommand(opts: {
684
+ ctx: ExtensionCommandContext;
685
+ rest: string[];
686
+ }): Promise<void> {
687
+ const { ctx, rest } = opts;
688
+
689
+ const flags = rest.filter((a) => a.startsWith("--"));
690
+ const argsOnly = rest.filter((a) => !a.startsWith("--"));
691
+ const dryRun = flags.includes("--dry-run");
692
+ const force = flags.includes("--force");
693
+
694
+ const unknownFlags = flags.filter((f) => f !== "--dry-run" && f !== "--force" && !f.startsWith("--max-age-hours="));
695
+ if (unknownFlags.length) {
696
+ ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
697
+ return;
698
+ }
699
+ if (argsOnly.length) {
700
+ ctx.ui.notify("Usage: /team gc [--dry-run] [--force] [--max-age-hours=N]", "error");
701
+ return;
702
+ }
703
+
704
+ // Parse --max-age-hours=N
705
+ let maxAgeHours = DEFAULT_GC_MAX_AGE_HOURS;
706
+ const maxAgeFlag = flags.find((f) => f.startsWith("--max-age-hours="));
707
+ if (maxAgeFlag) {
708
+ const val = Number(maxAgeFlag.split("=")[1]);
709
+ if (!Number.isFinite(val) || val < 0) {
710
+ ctx.ui.notify("--max-age-hours must be a non-negative number", "error");
711
+ return;
712
+ }
713
+ maxAgeHours = val;
714
+ }
715
+
716
+ const teamsRoot = getTeamsRootDir();
717
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
718
+
719
+ if (!force && !dryRun) {
720
+ if (process.stdout.isTTY && process.stdin.isTTY) {
721
+ const ok = await ctx.ui.confirm(
722
+ "Garbage collect teams",
723
+ `Remove all stale team directories older than ${maxAgeHours}h?\nTeams root: ${teamsRoot}`,
724
+ );
725
+ if (!ok) return;
726
+ } else {
727
+ ctx.ui.notify("Use --force or --dry-run in non-interactive mode", "error");
728
+ return;
729
+ }
730
+ }
731
+
732
+ const result = await gcStaleTeamDirs({
733
+ teamsRootDir: teamsRoot,
734
+ maxAgeMs,
735
+ repoCwd: ctx.cwd,
736
+ dryRun,
737
+ });
738
+
739
+ const lines: string[] = [];
740
+ if (dryRun) {
741
+ lines.push(`[DRY RUN] Would remove ${result.removed.length} of ${result.scanned} team dirs`);
742
+ } else {
743
+ lines.push(`Removed ${result.removed.length} of ${result.scanned} team dirs`);
744
+ }
745
+ if (result.skipped.length > 0) {
746
+ const byReason = new Map<string, number>();
747
+ for (const s of result.skipped) {
748
+ byReason.set(s.reason, (byReason.get(s.reason) ?? 0) + 1);
749
+ }
750
+ const parts: string[] = [];
751
+ for (const [reason, count] of byReason) {
752
+ parts.push(`${count} ${reason}`);
753
+ }
754
+ lines.push(`Skipped: ${parts.join(", ")}`);
755
+ }
756
+ for (const w of result.warnings) {
757
+ lines.push(`⚠ ${w}`);
758
+ }
759
+
760
+ ctx.ui.notify(lines.join("\n"), dryRun ? "info" : "warning");
761
+ }