@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.
@@ -1,21 +1,28 @@
1
1
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
2
  import type { Component, TUI } from "@mariozechner/pi-tui";
3
3
  import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
4
- import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
4
+ import type { TeammateRpc } from "./teammate-rpc.js";
5
5
  import type { ActivityTracker } from "./activity-tracker.js";
6
6
  import type { TeamTask } from "./task-store.js";
7
7
  import type { TeamConfig, TeamMember } from "./team-config.js";
8
8
  import type { TeamsStyle } from "./teams-style.js";
9
9
  import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
10
10
  import {
11
- STATUS_COLOR,
12
- STATUS_ICON,
11
+ DISPLAY_STATUS_COLOR,
12
+ DISPLAY_STATUS_ICON,
13
+ formatElapsed,
13
14
  formatTokens,
15
+ getMemberModel,
16
+ getMemberThinking,
14
17
  getVisibleWorkerNames,
18
+ isTeamDone,
15
19
  padRight,
16
- resolveStatus,
20
+ renderPolicySummary,
21
+ resolveDisplayStatus,
22
+ shortModelLabel,
17
23
  toolActivity,
18
24
  } from "./teams-ui-shared.js";
25
+ import type { DisplayStatus, LeaderModelInfo } from "./teams-ui-shared.js";
19
26
 
20
27
  export interface WidgetDeps {
21
28
  getTeammates(): Map<string, TeammateRpc>;
@@ -26,6 +33,7 @@ export interface WidgetDeps {
26
33
  isDelegateMode(): boolean;
27
34
  getActiveTeamId(): string | null;
28
35
  getSessionTeamId(): string | null;
36
+ getLeaderModel(): LeaderModelInfo | null;
29
37
  }
30
38
 
31
39
  export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
@@ -34,11 +42,19 @@ interface WidgetRow {
34
42
  icon: string; // raw char (before styling)
35
43
  iconColor: ThemeColor;
36
44
  displayName: string;
37
- statusKey: TeammateStatus;
45
+ statusKey: DisplayStatus;
38
46
  pending: number;
39
47
  completed: number;
40
48
  tokensStr: string; // "—" for chairman
41
49
  activityText: string;
50
+ /** Compact time-in-current-state string, e.g. "3m12s". Empty for leader. */
51
+ elapsedStr: string;
52
+ /** Short model label (e.g. "claude-sonnet-4-5") or null. */
53
+ modelLabel: string | null;
54
+ /** Thinking level (e.g. "high") or null. */
55
+ thinkingLabel: string | null;
56
+ /** Active task subject (if any). */
57
+ activeTaskSubject: string | null;
42
58
  }
43
59
 
44
60
  function shortTeamId(teamId: string): string {
@@ -96,6 +112,15 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
96
112
  );
97
113
  }
98
114
 
115
+ // ── Policy summary ──
116
+ const policyLines = renderPolicySummary({
117
+ teamConfig,
118
+ leaderModel: deps.getLeaderModel(),
119
+ theme,
120
+ width,
121
+ });
122
+ for (const pl of policyLines) lines.push(pl);
123
+
99
124
  // ── Build row data ──
100
125
  const cfgMembers = teamConfig?.members ?? [];
101
126
  const cfgByName = new Map<string, TeamMember>();
@@ -116,6 +141,10 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
116
141
  completed: leadTasks.filter((t) => t.status === "completed").length,
117
142
  tokensStr: "\u2014",
118
143
  activityText: "",
144
+ elapsedStr: "",
145
+ modelLabel: null,
146
+ thinkingLabel: null,
147
+ activeTaskSubject: null,
119
148
  });
120
149
  }
121
150
 
@@ -131,19 +160,27 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
131
160
  for (const name of workerNames) {
132
161
  const rpc = teammates.get(name);
133
162
  const cfg = cfgByName.get(name);
134
- const statusKey = resolveStatus(rpc, cfg);
163
+ const statusKey = resolveDisplayStatus(rpc, cfg);
135
164
  const activity = tracker.get(name);
136
165
  const owned = tasks.filter((t) => t.owner === name);
166
+ const activeTask = owned.find((t) => t.status === "in_progress");
167
+ const memberModel = getMemberModel(cfg);
168
+ const memberThinking = getMemberThinking(cfg);
169
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
137
170
 
138
171
  rows.push({
139
- icon: STATUS_ICON[statusKey],
140
- iconColor: STATUS_COLOR[statusKey],
172
+ icon: DISPLAY_STATUS_ICON[statusKey],
173
+ iconColor: DISPLAY_STATUS_COLOR[statusKey],
141
174
  displayName: formatMemberDisplayName(style, name),
142
175
  statusKey,
143
176
  pending: owned.filter((t) => t.status === "pending").length,
144
177
  completed: owned.filter((t) => t.status === "completed").length,
145
178
  tokensStr: formatTokens(activity.totalTokens),
146
179
  activityText: toolActivity(activity.currentToolName),
180
+ elapsedStr: elapsed,
181
+ modelLabel: memberModel ? shortModelLabel(memberModel) : null,
182
+ thinkingLabel: memberThinking,
183
+ activeTaskSubject: activeTask ? `#${String(activeTask.id)} ${activeTask.subject}` : null,
147
184
  });
148
185
  }
149
186
 
@@ -163,7 +200,7 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
163
200
  for (const r of rows) {
164
201
  const icon = theme.fg(r.iconColor, r.icon);
165
202
  const styledName = theme.bold(r.displayName);
166
- const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
203
+ const statusLabel = theme.fg(DISPLAY_STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
167
204
  const pNum = String(r.pending).padStart(pW);
168
205
  const cNum = String(r.completed).padStart(cW);
169
206
  const tokStr = r.tokensStr.padStart(tokW);
@@ -171,10 +208,21 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
171
208
  "dim",
172
209
  ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
173
210
  );
211
+ const elapsedLabel = r.elapsedStr ? " " + theme.fg("dim", r.elapsedStr) : "";
174
212
  const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
213
+ // Model + thinking badge (compact)
214
+ const badges: string[] = [];
215
+ if (r.modelLabel) badges.push(r.modelLabel);
216
+ if (r.thinkingLabel && r.thinkingLabel !== "off") badges.push(`t:${r.thinkingLabel}`);
217
+ const badgeStr = badges.length > 0 ? " " + theme.fg("muted", badges.join(" \u00b7 ")) : "";
175
218
 
176
- const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
219
+ const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${elapsedLabel}${cols}${actLabel}${badgeStr}`;
177
220
  lines.push(truncateToWidth(row, width));
221
+ // Active task on second line (indented, only when actively working)
222
+ if (r.activeTaskSubject) {
223
+ const taskLine = ` ${theme.fg("dim", "\u2514")} ${theme.fg("warning", r.activeTaskSubject)}`;
224
+ lines.push(truncateToWidth(taskLine, width));
225
+ }
178
226
  }
179
227
 
180
228
  // ── Total row ──
@@ -198,10 +246,15 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
198
246
  }
199
247
 
200
248
  // ── Hints line ──
201
- const hints = theme.fg(
202
- "dim",
203
- " /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
204
- );
249
+ const teamDone = isTeamDone(tasks, teammates);
250
+ const hints = teamDone
251
+ ? theme.fg("success", " All tasks done.") +
252
+ " " +
253
+ theme.fg("dim", "/team done \u00b7 /team task list")
254
+ : theme.fg(
255
+ "dim",
256
+ " /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
257
+ );
205
258
  lines.push(truncateToWidth(hints, width));
206
259
 
207
260
  return lines;
@@ -130,16 +130,19 @@ export function runWorker(pi: ExtensionAPI): void {
130
130
  const TeamMessageToolParamsSchema = Type.Object({
131
131
  recipient: Type.String({ description: "Name of the comrade to message" }),
132
132
  message: Type.String({ description: "The message to send" }),
133
+ urgent: Type.Optional(Type.Boolean({
134
+ description: "When true, the message interrupts the recipient's active turn via steering instead of waiting for idle. Use for time-sensitive coordination.",
135
+ })),
133
136
  });
134
137
  // Match the schema at compile-time.
135
138
  type TeamMessageToolParams = Static<typeof TeamMessageToolParamsSchema>;
136
139
  // Tool result details to match AgentToolResult<TDetails> contract.
137
- type TeamMessageToolDetails = { recipient: string; timestamp: string };
140
+ type TeamMessageToolDetails = { recipient: string; timestamp: string; urgent: boolean };
138
141
 
139
142
  pi.registerTool({
140
143
  name: "team_message",
141
144
  label: "Team Message",
142
- description: "Send a message to a comrade. Use this to coordinate with peers on related tasks.",
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).",
143
146
  parameters: TeamMessageToolParamsSchema,
144
147
  async execute(
145
148
  _toolCallId,
@@ -150,12 +153,14 @@ export function runWorker(pi: ExtensionAPI): void {
150
153
  ): Promise<AgentToolResult<TeamMessageToolDetails>> {
151
154
  const recipient = sanitizeName(params.recipient);
152
155
  const message = params.message;
156
+ const isUrgent = params.urgent === true;
153
157
  const ts = new Date().toISOString();
154
158
  // Write to recipient's mailbox in team namespace
155
159
  await writeToMailbox(teamDir, TEAM_MAILBOX_NS, recipient, {
156
160
  from: agentName,
157
161
  text: message,
158
162
  timestamp: ts,
163
+ ...(isUrgent ? { urgent: true } : {}),
159
164
  });
160
165
  // CC leader with peer_dm_sent notification
161
166
  await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
@@ -165,13 +170,14 @@ export function runWorker(pi: ExtensionAPI): void {
165
170
  from: agentName,
166
171
  to: recipient,
167
172
  summary: message.slice(0, 100),
173
+ urgent: isUrgent,
168
174
  timestamp: ts,
169
175
  }),
170
176
  timestamp: ts,
171
177
  });
172
178
  return {
173
- content: [{ type: "text", text: `Message sent to ${recipient}` }],
174
- details: { recipient, timestamp: ts },
179
+ content: [{ type: "text", text: `${isUrgent ? "Urgent message" : "Message"} sent to ${recipient}` }],
180
+ details: { recipient, timestamp: ts, urgent: isUrgent },
175
181
  };
176
182
  },
177
183
  });
@@ -330,8 +336,14 @@ export function runWorker(pi: ExtensionAPI): void {
330
336
  pendingTaskAssignments.push(assign.taskId);
331
337
  continue;
332
338
  }
333
- // Plain DM (or unknown structured message)
334
- pendingDmTexts.push(m.text);
339
+
340
+ // Urgent DMs interrupt the active turn via steer; normal DMs queue for idle.
341
+ if (m.urgent && isStreaming) {
342
+ const prefix = `[urgent message from ${m.from}] `;
343
+ pi.sendUserMessage(prefix + m.text, { deliverAs: "steer" });
344
+ } else {
345
+ pendingDmTexts.push(m.text);
346
+ }
335
347
  }
336
348
 
337
349
  if (!shutdownInProgress) await maybeStartNextWork();
@@ -3,6 +3,12 @@ import * as path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { sanitizeName } from "./names.js";
5
5
 
6
+ export type WorktreeCleanupResult = {
7
+ removedWorktrees: string[];
8
+ removedBranches: string[];
9
+ warnings: string[];
10
+ };
11
+
6
12
  async function execGit(args: string[], opts: { cwd: string; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> {
7
13
  return await new Promise((resolve, reject) => {
8
14
  execFile(
@@ -101,3 +107,140 @@ export async function ensureWorktreeCwd(opts: {
101
107
  return { cwd: opts.leaderCwd, warnings, mode: "shared" };
102
108
  }
103
109
  }
110
+
111
+ /**
112
+ * Find the git repo root from a directory that may be inside a worktree.
113
+ * Returns null if not a git repo.
114
+ */
115
+ async function findRepoRoot(cwd: string): Promise<string | null> {
116
+ try {
117
+ // --show-superproject-working-tree returns empty if not a worktree subproject.
118
+ // Use the commondir approach: worktrees share a common git dir with the main repo.
119
+ const commonDir = (await execGit(["rev-parse", "--git-common-dir"], { cwd })).stdout.trim();
120
+ if (commonDir && !path.isAbsolute(commonDir)) {
121
+ // Relative to the .git dir of the worktree — resolve upward.
122
+ const gitDir = (await execGit(["rev-parse", "--git-dir"], { cwd })).stdout.trim();
123
+ const absCommon = path.resolve(cwd, gitDir, commonDir);
124
+ // The common dir is the .git directory of the main repo. Its parent is the repo root.
125
+ return path.dirname(absCommon);
126
+ }
127
+ if (commonDir && path.isAbsolute(commonDir)) {
128
+ return path.dirname(commonDir);
129
+ }
130
+ // Fallback: plain repo
131
+ const toplevel = (await execGit(["rev-parse", "--show-toplevel"], { cwd })).stdout.trim();
132
+ return toplevel || null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Remove all git worktrees under `<teamDir>/worktrees/` and their associated branches.
140
+ *
141
+ * Steps for each worktree:
142
+ * 1. `git worktree remove --force <path>` (removes the worktree checkout)
143
+ * 2. `git branch -D <branch>` (removes the local branch)
144
+ * 3. Falls back to filesystem removal if git commands fail
145
+ *
146
+ * Also runs `git worktree prune` to clean up stale worktree bookkeeping.
147
+ */
148
+ export async function cleanupWorktrees(opts: {
149
+ teamDir: string;
150
+ teamId: string;
151
+ /** A directory known to be in the git repo (e.g. leaderCwd). */
152
+ repoCwd?: string;
153
+ }): Promise<WorktreeCleanupResult> {
154
+ const removedWorktrees: string[] = [];
155
+ const removedBranches: string[] = [];
156
+ const warnings: string[] = [];
157
+
158
+ const worktreesDir = path.join(opts.teamDir, "worktrees");
159
+ let entries: string[];
160
+ try {
161
+ entries = await fs.promises.readdir(worktreesDir);
162
+ } catch {
163
+ // No worktrees directory — nothing to do.
164
+ return { removedWorktrees, removedBranches, warnings };
165
+ }
166
+
167
+ if (entries.length === 0) {
168
+ return { removedWorktrees, removedBranches, warnings };
169
+ }
170
+
171
+ // Find the repo root. Prefer deriving from the worktree paths themselves (they belong
172
+ // to the repo that created them), only fall back to repoCwd when none resolve.
173
+ // This avoids cross-repo issues when cleanup targets a team created from a different repo.
174
+ let repoRoot: string | null = null;
175
+ for (const entry of entries) {
176
+ const candidate = path.join(worktreesDir, entry);
177
+ repoRoot = await findRepoRoot(candidate);
178
+ if (repoRoot) break;
179
+ }
180
+ if (!repoRoot && opts.repoCwd) {
181
+ repoRoot = await findRepoRoot(opts.repoCwd);
182
+ }
183
+
184
+ const shortTeam = sanitizeName(opts.teamId).slice(0, 12) || "team";
185
+
186
+ for (const entry of entries) {
187
+ const worktreePath = path.join(worktreesDir, entry);
188
+ const safeAgent = sanitizeName(entry);
189
+ const branch = `pi-teams/${shortTeam}/${safeAgent}`;
190
+
191
+ // 1. Remove worktree via git
192
+ if (repoRoot) {
193
+ try {
194
+ await execGit(["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, timeoutMs: 30_000 });
195
+ removedWorktrees.push(worktreePath);
196
+ } catch {
197
+ // Git removal failed — try filesystem fallback below.
198
+ try {
199
+ await fs.promises.rm(worktreePath, { recursive: true, force: true });
200
+ removedWorktrees.push(worktreePath);
201
+ } catch (fsErr: unknown) {
202
+ const msg = fsErr instanceof Error ? fsErr.message : String(fsErr);
203
+ warnings.push(`Failed to remove worktree ${worktreePath}: ${msg}`);
204
+ }
205
+ }
206
+
207
+ // 2. Remove the associated branch
208
+ try {
209
+ await execGit(["branch", "-D", branch], { cwd: repoRoot });
210
+ removedBranches.push(branch);
211
+ } catch {
212
+ // Branch may not exist (shared workspace fallback) — that's fine.
213
+ }
214
+ } else {
215
+ // No repo root — just delete the directory.
216
+ try {
217
+ await fs.promises.rm(worktreePath, { recursive: true, force: true });
218
+ removedWorktrees.push(worktreePath);
219
+ } catch (fsErr: unknown) {
220
+ const msg = fsErr instanceof Error ? fsErr.message : String(fsErr);
221
+ warnings.push(`Failed to remove worktree directory ${worktreePath}: ${msg}`);
222
+ }
223
+ }
224
+ }
225
+
226
+ // 3. Prune stale worktree bookkeeping entries
227
+ if (repoRoot) {
228
+ try {
229
+ await execGit(["worktree", "prune"], { cwd: repoRoot });
230
+ } catch {
231
+ warnings.push("git worktree prune failed (non-fatal)");
232
+ }
233
+ }
234
+
235
+ // 4. Remove the now-empty worktrees directory itself
236
+ try {
237
+ const remaining = await fs.promises.readdir(worktreesDir);
238
+ if (remaining.length === 0) {
239
+ await fs.promises.rmdir(worktreesDir);
240
+ }
241
+ } catch {
242
+ // ignore — best effort
243
+ }
244
+
245
+ return { removedWorktrees, removedBranches, warnings };
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-agent-teams",
3
- "version": "0.4.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",
@@ -34,7 +34,8 @@
34
34
  "integration-claim-test": "tsx scripts/integration-claim-test.mts",
35
35
  "integration-spawn-overrides-test": "tsx scripts/integration-spawn-overrides-test.mts",
36
36
  "integration-hooks-remediation-test": "tsx scripts/integration-hooks-remediation-test.mts",
37
- "integration-todo-test": "tsx scripts/integration-todo-test.mts"
37
+ "integration-todo-test": "tsx scripts/integration-todo-test.mts",
38
+ "integration-cleanup-test": "tsx scripts/integration-cleanup-test.mts"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@eslint/js": "^9.39.2",