@tmustier/pi-agent-teams 0.2.0 → 0.3.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.
- package/README.md +15 -3
- package/docs/claude-parity.md +7 -5
- package/docs/smoke-test-plan.md +20 -4
- package/eslint.config.js +74 -0
- package/extensions/teams/activity-tracker.ts +2 -2
- package/extensions/teams/leader-lifecycle-commands.ts +151 -14
- package/extensions/teams/leader-messaging-commands.ts +0 -1
- package/extensions/teams/leader-plan-commands.ts +1 -1
- package/extensions/teams/leader-spawn-command.ts +3 -19
- package/extensions/teams/leader-task-commands.ts +11 -7
- package/extensions/teams/leader-team-command.ts +329 -0
- package/extensions/teams/leader-teams-tool.ts +5 -16
- package/extensions/teams/leader.ts +34 -310
- package/extensions/teams/protocol.ts +20 -0
- package/extensions/teams/spawn-types.ts +21 -0
- package/extensions/teams/task-store.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +26 -2
- package/extensions/teams/teams-panel.ts +41 -95
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +15 -68
- package/extensions/teams/worker.ts +1 -1
- package/package.json +9 -4
- package/scripts/integration-claim-test.mts +16 -86
- package/scripts/integration-todo-test.mts +14 -65
- package/scripts/lib/pi-workers.ts +105 -0
- package/skills/agent-teams/SKILL.md +8 -4
- package/.github/workflows/ci.yml +0 -32
- package/scripts/smoke-test.mjs +0 -199
|
@@ -6,6 +6,16 @@ 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
|
+
import {
|
|
10
|
+
STATUS_COLOR,
|
|
11
|
+
STATUS_ICON,
|
|
12
|
+
formatTokens,
|
|
13
|
+
getVisibleWorkerNames,
|
|
14
|
+
padRight,
|
|
15
|
+
resolveStatus,
|
|
16
|
+
toolActivity,
|
|
17
|
+
toolVerb,
|
|
18
|
+
} from "./teams-ui-shared.js";
|
|
9
19
|
|
|
10
20
|
export interface InteractiveWidgetDeps {
|
|
11
21
|
getTeammates(): Map<string, TeammateRpc>;
|
|
@@ -16,89 +26,17 @@ export interface InteractiveWidgetDeps {
|
|
|
16
26
|
getStyle(): TeamsStyle;
|
|
17
27
|
isDelegateMode(): boolean;
|
|
18
28
|
sendMessage(name: string, message: string): Promise<void>;
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
abortMember(name: string): void;
|
|
30
|
+
killMember(name: string): void;
|
|
21
31
|
suppressWidget(): void;
|
|
22
32
|
restoreWidget(): void;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
// ── Status icon + color (shared with teams-widget.ts) ──
|
|
26
|
-
|
|
27
|
-
const STATUS_ICON: Record<TeammateStatus, string> = {
|
|
28
|
-
streaming: "\u25c9",
|
|
29
|
-
idle: "\u25cf",
|
|
30
|
-
starting: "\u25cb",
|
|
31
|
-
stopped: "\u2717",
|
|
32
|
-
error: "\u2717",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
|
|
36
|
-
streaming: "accent",
|
|
37
|
-
idle: "success",
|
|
38
|
-
starting: "muted",
|
|
39
|
-
stopped: "dim",
|
|
40
|
-
error: "error",
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const TOOL_VERB: Record<string, string> = {
|
|
44
|
-
read: "reading\u2026",
|
|
45
|
-
edit: "editing\u2026",
|
|
46
|
-
write: "writing\u2026",
|
|
47
|
-
grep: "searching\u2026",
|
|
48
|
-
glob: "finding files\u2026",
|
|
49
|
-
bash: "running\u2026",
|
|
50
|
-
task: "delegating\u2026",
|
|
51
|
-
webfetch: "fetching\u2026",
|
|
52
|
-
websearch: "searching web\u2026",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// ── Helpers ──
|
|
56
|
-
|
|
57
|
-
function padRight(str: string, targetWidth: number): string {
|
|
58
|
-
const w = visibleWidth(str);
|
|
59
|
-
return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
|
|
63
|
-
if (rpc) return rpc.status;
|
|
64
|
-
return cfg?.status === "online" ? "idle" : "stopped";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function formatTokens(n: number): string {
|
|
68
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
69
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
70
|
-
return String(n);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function toolActivity(toolName: string | null): string {
|
|
74
|
-
if (!toolName) return "";
|
|
75
|
-
const key = toolName.toLowerCase();
|
|
76
|
-
return TOOL_VERB[key] ?? `${key}\u2026`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
35
|
function formatTimestamp(ts: number): string {
|
|
80
36
|
const d = new Date(ts);
|
|
81
37
|
return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
82
38
|
}
|
|
83
39
|
|
|
84
|
-
function getComradeNames(deps: InteractiveWidgetDeps): string[] {
|
|
85
|
-
const teamConfig = deps.getTeamConfig();
|
|
86
|
-
const teammates = deps.getTeammates();
|
|
87
|
-
const tasks = deps.getTasks();
|
|
88
|
-
const leadName = teamConfig?.leadName;
|
|
89
|
-
const cfgMembers = teamConfig?.members ?? [];
|
|
90
|
-
|
|
91
|
-
const names = new Set<string>();
|
|
92
|
-
for (const name of teammates.keys()) names.add(name);
|
|
93
|
-
for (const m of cfgMembers) {
|
|
94
|
-
if (m.role === "worker" && m.status === "online") names.add(m.name);
|
|
95
|
-
}
|
|
96
|
-
for (const t of tasks) {
|
|
97
|
-
if (t.owner && t.owner !== leadName && t.status === "in_progress") names.add(t.owner);
|
|
98
|
-
}
|
|
99
|
-
return Array.from(names).sort();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
40
|
// ── Row data (mirrors teams-widget.ts) ──
|
|
103
41
|
|
|
104
42
|
interface Row {
|
|
@@ -148,7 +86,7 @@ function formatTranscriptEntry(entry: TranscriptEntry, theme: Theme, width: numb
|
|
|
148
86
|
}
|
|
149
87
|
|
|
150
88
|
if (entry.kind === "tool_start") {
|
|
151
|
-
const verb =
|
|
89
|
+
const verb = toolVerb(entry.toolName);
|
|
152
90
|
return [` ${tsStr} ${theme.fg("warning", verb)}`];
|
|
153
91
|
}
|
|
154
92
|
|
|
@@ -173,7 +111,11 @@ function formatTranscriptEntry(entry: TranscriptEntry, theme: Theme, width: numb
|
|
|
173
111
|
export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps: InteractiveWidgetDeps): Promise<void> {
|
|
174
112
|
const style = deps.getStyle();
|
|
175
113
|
const strings = getTeamsStrings(style);
|
|
176
|
-
const names =
|
|
114
|
+
const names = getVisibleWorkerNames({
|
|
115
|
+
teammates: deps.getTeammates(),
|
|
116
|
+
teamConfig: deps.getTeamConfig(),
|
|
117
|
+
tasks: deps.getTasks(),
|
|
118
|
+
});
|
|
177
119
|
if (names.length === 0) {
|
|
178
120
|
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to show`, "info");
|
|
179
121
|
return;
|
|
@@ -209,7 +151,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
209
151
|
|
|
210
152
|
// ── Build row data (same logic as persistent widget) ──
|
|
211
153
|
|
|
212
|
-
function buildRows(): { rows: Row[];
|
|
154
|
+
function buildRows(): { rows: Row[]; memberNames: string[] } {
|
|
213
155
|
const teammates = deps.getTeammates();
|
|
214
156
|
const tracker = deps.getTracker();
|
|
215
157
|
const tasks = deps.getTasks();
|
|
@@ -238,9 +180,9 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
238
180
|
});
|
|
239
181
|
}
|
|
240
182
|
|
|
241
|
-
//
|
|
242
|
-
const
|
|
243
|
-
for (const name of
|
|
183
|
+
// Workers
|
|
184
|
+
const memberNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
|
|
185
|
+
for (const name of memberNames) {
|
|
244
186
|
const rpc = teammates.get(name);
|
|
245
187
|
const cfg = cfgByName.get(name);
|
|
246
188
|
const statusKey = resolveStatus(rpc, cfg);
|
|
@@ -261,7 +203,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
261
203
|
});
|
|
262
204
|
}
|
|
263
205
|
|
|
264
|
-
return { rows,
|
|
206
|
+
return { rows, memberNames };
|
|
265
207
|
}
|
|
266
208
|
|
|
267
209
|
// ── Overview render (identical to persistent widget + cursor) ──
|
|
@@ -270,10 +212,10 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
270
212
|
const tasks = deps.getTasks();
|
|
271
213
|
const tracker = deps.getTracker();
|
|
272
214
|
const delegateMode = deps.isDelegateMode();
|
|
273
|
-
const { rows,
|
|
215
|
+
const { rows, memberNames } = buildRows();
|
|
274
216
|
|
|
275
217
|
// Clamp cursor
|
|
276
|
-
if (cursorIndex >=
|
|
218
|
+
if (cursorIndex >= memberNames.length) cursorIndex = Math.max(0, memberNames.length - 1);
|
|
277
219
|
|
|
278
220
|
const lines: string[] = [];
|
|
279
221
|
|
|
@@ -294,7 +236,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
294
236
|
const totalPending = tasks.filter((t) => t.status === "pending").length;
|
|
295
237
|
const totalCompleted = tasks.filter((t) => t.status === "completed").length;
|
|
296
238
|
let totalTokensRaw = 0;
|
|
297
|
-
for (const name of
|
|
239
|
+
for (const name of memberNames) totalTokensRaw += tracker.get(name).totalTokens;
|
|
298
240
|
const totalTokensStr = formatTokens(totalTokensRaw);
|
|
299
241
|
|
|
300
242
|
const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
|
|
@@ -313,7 +255,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
313
255
|
|
|
314
256
|
// Render rows
|
|
315
257
|
for (const r of rows) {
|
|
316
|
-
const isSelected = !r.isChairman &&
|
|
258
|
+
const isSelected = !r.isChairman && memberNames.indexOf(r.name) === cursorIndex;
|
|
317
259
|
const pointer = isSelected ? theme.fg("accent", "\u25b8") : " ";
|
|
318
260
|
const icon = theme.fg(r.iconColor, r.icon);
|
|
319
261
|
const styledName = isSelected
|
|
@@ -606,7 +548,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
606
548
|
}
|
|
607
549
|
if (data === "a") {
|
|
608
550
|
if (sessionName) {
|
|
609
|
-
deps.
|
|
551
|
+
deps.abortMember(sessionName);
|
|
610
552
|
showNotification(`Abort sent to ${formatMemberDisplayName(style, sessionName)}`, "warning");
|
|
611
553
|
}
|
|
612
554
|
return;
|
|
@@ -614,7 +556,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
614
556
|
if (data === "k") {
|
|
615
557
|
if (sessionName) {
|
|
616
558
|
const name = sessionName;
|
|
617
|
-
deps.
|
|
559
|
+
deps.killMember(name);
|
|
618
560
|
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "error");
|
|
619
561
|
mode = "overview";
|
|
620
562
|
sessionName = null;
|
|
@@ -626,7 +568,11 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
626
568
|
}
|
|
627
569
|
|
|
628
570
|
// ── Overview mode ──
|
|
629
|
-
const
|
|
571
|
+
const memberNames = getVisibleWorkerNames({
|
|
572
|
+
teammates: deps.getTeammates(),
|
|
573
|
+
teamConfig: deps.getTeamConfig(),
|
|
574
|
+
tasks: deps.getTasks(),
|
|
575
|
+
});
|
|
630
576
|
|
|
631
577
|
if (matchesKey(data, "escape") || data === "q") {
|
|
632
578
|
done(undefined);
|
|
@@ -638,12 +584,12 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
638
584
|
return;
|
|
639
585
|
}
|
|
640
586
|
if (matchesKey(data, "down")) {
|
|
641
|
-
cursorIndex = Math.min(
|
|
587
|
+
cursorIndex = Math.min(memberNames.length - 1, cursorIndex + 1);
|
|
642
588
|
tui.requestRender();
|
|
643
589
|
return;
|
|
644
590
|
}
|
|
645
591
|
if (matchesKey(data, "enter")) {
|
|
646
|
-
const name =
|
|
592
|
+
const name = memberNames[cursorIndex];
|
|
647
593
|
if (name) {
|
|
648
594
|
sessionName = name;
|
|
649
595
|
mode = "session";
|
|
@@ -654,7 +600,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
654
600
|
return;
|
|
655
601
|
}
|
|
656
602
|
if (data === "m") {
|
|
657
|
-
const name =
|
|
603
|
+
const name = memberNames[cursorIndex];
|
|
658
604
|
if (name) {
|
|
659
605
|
dmTarget = name;
|
|
660
606
|
mode = "dm";
|
|
@@ -664,17 +610,17 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
664
610
|
return;
|
|
665
611
|
}
|
|
666
612
|
if (data === "a") {
|
|
667
|
-
const name =
|
|
613
|
+
const name = memberNames[cursorIndex];
|
|
668
614
|
if (name) {
|
|
669
|
-
deps.
|
|
615
|
+
deps.abortMember(name);
|
|
670
616
|
showNotification(`Abort sent to ${formatMemberDisplayName(style, name)}`, "warning");
|
|
671
617
|
}
|
|
672
618
|
return;
|
|
673
619
|
}
|
|
674
620
|
if (data === "k") {
|
|
675
|
-
const name =
|
|
621
|
+
const name = memberNames[cursorIndex];
|
|
676
622
|
if (name) {
|
|
677
|
-
deps.
|
|
623
|
+
deps.killMember(name);
|
|
678
624
|
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "error");
|
|
679
625
|
tui.requestRender();
|
|
680
626
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
4
|
+
import type { TeamTask } from "./task-store.js";
|
|
5
|
+
import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
|
|
6
|
+
|
|
7
|
+
// Status icon and color mapping (shared by widget + interactive panel)
|
|
8
|
+
export const STATUS_ICON: Record<TeammateStatus, string> = {
|
|
9
|
+
streaming: "\u25c9",
|
|
10
|
+
idle: "\u25cf",
|
|
11
|
+
starting: "\u25cb",
|
|
12
|
+
stopped: "\u2717",
|
|
13
|
+
error: "\u2717",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
|
|
17
|
+
streaming: "accent",
|
|
18
|
+
idle: "success",
|
|
19
|
+
starting: "muted",
|
|
20
|
+
stopped: "dim",
|
|
21
|
+
error: "error",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const TOOL_VERB: Record<string, string> = {
|
|
25
|
+
read: "reading\u2026",
|
|
26
|
+
edit: "editing\u2026",
|
|
27
|
+
write: "writing\u2026",
|
|
28
|
+
grep: "searching\u2026",
|
|
29
|
+
glob: "finding files\u2026",
|
|
30
|
+
bash: "running\u2026",
|
|
31
|
+
task: "delegating\u2026",
|
|
32
|
+
webfetch: "fetching\u2026",
|
|
33
|
+
websearch: "searching web\u2026",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function toolVerb(toolName: string): string {
|
|
37
|
+
const key = toolName.toLowerCase();
|
|
38
|
+
return TOOL_VERB[key] ?? `${toolName}\u2026`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toolActivity(toolName: string | null): string {
|
|
42
|
+
if (!toolName) return "";
|
|
43
|
+
return toolVerb(toolName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function padRight(str: string, targetWidth: number): string {
|
|
47
|
+
const w = visibleWidth(str);
|
|
48
|
+
return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
|
|
52
|
+
if (rpc) return rpc.status;
|
|
53
|
+
return cfg?.status === "online" ? "idle" : "stopped";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatTokens(n: number): string {
|
|
57
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
58
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
59
|
+
return String(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute the set of worker names that should be visible in the UI.
|
|
64
|
+
*
|
|
65
|
+
* Rule: show any worker that is:
|
|
66
|
+
* - currently spawned/known as a teammate RPC
|
|
67
|
+
* - online in team config
|
|
68
|
+
* - owning an in-progress task (even if RPC is disconnected)
|
|
69
|
+
*/
|
|
70
|
+
export function getVisibleWorkerNames(opts: {
|
|
71
|
+
teammates: ReadonlyMap<string, TeammateRpc>;
|
|
72
|
+
teamConfig: TeamConfig | null;
|
|
73
|
+
tasks: readonly TeamTask[];
|
|
74
|
+
}): string[] {
|
|
75
|
+
const { teammates, teamConfig, tasks } = opts;
|
|
76
|
+
const leadName = teamConfig?.leadName;
|
|
77
|
+
const cfgMembers = teamConfig?.members ?? [];
|
|
78
|
+
|
|
79
|
+
const names = new Set<string>();
|
|
80
|
+
for (const name of teammates.keys()) names.add(name);
|
|
81
|
+
for (const m of cfgMembers) {
|
|
82
|
+
if (m.role === "worker" && m.status === "online") names.add(m.name);
|
|
83
|
+
}
|
|
84
|
+
for (const t of tasks) {
|
|
85
|
+
if (t.owner && t.owner !== leadName && t.status === "in_progress") names.add(t.owner);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Array.from(names).sort();
|
|
89
|
+
}
|
|
@@ -7,6 +7,15 @@ 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
|
+
import {
|
|
11
|
+
STATUS_COLOR,
|
|
12
|
+
STATUS_ICON,
|
|
13
|
+
formatTokens,
|
|
14
|
+
getVisibleWorkerNames,
|
|
15
|
+
padRight,
|
|
16
|
+
resolveStatus,
|
|
17
|
+
toolActivity,
|
|
18
|
+
} from "./teams-ui-shared.js";
|
|
10
19
|
|
|
11
20
|
export interface WidgetDeps {
|
|
12
21
|
getTeammates(): Map<string, TeammateRpc>;
|
|
@@ -19,57 +28,6 @@ export interface WidgetDeps {
|
|
|
19
28
|
|
|
20
29
|
export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
|
|
21
30
|
|
|
22
|
-
// Status icon and color mapping
|
|
23
|
-
const STATUS_ICON: Record<TeammateStatus, string> = {
|
|
24
|
-
streaming: "\u25c9",
|
|
25
|
-
idle: "\u25cf",
|
|
26
|
-
starting: "\u25cb",
|
|
27
|
-
stopped: "\u2717",
|
|
28
|
-
error: "\u2717",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
|
|
32
|
-
streaming: "accent",
|
|
33
|
-
idle: "success",
|
|
34
|
-
starting: "muted",
|
|
35
|
-
stopped: "dim",
|
|
36
|
-
error: "error",
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function padRight(str: string, targetWidth: number): string {
|
|
40
|
-
const w = visibleWidth(str);
|
|
41
|
-
return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
|
|
45
|
-
if (rpc) return rpc.status;
|
|
46
|
-
return cfg?.status === "online" ? "idle" : "stopped";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function formatTokens(n: number): string {
|
|
50
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
51
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
52
|
-
return String(n);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const TOOL_VERB: Record<string, string> = {
|
|
56
|
-
read: "reading\u2026",
|
|
57
|
-
edit: "editing\u2026",
|
|
58
|
-
write: "writing\u2026",
|
|
59
|
-
grep: "searching\u2026",
|
|
60
|
-
glob: "finding files\u2026",
|
|
61
|
-
bash: "running\u2026",
|
|
62
|
-
task: "delegating\u2026",
|
|
63
|
-
webfetch: "fetching\u2026",
|
|
64
|
-
websearch: "searching web\u2026",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
function toolActivity(toolName: string | null): string {
|
|
68
|
-
if (!toolName) return "";
|
|
69
|
-
const key = toolName.toLowerCase();
|
|
70
|
-
return TOOL_VERB[key] ?? `${key}\u2026`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
31
|
interface WidgetRow {
|
|
74
32
|
icon: string; // raw char (before styling)
|
|
75
33
|
iconColor: ThemeColor;
|
|
@@ -77,7 +35,7 @@ interface WidgetRow {
|
|
|
77
35
|
statusKey: TeammateStatus;
|
|
78
36
|
pending: number;
|
|
79
37
|
completed: number;
|
|
80
|
-
tokensStr: string; // "
|
|
38
|
+
tokensStr: string; // "—" for chairman
|
|
81
39
|
activityText: string;
|
|
82
40
|
}
|
|
83
41
|
|
|
@@ -131,17 +89,8 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
|
131
89
|
});
|
|
132
90
|
}
|
|
133
91
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
for (const name of teammates.keys()) visibleNames.add(name);
|
|
137
|
-
for (const m of cfgMembers) {
|
|
138
|
-
if (m.role === "worker" && m.status === "online") visibleNames.add(m.name);
|
|
139
|
-
}
|
|
140
|
-
for (const t of tasks) {
|
|
141
|
-
if (t.owner && t.owner !== leadName && t.status === "in_progress") visibleNames.add(t.owner);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (visibleNames.size === 0 && rows.length === 0) {
|
|
92
|
+
const workerNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
|
|
93
|
+
if (workerNames.length === 0 && rows.length === 0) {
|
|
145
94
|
lines.push(
|
|
146
95
|
truncateToWidth(
|
|
147
96
|
" " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
|
|
@@ -149,8 +98,7 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
|
149
98
|
),
|
|
150
99
|
);
|
|
151
100
|
} else {
|
|
152
|
-
const
|
|
153
|
-
for (const name of sortedNames) {
|
|
101
|
+
for (const name of workerNames) {
|
|
154
102
|
const rpc = teammates.get(name);
|
|
155
103
|
const cfg = cfgByName.get(name);
|
|
156
104
|
const statusKey = resolveStatus(rpc, cfg);
|
|
@@ -173,7 +121,7 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
|
173
121
|
const totalPending = tasks.filter((t) => t.status === "pending").length;
|
|
174
122
|
const totalCompleted = tasks.filter((t) => t.status === "completed").length;
|
|
175
123
|
let totalTokensRaw = 0;
|
|
176
|
-
for (const name of
|
|
124
|
+
for (const name of workerNames) totalTokensRaw += tracker.get(name).totalTokens;
|
|
177
125
|
const totalTokensStr = formatTokens(totalTokensRaw);
|
|
178
126
|
|
|
179
127
|
const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
|
|
@@ -200,7 +148,6 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
|
200
148
|
}
|
|
201
149
|
|
|
202
150
|
// ── Total row ──
|
|
203
|
-
const leftWidth = nameColWidth + 13;
|
|
204
151
|
const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
|
|
205
152
|
lines.push(truncateToWidth(sepLine, width));
|
|
206
153
|
|
|
@@ -215,7 +162,7 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
|
215
162
|
"muted",
|
|
216
163
|
` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
|
|
217
164
|
);
|
|
218
|
-
// nameColWidth + 4 = " ◆ " + name;
|
|
165
|
+
// nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
|
|
219
166
|
const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
|
|
220
167
|
lines.push(truncateToWidth(totalRow, width));
|
|
221
168
|
}
|
|
@@ -4,7 +4,7 @@ import { Type, type Static } from "@sinclair/typebox";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
6
6
|
import { sanitizeName } from "./names.js";
|
|
7
|
-
import { getTeamsStyleFromEnv, type TeamsStyle,
|
|
7
|
+
import { getTeamsStyleFromEnv, type TeamsStyle, getTeamsStrings } from "./teams-style.js";
|
|
8
8
|
import {
|
|
9
9
|
TEAM_MAILBOX_NS,
|
|
10
10
|
isAbortRequestMessage,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmustier/pi-agent-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Claude Code agent teams style workflow for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Mustier",
|
|
@@ -27,25 +27,30 @@
|
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
+
"lint": "eslint 'extensions/**/*.ts' 'scripts/**/*.ts' 'scripts/**/*.mts'",
|
|
30
31
|
"typecheck": "tsc -p tsconfig.strict.json",
|
|
32
|
+
"check": "npm run typecheck && npm run lint",
|
|
31
33
|
"smoke-test": "tsx scripts/smoke-test.mts",
|
|
32
34
|
"integration-claim-test": "tsx scripts/integration-claim-test.mts",
|
|
33
35
|
"integration-todo-test": "tsx scripts/integration-todo-test.mts"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
38
|
+
"@eslint/js": "^9.39.2",
|
|
36
39
|
"@mariozechner/pi-agent-core": "^0.52.8",
|
|
37
40
|
"@mariozechner/pi-ai": "^0.52.8",
|
|
38
41
|
"@mariozechner/pi-coding-agent": "^0.52.8",
|
|
39
42
|
"@mariozechner/pi-tui": "^0.52.8",
|
|
40
43
|
"@sinclair/typebox": "^0.34.48",
|
|
44
|
+
"eslint": "^9.39.2",
|
|
45
|
+
"tsx": "^4.20.5",
|
|
41
46
|
"typescript": "^5.9.3",
|
|
42
|
-
"
|
|
47
|
+
"typescript-eslint": "^8.54.0"
|
|
43
48
|
},
|
|
44
49
|
"peerDependencies": {
|
|
50
|
+
"@mariozechner/pi-agent-core": "*",
|
|
51
|
+
"@mariozechner/pi-ai": "*",
|
|
45
52
|
"@mariozechner/pi-coding-agent": "*",
|
|
46
53
|
"@mariozechner/pi-tui": "*",
|
|
47
|
-
"@mariozechner/pi-ai": "*",
|
|
48
|
-
"@mariozechner/pi-agent-core": "*",
|
|
49
54
|
"@sinclair/typebox": "*"
|
|
50
55
|
}
|
|
51
56
|
}
|
|
@@ -10,20 +10,17 @@
|
|
|
10
10
|
* npx tsx scripts/integration-claim-test.mts --agents 2 --tasks 3 --timeoutSec 90
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import * as fs from "node:fs";
|
|
14
13
|
import * as os from "node:os";
|
|
15
14
|
import * as path from "node:path";
|
|
16
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
15
|
import { randomUUID } from "node:crypto";
|
|
18
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { ChildProcess } from "node:child_process";
|
|
19
18
|
|
|
20
19
|
import { ensureTeamConfig } from "../extensions/teams/team-config.js";
|
|
21
20
|
import { getTeamDir } from "../extensions/teams/paths.js";
|
|
22
21
|
import { createTask, listTasks, type TeamTask } from "../extensions/teams/task-store.js";
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
26
|
-
}
|
|
23
|
+
import { sleep, spawnTeamsWorkerRpc, terminateAll } from "./lib/pi-workers.js";
|
|
27
24
|
|
|
28
25
|
function parseArgs(argv: readonly string[]): { agents: number; tasks: number; timeoutSec: number } {
|
|
29
26
|
let agents = 2;
|
|
@@ -63,85 +60,6 @@ function allCompleted(ts: TeamTask[]): boolean {
|
|
|
63
60
|
return ts.length > 0 && ts.every((t) => t.status === "completed");
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
async function terminateAll(children: ChildProcess[]): Promise<void> {
|
|
67
|
-
for (const c of children) {
|
|
68
|
-
try {
|
|
69
|
-
c.kill("SIGTERM");
|
|
70
|
-
} catch {
|
|
71
|
-
// ignore
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Give them a moment to flush + exit.
|
|
76
|
-
const deadline = Date.now() + 10_000;
|
|
77
|
-
for (const c of children) {
|
|
78
|
-
while (c.exitCode === null && Date.now() < deadline) {
|
|
79
|
-
await sleep(100);
|
|
80
|
-
}
|
|
81
|
-
if (c.exitCode === null) {
|
|
82
|
-
try {
|
|
83
|
-
c.kill("SIGKILL");
|
|
84
|
-
} catch {
|
|
85
|
-
// ignore
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function spawnWorker(opts: {
|
|
92
|
-
repoRoot: string;
|
|
93
|
-
entryPath: string;
|
|
94
|
-
sessionsDir: string;
|
|
95
|
-
teamId: string;
|
|
96
|
-
agentName: string;
|
|
97
|
-
logDir: string;
|
|
98
|
-
}): ChildProcess {
|
|
99
|
-
const { repoRoot, entryPath, sessionsDir, teamId, agentName, logDir } = opts;
|
|
100
|
-
|
|
101
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
102
|
-
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
103
|
-
|
|
104
|
-
const sessionFile = path.join(sessionsDir, `${agentName}.jsonl`);
|
|
105
|
-
fs.closeSync(fs.openSync(sessionFile, "a"));
|
|
106
|
-
|
|
107
|
-
const logPath = path.join(logDir, `${agentName}.log`);
|
|
108
|
-
const out = fs.openSync(logPath, "a");
|
|
109
|
-
const err = fs.openSync(logPath, "a");
|
|
110
|
-
|
|
111
|
-
const args = [
|
|
112
|
-
"--mode",
|
|
113
|
-
"rpc",
|
|
114
|
-
"--session",
|
|
115
|
-
sessionFile,
|
|
116
|
-
"--session-dir",
|
|
117
|
-
sessionsDir,
|
|
118
|
-
"--no-extensions",
|
|
119
|
-
"-e",
|
|
120
|
-
entryPath,
|
|
121
|
-
"--append-system-prompt",
|
|
122
|
-
["You are a teammate in an automated integration test.",
|
|
123
|
-
"Keep replies extremely short.",
|
|
124
|
-
"If you are assigned or auto-claim a task that says 'Reply with: okX', respond with exactly 'okX' and nothing else.",
|
|
125
|
-
].join(" "),
|
|
126
|
-
];
|
|
127
|
-
|
|
128
|
-
return spawn("pi", args, {
|
|
129
|
-
cwd: repoRoot,
|
|
130
|
-
env: {
|
|
131
|
-
...process.env,
|
|
132
|
-
PI_TEAMS_WORKER: "1",
|
|
133
|
-
PI_TEAMS_TEAM_ID: teamId,
|
|
134
|
-
PI_TEAMS_TASK_LIST_ID: teamId,
|
|
135
|
-
PI_TEAMS_AGENT_NAME: agentName,
|
|
136
|
-
PI_TEAMS_LEAD_NAME: "team-lead",
|
|
137
|
-
PI_TEAMS_STYLE: "normal",
|
|
138
|
-
PI_TEAMS_AUTO_CLAIM: "1",
|
|
139
|
-
PI_TEAMS_PLAN_REQUIRED: "0",
|
|
140
|
-
},
|
|
141
|
-
stdio: ["ignore", out, err],
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
63
|
const { agents, tasks, timeoutSec } = parseArgs(process.argv.slice(2));
|
|
146
64
|
|
|
147
65
|
if (agents < 2 || tasks < 3) {
|
|
@@ -158,6 +76,12 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
158
76
|
const repoRoot = path.resolve(scriptDir, "..");
|
|
159
77
|
const entryPath = path.join(repoRoot, "extensions", "teams", "index.ts");
|
|
160
78
|
|
|
79
|
+
const systemAppend = [
|
|
80
|
+
"You are a teammate in an automated integration test.",
|
|
81
|
+
"Keep replies extremely short.",
|
|
82
|
+
"If you are assigned or auto-claim a task that says 'Reply with: okX', respond with exactly 'okX' and nothing else.",
|
|
83
|
+
].join(" ");
|
|
84
|
+
|
|
161
85
|
console.log(`TeamId: ${teamId}`);
|
|
162
86
|
console.log(`TeamDir: ${teamDir}`);
|
|
163
87
|
console.log(`Spawning ${agents} worker(s), creating ${tasks} task(s)`);
|
|
@@ -190,12 +114,18 @@ process.on("SIGTERM", () => {
|
|
|
190
114
|
try {
|
|
191
115
|
for (let i = 1; i <= agents; i += 1) {
|
|
192
116
|
children.push(
|
|
193
|
-
|
|
194
|
-
repoRoot,
|
|
117
|
+
spawnTeamsWorkerRpc({
|
|
118
|
+
cwd: repoRoot,
|
|
195
119
|
entryPath,
|
|
196
120
|
sessionsDir,
|
|
197
121
|
teamId,
|
|
122
|
+
taskListId: teamId,
|
|
198
123
|
agentName: `agent${i}`,
|
|
124
|
+
leadName: "team-lead",
|
|
125
|
+
style: "normal",
|
|
126
|
+
autoClaim: true,
|
|
127
|
+
planRequired: false,
|
|
128
|
+
systemAppend,
|
|
199
129
|
logDir: logsDir,
|
|
200
130
|
}),
|
|
201
131
|
);
|