@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
@@ -1,21 +1,28 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
2
  import type { Theme, ThemeColor, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
3
- import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
3
+ import type { TeammateRpc } from "./teammate-rpc.js";
4
4
  import type { ActivityTracker, TranscriptLog, TranscriptEntry } from "./activity-tracker.js";
5
5
  import type { TeamTask } from "./task-store.js";
6
6
  import type { TeamConfig, TeamMember } from "./team-config.js";
7
7
  import type { TeamsStyle } from "./teams-style.js";
8
8
  import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
9
9
  import {
10
- STATUS_COLOR,
11
- STATUS_ICON,
10
+ DISPLAY_STATUS_COLOR,
11
+ DISPLAY_STATUS_ICON,
12
+ formatElapsed,
12
13
  formatTokens,
14
+ getMemberModel,
15
+ getMemberThinking,
13
16
  getVisibleWorkerNames,
17
+ lastMessageSummary,
14
18
  padRight,
15
- resolveStatus,
19
+ renderPolicySummary,
20
+ resolveDisplayStatus,
21
+ shortModelLabel,
16
22
  toolActivity,
17
23
  toolVerb,
18
24
  } from "./teams-ui-shared.js";
25
+ import type { DisplayStatus, LeaderModelInfo } from "./teams-ui-shared.js";
19
26
 
20
27
  export interface InteractiveWidgetDeps {
21
28
  getTeammates(): Map<string, TeammateRpc>;
@@ -33,6 +40,7 @@ export interface InteractiveWidgetDeps {
33
40
  assignTask(taskId: string, ownerName: string): Promise<boolean>;
34
41
  getActiveTeamId(): string | null;
35
42
  getSessionTeamId(): string | null;
43
+ getLeaderModel(): LeaderModelInfo | null;
36
44
  suppressWidget(): void;
37
45
  restoreWidget(): void;
38
46
  }
@@ -53,12 +61,20 @@ interface Row {
53
61
  iconColor: ThemeColor;
54
62
  name: string;
55
63
  displayName: string;
56
- statusKey: TeammateStatus;
64
+ statusKey: DisplayStatus;
57
65
  pending: number;
58
66
  completed: number;
59
67
  tokensStr: string;
60
68
  activityText: string;
69
+ elapsedStr: string;
70
+ lastMsgStr: string;
61
71
  isChairman: boolean;
72
+ /** Short model label (e.g. "claude-sonnet-4-5") or null. */
73
+ modelLabel: string | null;
74
+ /** Thinking level (e.g. "high") or null. */
75
+ thinkingLabel: string | null;
76
+ /** Active task subject (if any). */
77
+ activeTaskSubject: string | null;
62
78
  }
63
79
 
64
80
  type WidgetMode = "overview" | "session" | "dm" | "tasks" | "reassign";
@@ -72,8 +88,17 @@ function summarizeTranscriptEntry(entry: TranscriptEntry | undefined): string |
72
88
  if (!compact) return null;
73
89
  return compact.length > 96 ? `${compact.slice(0, 95)}…` : compact;
74
90
  }
75
- if (entry.kind === "tool_start") return `running ${entry.toolName}`;
76
- if (entry.kind === "tool_end") return `finished ${entry.toolName} (${(entry.durationMs / 1000).toFixed(1)}s)`;
91
+ if (entry.kind === "tool_start") {
92
+ const detail = entry.summary ? ` ${entry.summary}` : "";
93
+ const text = `running ${entry.toolName}${detail}`;
94
+ return text.length > 96 ? `${text.slice(0, 95)}…` : text;
95
+ }
96
+ if (entry.kind === "tool_end") {
97
+ const prefix = entry.isError ? "failed" : "finished";
98
+ const detail = entry.summary ? ` → ${entry.summary}` : "";
99
+ const text = `${prefix} ${entry.toolName} (${(entry.durationMs / 1000).toFixed(1)}s)${detail}`;
100
+ return text.length > 96 ? `${text.slice(0, 95)}…` : text;
101
+ }
77
102
  const tok = formatTokens(entry.tokens);
78
103
  return `turn ${String(entry.turnNumber)} complete (${tok} tokens)`;
79
104
  }
@@ -142,13 +167,22 @@ function formatTranscriptEntry(entry: TranscriptEntry, theme: Theme, width: numb
142
167
 
143
168
  if (entry.kind === "tool_start") {
144
169
  const verb = toolVerb(entry.toolName);
145
- return [` ${tsStr} ${theme.fg("warning", verb)}`];
170
+ const contentSuffix = entry.content
171
+ ? ` ${theme.fg("dim", entry.content)}`
172
+ : "";
173
+ return [` ${tsStr} ${theme.fg("warning", verb)}${contentSuffix}`];
146
174
  }
147
175
 
148
176
  if (entry.kind === "tool_end") {
149
177
  const dur = entry.durationMs < 1000
150
178
  ? `${(entry.durationMs / 1000).toFixed(1)}s`
151
179
  : `${(entry.durationMs / 1000).toFixed(1)}s`;
180
+ if (entry.isError) {
181
+ const errorDetail = entry.content
182
+ ? ` ${theme.fg("dim", entry.content)}`
183
+ : "";
184
+ return [` ${tsStr} ${theme.fg("error", `\u2717 ${entry.toolName}`)} ${theme.fg("dim", "\u2500")} ${theme.fg("dim", dur)}${errorDetail}`];
185
+ }
152
186
  return [` ${tsStr} ${theme.fg("muted", entry.toolName)} ${theme.fg("dim", "\u2500")} ${theme.fg("dim", dur)}`];
153
187
  }
154
188
 
@@ -297,8 +331,13 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
297
331
  completed: leadTasks.filter((t) => t.status === "completed").length,
298
332
  tokensStr: "\u2014",
299
333
  activityText: "",
334
+ elapsedStr: "",
335
+ lastMsgStr: "",
300
336
  isChairman: true,
301
337
  name: leadName,
338
+ modelLabel: null,
339
+ thinkingLabel: null,
340
+ activeTaskSubject: null,
302
341
  });
303
342
  }
304
343
 
@@ -307,21 +346,30 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
307
346
  for (const name of memberNames) {
308
347
  const rpc = teammates.get(name);
309
348
  const cfg = cfgByName.get(name);
310
- const statusKey = resolveStatus(rpc, cfg);
349
+ const statusKey = resolveDisplayStatus(rpc, cfg);
311
350
  const activity = tracker.get(name);
312
351
  const owned = tasks.filter((t) => t.owner === name);
352
+ const activeTask = owned.find((t) => t.status === "in_progress");
353
+ const memberModel = getMemberModel(cfg);
354
+ const memberThinking = getMemberThinking(cfg);
355
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
313
356
 
314
357
  rows.push({
315
- icon: STATUS_ICON[statusKey],
316
- iconColor: STATUS_COLOR[statusKey],
358
+ icon: DISPLAY_STATUS_ICON[statusKey],
359
+ iconColor: DISPLAY_STATUS_COLOR[statusKey],
317
360
  displayName: formatMemberDisplayName(style, name),
318
361
  statusKey,
319
362
  pending: owned.filter((t) => t.status === "pending").length,
320
363
  completed: owned.filter((t) => t.status === "completed").length,
321
364
  tokensStr: formatTokens(activity.totalTokens),
322
365
  activityText: toolActivity(activity.currentToolName),
366
+ elapsedStr: elapsed,
367
+ lastMsgStr: lastMessageSummary(rpc, 80),
323
368
  isChairman: false,
324
369
  name,
370
+ modelLabel: memberModel ? shortModelLabel(memberModel) : null,
371
+ thinkingLabel: memberThinking,
372
+ activeTaskSubject: activeTask ? `#${String(activeTask.id)} ${activeTask.subject}` : null,
325
373
  });
326
374
  }
327
375
 
@@ -348,6 +396,15 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
348
396
  const attachBanner = renderAttachBanner(width);
349
397
  if (attachBanner) lines.push(attachBanner);
350
398
 
399
+ // ── Policy summary ──
400
+ const policyLines = renderPolicySummary({
401
+ teamConfig: deps.getTeamConfig(),
402
+ leaderModel: deps.getLeaderModel(),
403
+ theme,
404
+ width,
405
+ });
406
+ for (const pl of policyLines) lines.push(pl);
407
+
351
408
  if (rows.length === 0) {
352
409
  lines.push(
353
410
  truncateToWidth(
@@ -385,7 +442,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
385
442
  const styledName = isSelected
386
443
  ? theme.bold(theme.fg("accent", r.displayName))
387
444
  : theme.bold(r.displayName);
388
- const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
445
+ const statusLabel = theme.fg(DISPLAY_STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
389
446
  const pNum = String(r.pending).padStart(pW);
390
447
  const cNum = String(r.completed).padStart(cW);
391
448
  const tokStr = r.tokensStr.padStart(tokW);
@@ -393,12 +450,23 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
393
450
  "dim",
394
451
  ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
395
452
  );
453
+ const elapsedLabel = r.elapsedStr ? " " + theme.fg("dim", r.elapsedStr) : "";
396
454
  const actLabel = r.activityText
397
455
  ? " " + theme.fg("warning", r.activityText)
398
456
  : "";
457
+ // Model + thinking badge (compact)
458
+ const badges: string[] = [];
459
+ if (r.modelLabel) badges.push(r.modelLabel);
460
+ if (r.thinkingLabel && r.thinkingLabel !== "off") badges.push(`t:${r.thinkingLabel}`);
461
+ const badgeStr = badges.length > 0 ? " " + theme.fg("muted", badges.join(" \u00b7 ")) : "";
399
462
 
400
- const row = `${pointer}${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
463
+ const row = `${pointer}${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${elapsedLabel}${cols}${actLabel}${badgeStr}`;
401
464
  lines.push(truncateToWidth(row, width));
465
+ // Active task on second line (indented, only when actively working)
466
+ if (r.activeTaskSubject) {
467
+ const taskLine = ` ${theme.fg("dim", "\u2514")} ${theme.fg("warning", r.activeTaskSubject)}`;
468
+ lines.push(truncateToWidth(taskLine, width));
469
+ }
402
470
  }
403
471
 
404
472
  // Separator + Total
@@ -424,6 +492,10 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
424
492
  const selectedName = memberNames[cursorIndex];
425
493
  if (selectedName) {
426
494
  const selectedLabel = formatMemberDisplayName(style, selectedName);
495
+ const selectedRpc = deps.getTeammates().get(selectedName);
496
+ const selectedCfg = (deps.getTeamConfig()?.members ?? []).find((m) => m.name === selectedName);
497
+ const selectedDisplayStatus = resolveDisplayStatus(selectedRpc, selectedCfg);
498
+ const selectedElapsed = selectedRpc ? formatElapsed(Date.now() - selectedRpc.lastStatusChangeAt) : "";
427
499
  const owned = tasks.filter((t) => t.owner === selectedName);
428
500
  const activeTask = owned.find((t) => t.status === "in_progress");
429
501
  const latestCompleted = owned
@@ -432,8 +504,16 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
432
504
  .at(0);
433
505
  const entries = deps.getTranscript(selectedName).getEntries();
434
506
  const lastSummary = summarizeTranscriptEntry(entries.at(-1));
435
-
436
- lines.push(truncateToWidth(` ${theme.fg("muted", "selected:")} ${theme.bold(theme.fg("accent", selectedLabel))}`, width));
507
+ const msgSummary = lastMessageSummary(selectedRpc, 80);
508
+
509
+ const statusTag = theme.fg(DISPLAY_STATUS_COLOR[selectedDisplayStatus], selectedDisplayStatus);
510
+ const elapsedTag = selectedElapsed ? ` ${theme.fg("dim", selectedElapsed)}` : "";
511
+ lines.push(truncateToWidth(` ${theme.fg("muted", "selected:")} ${theme.bold(theme.fg("accent", selectedLabel))} ${statusTag}${elapsedTag}`, width));
512
+ // Show model if available in team config meta
513
+ const selectedModel = selectedCfg?.meta?.["model"];
514
+ if (typeof selectedModel === "string" && selectedModel) {
515
+ lines.push(truncateToWidth(` ${theme.fg("dim", "model:")} ${theme.fg("muted", selectedModel)}`, width));
516
+ }
437
517
  if (activeTask) {
438
518
  lines.push(
439
519
  truncateToWidth(
@@ -452,6 +532,13 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
452
532
  if (lastSummary) {
453
533
  lines.push(truncateToWidth(` ${theme.fg("dim", "last event:")} ${theme.fg("muted", lastSummary)}`, width));
454
534
  }
535
+ if (msgSummary) {
536
+ lines.push(truncateToWidth(` ${theme.fg("dim", "last msg:")} ${theme.fg("muted", msgSummary)}`, width));
537
+ }
538
+ if (selectedDisplayStatus === "stalled") {
539
+ const stalledSince = selectedRpc ? formatElapsed(Date.now() - selectedRpc.lastEventAt) : "";
540
+ lines.push(truncateToWidth(` ${theme.fg("warning", `\u26a0 no events for ${stalledSince} — may be stalled`)}`, width));
541
+ }
455
542
  }
456
543
 
457
544
  // Notification
@@ -476,7 +563,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
476
563
 
477
564
  const rpc = deps.getTeammates().get(sessionName);
478
565
  const cfg = (deps.getTeamConfig()?.members ?? []).find((m) => m.name === sessionName);
479
- const statusKey = resolveStatus(rpc, cfg);
566
+ const statusKey = resolveDisplayStatus(rpc, cfg);
480
567
  const activity = deps.getTracker().get(sessionName);
481
568
  const tasks = deps.getTasks();
482
569
  const activeTask = tasks.find(
@@ -488,14 +575,25 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
488
575
  const sep = theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
489
576
 
490
577
  // Header
491
- const icon = theme.fg(STATUS_COLOR[statusKey], STATUS_ICON[statusKey]);
578
+ const icon = theme.fg(DISPLAY_STATUS_COLOR[statusKey], DISPLAY_STATUS_ICON[statusKey]);
492
579
  const nameStr = theme.bold(theme.fg("accent", formatMemberDisplayName(style, sessionName)));
493
- const status = theme.fg(STATUS_COLOR[statusKey], statusKey);
580
+ const status = theme.fg(DISPLAY_STATUS_COLOR[statusKey], statusKey);
581
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
582
+ const elapsedLabel = elapsed ? ` ${theme.fg("dim", elapsed)}` : "";
494
583
  const tokens = theme.fg("dim", `${formatTokens(activity.totalTokens)} tokens`);
495
584
  const taskLabel = activeTask
496
585
  ? ` ${theme.fg("muted", "\u00b7")} ${theme.fg("dim", `#${String(activeTask.id)} ${activeTask.subject}`)}`
497
586
  : "";
498
- lines.push(truncateToWidth(` ${icon} ${nameStr} \u2014 ${status} \u00b7 ${tokens}${taskLabel}`, width));
587
+ // Model + thinking badges in header
588
+ const memberModel = getMemberModel(cfg);
589
+ const memberThinking = getMemberThinking(cfg);
590
+ const sessionBadges: string[] = [];
591
+ if (memberModel) sessionBadges.push(shortModelLabel(memberModel));
592
+ if (memberThinking && memberThinking !== "off") sessionBadges.push(`t:${memberThinking}`);
593
+ const sessionBadgeStr = sessionBadges.length > 0
594
+ ? ` ${theme.fg("muted", "\u00b7")} ${theme.fg("muted", sessionBadges.join(" \u00b7 "))}`
595
+ : "";
596
+ lines.push(truncateToWidth(` ${icon} ${nameStr} \u2014 ${status}${elapsedLabel} \u00b7 ${tokens}${sessionBadgeStr}${taskLabel}`, width));
499
597
  const attachBanner = renderAttachBanner(width);
500
598
  if (attachBanner) lines.push(attachBanner);
501
599
  lines.push(truncateToWidth(` ${sep}`, width));
@@ -1,8 +1,19 @@
1
- import type { ThemeColor } from "@mariozechner/pi-coding-agent";
2
- import { visibleWidth } from "@mariozechner/pi-tui";
1
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
3
  import type { TeamConfig, TeamMember } from "./team-config.js";
4
4
  import type { TeamTask } from "./task-store.js";
5
5
  import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
6
+ import {
7
+ areTeamsHooksEnabled,
8
+ getTeamsHookFailureAction,
9
+ getTeamsHookFollowupOwnerPolicy,
10
+ getTeamsHookMaxReopensPerTask,
11
+ } from "./hooks.js";
12
+ import {
13
+ formatProviderModel,
14
+ isDeprecatedTeammateModelId,
15
+ resolveTeammateModelSelection,
16
+ } from "./model-policy.js";
6
17
 
7
18
  // Status icon and color mapping (shared by widget + interactive panel)
8
19
  export const STATUS_ICON: Record<TeammateStatus, string> = {
@@ -48,17 +59,146 @@ export function padRight(str: string, targetWidth: number): string {
48
59
  return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
49
60
  }
50
61
 
62
+ /** Display-only status that extends TeammateStatus with a "stalled" state. */
63
+ export type DisplayStatus = TeammateStatus | "stalled";
64
+
65
+ export const DISPLAY_STATUS_ICON: Record<DisplayStatus, string> = {
66
+ ...STATUS_ICON,
67
+ stalled: "\u26a0",
68
+ };
69
+
70
+ export const DISPLAY_STATUS_COLOR: Record<DisplayStatus, ThemeColor> = {
71
+ ...STATUS_COLOR,
72
+ stalled: "warning",
73
+ };
74
+
75
+ /**
76
+ * Default stall threshold in milliseconds.
77
+ * Configurable via PI_TEAMS_STALL_THRESHOLD_MS env var.
78
+ */
79
+ function getStallThresholdMs(): number {
80
+ const envVal = process.env.PI_TEAMS_STALL_THRESHOLD_MS;
81
+ if (envVal) {
82
+ const parsed = Number.parseInt(envVal, 10);
83
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
84
+ }
85
+ return 5 * 60 * 1000; // 5 minutes default
86
+ }
87
+
88
+ /**
89
+ * Resolve the display status for a teammate, including stall detection.
90
+ *
91
+ * A teammate is "stalled" when:
92
+ * - It has an active RPC connection
93
+ * - Its transport status is "streaming" (i.e. not idle/stopped/error)
94
+ * - No agent event has been received for > stallThresholdMs
95
+ */
96
+ export function resolveDisplayStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): DisplayStatus {
97
+ if (!rpc) return cfg?.status === "online" ? "idle" : "stopped";
98
+
99
+ if (rpc.status === "streaming") {
100
+ const elapsed = Date.now() - rpc.lastEventAt;
101
+ if (elapsed > getStallThresholdMs()) return "stalled";
102
+ }
103
+ return rpc.status;
104
+ }
105
+
51
106
  export function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
52
107
  if (rpc) return rpc.status;
53
108
  return cfg?.status === "online" ? "idle" : "stopped";
54
109
  }
55
110
 
111
+ /**
112
+ * Format elapsed duration as a compact human-readable string.
113
+ * e.g. "2s", "45s", "3m12s", "1h5m"
114
+ */
115
+ export function formatElapsed(ms: number): string {
116
+ const totalSec = Math.floor(ms / 1000);
117
+ if (totalSec < 60) return `${totalSec}s`;
118
+ const minutes = Math.floor(totalSec / 60);
119
+ const seconds = totalSec % 60;
120
+ if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
121
+ const hours = Math.floor(minutes / 60);
122
+ const remainingMin = minutes % 60;
123
+ return remainingMin > 0 ? `${hours}h${remainingMin}m` : `${hours}h`;
124
+ }
125
+
126
+ /**
127
+ * Get a compact summary of the last assistant text (first 100 visible chars).
128
+ */
129
+ export function lastMessageSummary(rpc: TeammateRpc | undefined, maxLen: number = 100): string {
130
+ if (!rpc) return "";
131
+ const raw = rpc.lastAssistantText;
132
+ if (!raw) return "";
133
+ const compact = raw.replace(/\s+/g, " ").trim();
134
+ if (!compact) return "";
135
+ return compact.length > maxLen ? `${compact.slice(0, maxLen - 1)}…` : compact;
136
+ }
137
+
56
138
  export function formatTokens(n: number): string {
57
139
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
58
140
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
59
141
  return String(n);
60
142
  }
61
143
 
144
+ /**
145
+ * Check if all tasks are completed and all teammates are idle/stopped.
146
+ * Used by the widget (done hint) and leader (auto-done detection).
147
+ */
148
+ export function isTeamDone(
149
+ tasks: readonly TeamTask[],
150
+ teammates: ReadonlyMap<string, TeammateRpc>,
151
+ ): boolean {
152
+ if (tasks.length === 0) return false;
153
+ const pending = tasks.filter((t) => t.status === "pending").length;
154
+ const inProgress = tasks.filter((t) => t.status === "in_progress").length;
155
+ if (pending > 0 || inProgress > 0) return false;
156
+ for (const [, rpc] of teammates) {
157
+ if (rpc.status === "streaming" || rpc.status === "starting") return false;
158
+ }
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Extract the model label from a TeamMember's freeform metadata.
164
+ *
165
+ * Stored as `meta.model` (e.g. "anthropic/claude-sonnet-4-5-20250514").
166
+ * Returns a short display form: strips the provider prefix and long date
167
+ * suffixes to keep the widget compact.
168
+ */
169
+ export function getMemberModel(member: TeamMember | undefined): string | null {
170
+ const raw = member?.meta?.["model"];
171
+ if (typeof raw !== "string" || !raw) return null;
172
+ return raw;
173
+ }
174
+
175
+ /**
176
+ * Shorten a full model identifier for display in compact UI contexts.
177
+ *
178
+ * Examples:
179
+ * - "anthropic/claude-sonnet-4-5-20250514" → "claude-sonnet-4-5"
180
+ * - "openai-codex/gpt-5.1-codex-mini" → "gpt-5.1-codex-mini"
181
+ * - "claude-sonnet-4-5-20250514" → "claude-sonnet-4-5"
182
+ */
183
+ export function shortModelLabel(fullModel: string): string {
184
+ // Strip provider prefix (everything before and including the first "/")
185
+ const slashIdx = fullModel.indexOf("/");
186
+ const modelId = slashIdx >= 0 ? fullModel.slice(slashIdx + 1) : fullModel;
187
+ // Strip trailing date suffixes like -20250514 or -20250514-v2
188
+ return modelId.replace(/-\d{8}(-\w+)?$/, "");
189
+ }
190
+
191
+ /**
192
+ * Extract the thinking level from a TeamMember's freeform metadata.
193
+ *
194
+ * Stored as `meta.thinkingLevel` (e.g. "high", "medium", "off").
195
+ */
196
+ export function getMemberThinking(member: TeamMember | undefined): string | null {
197
+ const raw = member?.meta?.["thinkingLevel"];
198
+ if (typeof raw !== "string" || !raw) return null;
199
+ return raw;
200
+ }
201
+
62
202
  /**
63
203
  * Compute the set of worker names that should be visible in the UI.
64
204
  *
@@ -87,3 +227,66 @@ export function getVisibleWorkerNames(opts: {
87
227
 
88
228
  return Array.from(names).sort();
89
229
  }
230
+
231
+ // ── Policy summary (shared by widget + interactive panel) ──
232
+
233
+ export interface LeaderModelInfo {
234
+ provider: string | undefined;
235
+ modelId: string | undefined;
236
+ }
237
+
238
+ /**
239
+ * Render a compact policy summary line for the Teams UI.
240
+ *
241
+ * Shows hook policy (failureAction, maxReopens, followupOwner) and model
242
+ * policy (leader model, deprecation, teammate source) as a single dim line.
243
+ */
244
+ export function renderPolicySummary(opts: {
245
+ teamConfig: TeamConfig | null;
246
+ leaderModel: LeaderModelInfo | null;
247
+ theme: Theme;
248
+ width: number;
249
+ }): string[] {
250
+ const { teamConfig, leaderModel, theme, width } = opts;
251
+ if (!teamConfig) return [];
252
+
253
+ const lines: string[] = [];
254
+
255
+ // ── Hooks policy ──
256
+ const hooksEnabled = areTeamsHooksEnabled();
257
+ const hooksCfg = teamConfig.hooks;
258
+ const failureAction = getTeamsHookFailureAction(process.env, hooksCfg?.failureAction);
259
+ const maxReopens = getTeamsHookMaxReopensPerTask(process.env, hooksCfg?.maxReopensPerTask);
260
+ const followupOwner = getTeamsHookFollowupOwnerPolicy(process.env, hooksCfg?.followupOwner);
261
+
262
+ const hooksLabel = hooksEnabled ? "on" : "off";
263
+ const hooksColor: ThemeColor = hooksEnabled ? "success" : "dim";
264
+ const hookLine =
265
+ ` ${theme.fg("dim", "hooks:")} ${theme.fg(hooksColor, hooksLabel)}` +
266
+ (hooksEnabled
267
+ ? theme.fg("dim", ` · failure=${failureAction} · reopens=${String(maxReopens)} · owner=${followupOwner}`)
268
+ : "");
269
+ lines.push(truncateToWidth(hookLine, width));
270
+
271
+ // ── Model policy ──
272
+ const leaderProvider = leaderModel?.provider;
273
+ const leaderModelId = leaderModel?.modelId;
274
+ const leaderDisplay = formatProviderModel(leaderProvider, leaderModelId) ?? "(unknown)";
275
+ const deprecated = leaderModelId ? isDeprecatedTeammateModelId(leaderModelId) : false;
276
+ const resolved = resolveTeammateModelSelection({ leaderProvider, leaderModelId });
277
+ let teammateSource = "(default)";
278
+ if (resolved.ok) {
279
+ const src = resolved.value.source;
280
+ const resolvedModel = formatProviderModel(resolved.value.provider, resolved.value.modelId);
281
+ teammateSource = src === "inherit_leader" ? "inherit" : src;
282
+ if (resolvedModel) teammateSource += `:${resolvedModel}`;
283
+ }
284
+
285
+ const deprecTag = deprecated ? theme.fg("warning", " [deprecated]") : "";
286
+ const modelLine =
287
+ ` ${theme.fg("dim", "model:")} ${theme.fg("muted", leaderDisplay)}${deprecTag}` +
288
+ theme.fg("dim", ` · teammate=${teammateSource}`);
289
+ lines.push(truncateToWidth(modelLine, width));
290
+
291
+ return lines;
292
+ }