@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.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +18 -13
- package/docs/hook-contract.md +183 -0
- package/docs/smoke-test-plan.md +26 -7
- package/extensions/teams/activity-tracker.ts +296 -8
- package/extensions/teams/cleanup.ts +216 -3
- package/extensions/teams/hooks.ts +57 -5
- package/extensions/teams/leader-attach-commands.ts +8 -4
- package/extensions/teams/leader-inbox.ts +162 -4
- package/extensions/teams/leader-info-commands.ts +105 -3
- package/extensions/teams/leader-lifecycle-commands.ts +205 -3
- package/extensions/teams/leader-messaging-commands.ts +19 -7
- package/extensions/teams/leader-spawn-command.ts +5 -1
- package/extensions/teams/leader-team-command.ts +51 -2
- package/extensions/teams/leader-teams-tool.ts +387 -11
- package/extensions/teams/leader.ts +126 -52
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/model-policy.ts +117 -0
- package/extensions/teams/spawn-types.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +14 -0
- package/extensions/teams/teams-panel.ts +117 -19
- package/extensions/teams/teams-ui-shared.ts +205 -2
- package/extensions/teams/teams-widget.ts +67 -14
- package/extensions/teams/worker.ts +18 -6
- package/extensions/teams/worktree.ts +143 -0
- package/package.json +4 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +10 -0
- package/scripts/smoke-test.mts +701 -3
- 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
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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:
|
|
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")
|
|
76
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
316
|
-
iconColor:
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|