@tmustier/pi-agent-teams 0.4.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.
@@ -9,15 +9,17 @@ 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";
14
- import { ensureWorktreeCwd } from "./worktree.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";
15
+ import { ensureWorktreeCwd, cleanupWorktrees } from "./worktree.js";
15
16
  import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
16
17
  import { openInteractiveWidget } from "./teams-panel.js";
18
+ import { isTeamDone } from "./teams-ui-shared.js";
17
19
  import { createTeamsWidget } from "./teams-widget.js";
18
20
  import { resolveTeammateModelSelection, formatProviderModel } from "./model-policy.js";
19
21
  import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
20
- import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
22
+ import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
21
23
  import {
22
24
  getHookBaseName,
23
25
  getTeamsHookFailureAction,
@@ -105,6 +107,29 @@ async function createSessionForTeammate(
105
107
  }
106
108
  }
107
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
+
108
133
  // Message parsers are shared with the worker implementation.
109
134
  export function runLeader(pi: ExtensionAPI): void {
110
135
  const teammates = new Map<string, TeammateRpc>();
@@ -116,6 +141,7 @@ export function runLeader(pi: ExtensionAPI): void {
116
141
  let tasks: TeamTask[] = [];
117
142
  let teamConfig: TeamConfig | null = null;
118
143
  const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
144
+ const delegationTracker = new DelegationTracker();
119
145
  // Task list namespace. By default we keep it aligned with the current session id.
120
146
  // (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
121
147
  // and can easily be set globally, which makes the leader "lose" its tasks.)
@@ -160,6 +186,7 @@ export function runLeader(pi: ExtensionAPI): void {
160
186
  );
161
187
  currentTeamId = sessionTeamId;
162
188
  taskListId = sessionTeamId;
189
+ delegationTracker.clear();
163
190
  await refreshTasks();
164
191
  renderWidget();
165
192
  };
@@ -411,8 +438,16 @@ export function runLeader(pi: ExtensionAPI): void {
411
438
  isDelegateMode: () => delegateMode,
412
439
  getActiveTeamId: () => currentTeamId,
413
440
  getSessionTeamId: () => currentCtx?.sessionManager.getSessionId() ?? null,
441
+ getLeaderModel: () => {
442
+ const model = currentCtx?.model;
443
+ if (!model) return null;
444
+ return { provider: model.provider, modelId: model.id };
445
+ },
414
446
  });
415
447
 
448
+ // Auto-done detection: notify once when all tasks complete and teammates idle.
449
+ let autoDoneNotified = false;
450
+
416
451
  const refreshTasks = async () => {
417
452
  if (!currentCtx || !currentTeamId) return;
418
453
  const teamDir = getTeamDir(currentTeamId);
@@ -429,6 +464,17 @@ export function runLeader(pi: ExtensionAPI): void {
429
464
  style,
430
465
  }));
431
466
  style = teamConfig.style ?? style;
467
+
468
+ // Auto-done hint (fire once per "all done" state transition)
469
+ if (isTeamDone(tasks, teammates)) {
470
+ if (!autoDoneNotified) {
471
+ autoDoneNotified = true;
472
+ currentCtx.ui.notify("All tasks completed. Use /team done to end the team session.", "info");
473
+ }
474
+ } else {
475
+ // Reset when work resumes (new tasks added, etc.)
476
+ autoDoneNotified = false;
477
+ }
432
478
  };
433
479
 
434
480
  let widgetSuppressed = false;
@@ -439,6 +485,16 @@ export function runLeader(pi: ExtensionAPI): void {
439
485
  currentCtx.ui.setWidget("pi-teams", widgetFactory);
440
486
  };
441
487
 
488
+ const hideWidget = () => {
489
+ widgetSuppressed = true;
490
+ if (currentCtx) currentCtx.ui.setWidget("pi-teams", undefined);
491
+ };
492
+
493
+ const restoreWidget = () => {
494
+ widgetSuppressed = false;
495
+ renderWidget();
496
+ };
497
+
442
498
  const spawnTeammate: SpawnTeammateFn = async (ctx, opts): Promise<SpawnTeammateResult> => {
443
499
  const warnings: string[] = [];
444
500
  const mode: ContextMode = opts.mode ?? "fresh";
@@ -472,10 +528,21 @@ export function runLeader(pi: ExtensionAPI): void {
472
528
 
473
529
  const t = new TeammateRpc(name, sessionFile);
474
530
  teammates.set(name, t);
531
+ // Restore the widget if it was hidden by /team done — new work is starting.
532
+ restoreWidget();
475
533
  // Track teammate activity for the widget/panel.
534
+ // Render on status-changing events for a more "live" feel.
476
535
  const unsub = t.onEvent((ev) => {
477
536
  tracker.handleEvent(name, ev);
478
537
  transcriptTracker.handleEvent(name, ev);
538
+ // Refresh widget on events that change visible state (tool start/end, turn end).
539
+ if (
540
+ ev.type === "tool_execution_start" ||
541
+ ev.type === "tool_execution_end" ||
542
+ ev.type === "agent_end"
543
+ ) {
544
+ renderWidget();
545
+ }
479
546
  });
480
547
  teammateEventUnsubs.set(name, unsub);
481
548
  renderWidget();
@@ -598,7 +665,17 @@ export function runLeader(pi: ExtensionAPI): void {
598
665
  await refreshTasks();
599
666
  renderWidget();
600
667
 
601
- return { ok: true, name, mode, workspaceMode, childCwd, note, warnings };
668
+ return {
669
+ ok: true,
670
+ name,
671
+ mode,
672
+ workspaceMode,
673
+ childCwd,
674
+ note,
675
+ model: childModel ?? undefined,
676
+ thinking: thinkingLevel,
677
+ warnings,
678
+ };
602
679
  };
603
680
 
604
681
  const pollLeaderInbox = async () => {
@@ -614,6 +691,10 @@ export function runLeader(pi: ExtensionAPI): void {
614
691
  style,
615
692
  pendingPlanApprovals,
616
693
  enqueueHook,
694
+ sendLeaderLlmMessage: (content, options) => {
695
+ pi.sendUserMessage(content, options);
696
+ },
697
+ delegationTracker,
617
698
  });
618
699
  };
619
700
 
@@ -632,6 +713,9 @@ export function runLeader(pi: ExtensionAPI): void {
632
713
  // use `/team task use <taskListId>` after switching.
633
714
  taskListId = currentTeamId;
634
715
  lastAttachClaimHeartbeatMs = 0;
716
+ // Clear any /team done suppression from a previous session.
717
+ widgetSuppressed = false;
718
+ autoDoneNotified = false;
635
719
 
636
720
  // Claude-style: a persisted team config file.
637
721
  await ensureTeamConfig(getTeamDir(currentTeamId), {
@@ -641,6 +725,15 @@ export function runLeader(pi: ExtensionAPI): void {
641
725
  style,
642
726
  });
643
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
+
644
737
  await refreshTasks();
645
738
  renderWidget();
646
739
 
@@ -671,12 +764,28 @@ export function runLeader(pi: ExtensionAPI): void {
671
764
  });
672
765
 
673
766
  pi.on("session_switch", async (_event, ctx) => {
767
+ const prevTeamId = currentTeamId;
768
+ const prevCwd = currentCtx?.cwd;
769
+
674
770
  if (currentCtx) {
675
771
  await releaseActiveAttachClaim(currentCtx);
676
772
  const strings = getTeamsStrings(style);
677
773
  await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is dissolved — leader moved on`);
678
774
  }
679
775
  stopLoops();
776
+ delegationTracker.clear();
777
+
778
+ // Clean up worktrees from the old session before switching.
779
+ // Only clean up teams this session owns — never attached teams.
780
+ const prevSessionId = currentCtx?.sessionManager.getSessionId();
781
+ if (prevTeamId && prevTeamId === prevSessionId) {
782
+ const teamDir = getTeamDir(prevTeamId);
783
+ try {
784
+ await cleanupWorktrees({ teamDir, teamId: prevTeamId, repoCwd: prevCwd });
785
+ } catch {
786
+ // Best-effort — don't block session switch.
787
+ }
788
+ }
680
789
 
681
790
  currentCtx = ctx;
682
791
  currentTeamId = currentCtx.sessionManager.getSessionId();
@@ -684,6 +793,9 @@ export function runLeader(pi: ExtensionAPI): void {
684
793
  // use `/team task use <taskListId>` after switching.
685
794
  taskListId = currentTeamId;
686
795
  lastAttachClaimHeartbeatMs = 0;
796
+ // Clear any /team done suppression — new session context.
797
+ widgetSuppressed = false;
798
+ autoDoneNotified = false;
687
799
 
688
800
  await ensureTeamConfig(getTeamDir(currentTeamId), {
689
801
  teamId: currentTeamId,
@@ -725,8 +837,45 @@ export function runLeader(pi: ExtensionAPI): void {
725
837
  if (!currentCtx) return;
726
838
  await releaseActiveAttachClaim(currentCtx);
727
839
  stopLoops();
840
+ const hadTeammates = teammates.size > 0;
728
841
  const strings = getTeamsStrings(style);
729
842
  await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
843
+
844
+ // Clean up worktrees + branches for this team session so they don't accumulate on disk.
845
+ // Only clean up teams this session owns — never attached teams.
846
+ const sessionId = currentCtx.sessionManager.getSessionId();
847
+ if (currentTeamId && currentTeamId === sessionId) {
848
+ const teamDir = getTeamDir(currentTeamId);
849
+ try {
850
+ await cleanupWorktrees({ teamDir, teamId: currentTeamId, repoCwd: currentCtx.cwd });
851
+ } catch {
852
+ // Best-effort — don't block shutdown.
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
+ }
878
+ }
730
879
  });
731
880
 
732
881
  registerTeamsTool({
@@ -735,9 +884,17 @@ export function runLeader(pi: ExtensionAPI): void {
735
884
  spawnTeammate,
736
885
  getTeamId: (ctx) => currentTeamId ?? ctx.sessionManager.getSessionId(),
737
886
  getTaskListId: () => taskListId,
887
+ getTracker: () => tracker,
888
+ getTeamConfig: () => teamConfig,
738
889
  refreshTasks,
739
890
  renderWidget,
891
+ hideWidget,
892
+ stopAllTeammates: async (reason: string) => {
893
+ if (!currentCtx) return;
894
+ await stopAllTeammates(currentCtx, reason);
895
+ },
740
896
  pendingPlanApprovals,
897
+ delegationTracker,
741
898
  });
742
899
 
743
900
  const openWidget = async (ctx: ExtensionCommandContext) => {
@@ -841,13 +998,16 @@ export function runLeader(pi: ExtensionAPI): void {
841
998
  getSessionTeamId() {
842
999
  return ctx.sessionManager.getSessionId();
843
1000
  },
1001
+ getLeaderModel() {
1002
+ const model = currentCtx?.model;
1003
+ if (!model) return null;
1004
+ return { provider: model.provider, modelId: model.id };
1005
+ },
844
1006
  suppressWidget() {
845
- widgetSuppressed = true;
846
- ctx.ui.setWidget("pi-teams", undefined);
1007
+ hideWidget();
847
1008
  },
848
1009
  restoreWidget() {
849
- widgetSuppressed = false;
850
- renderWidget();
1010
+ restoreWidget();
851
1011
  },
852
1012
  });
853
1013
  };
@@ -893,16 +1053,21 @@ export function runLeader(pi: ExtensionAPI): void {
893
1053
  ctx,
894
1054
  teammates,
895
1055
  getTeamConfig: () => teamConfig,
1056
+ getTracker: () => tracker,
896
1057
  getTasks: () => tasks,
897
1058
  refreshTasks,
898
1059
  renderWidget,
1060
+ hideWidget,
1061
+ restoreWidget,
899
1062
  getTaskListId: () => taskListId,
900
1063
  setTaskListId: (id) => {
901
1064
  taskListId = id;
1065
+ delegationTracker.clear();
902
1066
  },
903
1067
  getActiveTeamId: () => currentTeamId ?? ctx.sessionManager.getSessionId(),
904
1068
  setActiveTeamId: (teamId) => {
905
1069
  currentTeamId = teamId;
1070
+ delegationTracker.clear();
906
1071
  },
907
1072
  pendingPlanApprovals,
908
1073
  getDelegateMode: () => delegateMode,
@@ -9,6 +9,9 @@ export interface MailboxMessage {
9
9
  timestamp: string;
10
10
  read: boolean;
11
11
  color?: string;
12
+ /** When true, the recipient should deliver this message as a steering interrupt
13
+ * even if the agent is mid-turn, rather than queueing for the next idle window. */
14
+ urgent?: boolean;
12
15
  }
13
16
 
14
17
  function inboxDir(teamDir: string, namespace: string): string {
@@ -38,7 +41,8 @@ function coerceMailboxMessage(v: unknown): MailboxMessage | null {
38
41
  if (typeof v.timestamp !== "string") return null;
39
42
  const read = typeof v.read === "boolean" ? v.read : false;
40
43
  const color = typeof v.color === "string" ? v.color : undefined;
41
- return { from: v.from, text: v.text, timestamp: v.timestamp, read, color };
44
+ const urgent = typeof v.urgent === "boolean" ? v.urgent : undefined;
45
+ return { from: v.from, text: v.text, timestamp: v.timestamp, read, color, urgent };
42
46
  }
43
47
 
44
48
  async function readJsonArray(file: string): Promise<unknown[]> {
@@ -80,6 +84,7 @@ export async function writeToMailbox(
80
84
  timestamp: msg.timestamp,
81
85
  read: msg.read ?? false,
82
86
  color: msg.color,
87
+ ...(msg.urgent === true ? { urgent: true } : {}),
83
88
  };
84
89
  arr.push(m);
85
90
  await writeJsonAtomic(inboxPath, arr);
@@ -29,6 +29,10 @@ export type SpawnTeammateResult =
29
29
  workspaceMode: WorkspaceMode;
30
30
  childCwd?: string;
31
31
  note?: string;
32
+ /** The resolved model string (provider/modelId or modelId), if any. */
33
+ model?: string;
34
+ /** The effective thinking level for this teammate. */
35
+ thinking?: ThinkingLevel;
32
36
  warnings: string[];
33
37
  }
34
38
  | { ok: false; error: string };
@@ -86,6 +86,12 @@ export class TeammateRpc {
86
86
  /** Task currently assigned by the team lead (if any). */
87
87
  currentTaskId: string | null = null;
88
88
 
89
+ /** Epoch ms when the current `status` was entered. */
90
+ lastStatusChangeAt: number = Date.now();
91
+
92
+ /** Epoch ms of the most recent agent event received from the child process. */
93
+ lastEventAt: number = Date.now();
94
+
89
95
  private proc: ReturnType<typeof spawn> | null = null;
90
96
  private pending = new Map<string, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
91
97
  private nextId = 0;
@@ -164,6 +170,9 @@ export class TeammateRpc {
164
170
  // Give the child a moment to boot.
165
171
  await new Promise((r) => setTimeout(r, 120));
166
172
  this.status = "idle";
173
+ const bootNow = Date.now();
174
+ this.lastStatusChangeAt = bootNow;
175
+ this.lastEventAt = bootNow;
167
176
  }
168
177
 
169
178
  async stop(): Promise<void> {
@@ -224,12 +233,17 @@ export class TeammateRpc {
224
233
  // Agent event
225
234
  if (!isAgentEvent(obj)) return;
226
235
  const ev = obj;
236
+ const now = Date.now();
237
+ this.lastEventAt = now;
238
+
227
239
  if (ev.type === "agent_start") {
228
240
  this.status = "streaming";
241
+ this.lastStatusChangeAt = now;
229
242
  this.lastAssistantText = "";
230
243
  }
231
244
  if (ev.type === "agent_end") {
232
245
  this.status = "idle";
246
+ this.lastStatusChangeAt = now;
233
247
  }
234
248
  if (ev.type === "message_update") {
235
249
  const ame = ev.assistantMessageEvent;