@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.
@@ -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
- abortComrade(name: string): void;
20
- killComrade(name: string): void;
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 = TOOL_VERB[entry.toolName.toLowerCase()] ?? `${entry.toolName}\u2026`;
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 = getComradeNames(deps);
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[]; comradeNames: string[] } {
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
- // Comrades
242
- const comradeNames = getComradeNames(deps);
243
- for (const name of comradeNames) {
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, comradeNames };
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, comradeNames } = buildRows();
215
+ const { rows, memberNames } = buildRows();
274
216
 
275
217
  // Clamp cursor
276
- if (cursorIndex >= comradeNames.length) cursorIndex = Math.max(0, comradeNames.length - 1);
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 comradeNames) totalTokensRaw += tracker.get(name).totalTokens;
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 && comradeNames.indexOf(r.name) === cursorIndex;
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.abortComrade(sessionName);
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.killComrade(name);
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 comradeNames = getComradeNames(deps);
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(comradeNames.length - 1, cursorIndex + 1);
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 = comradeNames[cursorIndex];
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 = comradeNames[cursorIndex];
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 = comradeNames[cursorIndex];
613
+ const name = memberNames[cursorIndex];
668
614
  if (name) {
669
- deps.abortComrade(name);
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 = comradeNames[cursorIndex];
621
+ const name = memberNames[cursorIndex];
676
622
  if (name) {
677
- deps.killComrade(name);
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; // "\u2014" for chairman
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
- // Comrade rows
135
- const visibleNames = new Set<string>();
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 sortedNames = Array.from(visibleNames).sort();
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 sortedNames) totalTokensRaw += tracker.get(name).totalTokens;
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; then " " + pctLabel fills the status column
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, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
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.2.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
- "tsx": "^4.20.5"
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
- function sleep(ms: number): Promise<void> {
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
- spawnWorker({
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
  );