@tmustier/pi-agent-teams 0.1.2 → 0.3.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 (39) hide show
  1. package/README.md +50 -9
  2. package/docs/claude-parity.md +22 -18
  3. package/docs/field-notes-teams-setup.md +6 -4
  4. package/docs/smoke-test-plan.md +139 -0
  5. package/eslint.config.js +74 -0
  6. package/extensions/teams/activity-tracker.ts +234 -0
  7. package/extensions/teams/fs-lock.ts +21 -5
  8. package/extensions/teams/leader-inbox.ts +175 -0
  9. package/extensions/teams/leader-info-commands.ts +139 -0
  10. package/extensions/teams/leader-lifecycle-commands.ts +343 -0
  11. package/extensions/teams/leader-messaging-commands.ts +148 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +57 -0
  14. package/extensions/teams/leader-task-commands.ts +421 -0
  15. package/extensions/teams/leader-team-command.ts +312 -0
  16. package/extensions/teams/leader-teams-tool.ts +227 -0
  17. package/extensions/teams/leader.ts +260 -1562
  18. package/extensions/teams/mailbox.ts +54 -29
  19. package/extensions/teams/names.ts +87 -0
  20. package/extensions/teams/protocol.ts +241 -0
  21. package/extensions/teams/spawn-types.ts +21 -0
  22. package/extensions/teams/task-store.ts +36 -21
  23. package/extensions/teams/team-config.ts +71 -25
  24. package/extensions/teams/teammate-rpc.ts +81 -23
  25. package/extensions/teams/teams-panel.ts +644 -0
  26. package/extensions/teams/teams-style.ts +62 -0
  27. package/extensions/teams/teams-ui-shared.ts +89 -0
  28. package/extensions/teams/teams-widget.ts +182 -0
  29. package/extensions/teams/worker.ts +100 -138
  30. package/extensions/teams/worktree.ts +4 -7
  31. package/package.json +32 -5
  32. package/scripts/integration-claim-test.mts +157 -0
  33. package/scripts/integration-todo-test.mts +532 -0
  34. package/scripts/lib/pi-workers.ts +105 -0
  35. package/scripts/smoke-test.mts +424 -0
  36. package/skills/agent-teams/SKILL.md +139 -0
  37. package/tsconfig.strict.json +22 -0
  38. package/extensions/teams/tasks.ts +0 -95
  39. package/scripts/smoke-test.mjs +0 -199
@@ -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
+ }
@@ -0,0 +1,182 @@
1
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import type { Component, TUI } from "@mariozechner/pi-tui";
3
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
4
+ import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
5
+ import type { ActivityTracker } from "./activity-tracker.js";
6
+ import type { TeamTask } from "./task-store.js";
7
+ import type { TeamConfig, TeamMember } from "./team-config.js";
8
+ import type { TeamsStyle } from "./teams-style.js";
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";
19
+
20
+ export interface WidgetDeps {
21
+ getTeammates(): Map<string, TeammateRpc>;
22
+ getTracker(): ActivityTracker;
23
+ getTasks(): TeamTask[];
24
+ getTeamConfig(): TeamConfig | null;
25
+ getStyle(): TeamsStyle;
26
+ isDelegateMode(): boolean;
27
+ }
28
+
29
+ export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
30
+
31
+ interface WidgetRow {
32
+ icon: string; // raw char (before styling)
33
+ iconColor: ThemeColor;
34
+ displayName: string;
35
+ statusKey: TeammateStatus;
36
+ pending: number;
37
+ completed: number;
38
+ tokensStr: string; // "—" for chairman
39
+ activityText: string;
40
+ }
41
+
42
+ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
43
+ return (_tui: TUI, theme: Theme): Component => {
44
+ return {
45
+ render(width: number): string[] {
46
+ const teammates = deps.getTeammates();
47
+ const tracker = deps.getTracker();
48
+ const tasks = deps.getTasks();
49
+ const teamConfig = deps.getTeamConfig();
50
+ const style = deps.getStyle();
51
+ const strings = getTeamsStrings(style);
52
+ const delegateMode = deps.isDelegateMode();
53
+
54
+ // Hide when no active team state
55
+ const hasOnlineMembers = (teamConfig?.members ?? []).some(
56
+ (m) => m.role === "worker" && m.status === "online",
57
+ );
58
+ if (teammates.size === 0 && tasks.length === 0 && !hasOnlineMembers) {
59
+ return [];
60
+ }
61
+
62
+ const lines: string[] = [];
63
+
64
+ // ── Header line ──
65
+ let header = " " + theme.bold(theme.fg("accent", "Teams"));
66
+ if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
67
+ lines.push(truncateToWidth(header, width));
68
+
69
+ // ── Build row data ──
70
+ const cfgMembers = teamConfig?.members ?? [];
71
+ const cfgByName = new Map<string, TeamMember>();
72
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
73
+
74
+ const rows: WidgetRow[] = [];
75
+
76
+ // Chairman row (always first when team is active)
77
+ const leadName = teamConfig?.leadName;
78
+ if (leadName) {
79
+ const leadTasks = tasks.filter((t) => t.owner === leadName);
80
+ rows.push({
81
+ icon: "\u25c6",
82
+ iconColor: "accent",
83
+ displayName: strings.leaderTitle,
84
+ statusKey: "idle",
85
+ pending: leadTasks.filter((t) => t.status === "pending").length,
86
+ completed: leadTasks.filter((t) => t.status === "completed").length,
87
+ tokensStr: "\u2014",
88
+ activityText: "",
89
+ });
90
+ }
91
+
92
+ const workerNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
93
+ if (workerNames.length === 0 && rows.length === 0) {
94
+ lines.push(
95
+ truncateToWidth(
96
+ " " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
97
+ width,
98
+ ),
99
+ );
100
+ } else {
101
+ for (const name of workerNames) {
102
+ const rpc = teammates.get(name);
103
+ const cfg = cfgByName.get(name);
104
+ const statusKey = resolveStatus(rpc, cfg);
105
+ const activity = tracker.get(name);
106
+ const owned = tasks.filter((t) => t.owner === name);
107
+
108
+ rows.push({
109
+ icon: STATUS_ICON[statusKey],
110
+ iconColor: STATUS_COLOR[statusKey],
111
+ displayName: formatMemberDisplayName(style, name),
112
+ statusKey,
113
+ pending: owned.filter((t) => t.status === "pending").length,
114
+ completed: owned.filter((t) => t.status === "completed").length,
115
+ tokensStr: formatTokens(activity.totalTokens),
116
+ activityText: toolActivity(activity.currentToolName),
117
+ });
118
+ }
119
+
120
+ // ── Compute column widths ──
121
+ const totalPending = tasks.filter((t) => t.status === "pending").length;
122
+ const totalCompleted = tasks.filter((t) => t.status === "completed").length;
123
+ let totalTokensRaw = 0;
124
+ for (const name of workerNames) totalTokensRaw += tracker.get(name).totalTokens;
125
+ const totalTokensStr = formatTokens(totalTokensRaw);
126
+
127
+ const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
128
+ const pW = Math.max(...rows.map((r) => String(r.pending).length), String(totalPending).length);
129
+ const cW = Math.max(...rows.map((r) => String(r.completed).length), String(totalCompleted).length);
130
+ const tokW = Math.max(...rows.map((r) => r.tokensStr.length), totalTokensStr.length);
131
+
132
+ // ── Render rows ──
133
+ for (const r of rows) {
134
+ const icon = theme.fg(r.iconColor, r.icon);
135
+ const styledName = theme.bold(r.displayName);
136
+ const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
137
+ const pNum = String(r.pending).padStart(pW);
138
+ const cNum = String(r.completed).padStart(cW);
139
+ const tokStr = r.tokensStr.padStart(tokW);
140
+ const cols = theme.fg(
141
+ "dim",
142
+ ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
143
+ );
144
+ const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
145
+
146
+ const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
147
+ lines.push(truncateToWidth(row, width));
148
+ }
149
+
150
+ // ── Total row ──
151
+ const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
152
+ lines.push(truncateToWidth(sepLine, width));
153
+
154
+ const totalLabel = theme.bold("Total");
155
+ const totalTaskCount = totalPending + totalCompleted;
156
+ const pct = totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
157
+ const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
158
+ const tpNum = String(totalPending).padStart(pW);
159
+ const tcNum = String(totalCompleted).padStart(cW);
160
+ const ttokStr = totalTokensStr.padStart(tokW);
161
+ const totalSuffix = theme.fg(
162
+ "muted",
163
+ ` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
164
+ );
165
+ // nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
166
+ const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
167
+ lines.push(truncateToWidth(totalRow, width));
168
+ }
169
+
170
+ // ── Hints line ──
171
+ const hints = theme.fg(
172
+ "dim",
173
+ " /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
174
+ );
175
+ lines.push(truncateToWidth(hints, width));
176
+
177
+ return lines;
178
+ },
179
+ invalidate() {},
180
+ };
181
+ };
182
+ }
@@ -1,8 +1,19 @@
1
- import type { AgentMessage } from "@mariozechner/pi-agent-core";
1
+ import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
- import { Type } from "@sinclair/typebox";
3
+ import { Type, type Static } from "@sinclair/typebox";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
6
+ import { sanitizeName } from "./names.js";
7
+ import { getTeamsStyleFromEnv, type TeamsStyle, getTeamsStrings } from "./teams-style.js";
8
+ import {
9
+ TEAM_MAILBOX_NS,
10
+ isAbortRequestMessage,
11
+ isPlanApprovedMessage,
12
+ isPlanRejectedMessage,
13
+ isSetSessionNameMessage,
14
+ isShutdownRequestMessage,
15
+ isTaskAssignmentMessage,
16
+ } from "./protocol.js";
6
17
  import { getTeamDir } from "./paths.js";
7
18
  import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
8
19
  import {
@@ -16,31 +27,27 @@ import {
16
27
  type TeamTask,
17
28
  } from "./task-store.js";
18
29
 
19
- const TEAM_MAILBOX_NS = "team";
20
-
21
30
  function sleep(ms: number): Promise<void> {
22
31
  return new Promise((r) => setTimeout(r, ms));
23
32
  }
24
33
 
25
- function sanitize(name: string): string {
26
- return name.replace(/[^a-zA-Z0-9_-]/g, "-");
27
- }
28
-
29
34
  function teamDirFromEnv(): {
30
35
  teamId: string;
31
36
  teamDir: string;
32
37
  taskListId: string;
33
38
  agentName: string;
34
39
  leadName: string;
40
+ style: TeamsStyle;
35
41
  autoClaim: boolean;
36
42
  } | null {
37
43
  const teamId = process.env.PI_TEAMS_TEAM_ID;
38
44
  const agentNameRaw = process.env.PI_TEAMS_AGENT_NAME;
39
45
  if (!teamId || !agentNameRaw) return null;
40
46
 
41
- const agentName = sanitize(agentNameRaw);
47
+ const agentName = sanitizeName(agentNameRaw);
42
48
  const taskListId = process.env.PI_TEAMS_TASK_LIST_ID ?? teamId;
43
- const leadName = sanitize(process.env.PI_TEAMS_LEAD_NAME ?? "team-lead");
49
+ const style = getTeamsStyleFromEnv(process.env);
50
+ const leadName = sanitizeName(process.env.PI_TEAMS_LEAD_NAME ?? "team-lead");
44
51
  const autoClaim = (process.env.PI_TEAMS_AUTO_CLAIM ?? "1") === "1";
45
52
 
46
53
  return {
@@ -49,32 +56,57 @@ function teamDirFromEnv(): {
49
56
  taskListId,
50
57
  agentName,
51
58
  leadName,
59
+ style,
52
60
  autoClaim,
53
61
  };
54
62
  }
55
63
 
64
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null;
66
+ }
67
+
68
+ function hasProperty<K extends string>(value: unknown, key: K): value is Record<K, unknown> & Record<string, unknown> {
69
+ return isObjectRecord(value) && key in value;
70
+ }
71
+
72
+ function hasStringProperty<K extends string>(value: unknown, key: K): value is Record<K, string> & Record<string, unknown> {
73
+ return isObjectRecord(value) && typeof value[key] === "string";
74
+ }
75
+
76
+ type AssistantMessageWithContent = Record<"role", "assistant"> & Record<"content", unknown> & Record<string, unknown>;
77
+
78
+ function isAssistantMessageWithContent(message: unknown): message is AssistantMessageWithContent {
79
+ return hasStringProperty(message, "role") && message.role === "assistant" && hasProperty(message, "content");
80
+ }
81
+
82
+ type TextBlock = { type: "text"; text: string };
83
+
84
+ function isTextBlock(block: unknown): block is TextBlock {
85
+ return hasStringProperty(block, "type") && block.type === "text" && hasStringProperty(block, "text");
86
+ }
87
+
56
88
  function extractLastAssistantText(messages: AgentMessage[]): string {
57
- const assistant = messages.filter((m: any) => m && typeof m === "object" && m.role === "assistant");
58
- const last: any = assistant[assistant.length - 1];
89
+ const assistant = messages.filter((m) => isAssistantMessageWithContent(m));
90
+ const last = assistant.at(-1);
59
91
  if (!last) return "";
60
92
 
61
93
  const content = last.content;
62
94
  if (typeof content === "string") return content;
63
95
  if (Array.isArray(content)) {
64
- return content
65
- .filter((c) => c && typeof c === "object" && c.type === "text" && typeof (c as any).text === "string")
66
- .map((c: any) => c.text)
67
- .join("");
96
+ return content.filter((c) => isTextBlock(c)).map((c) => c.text).join("");
68
97
  }
69
98
  return "";
70
99
  }
71
100
 
72
- function buildTaskPrompt(agentName: string, task: TeamTask, planOnly = false): string {
101
+ function buildTaskPrompt(style: TeamsStyle, agentName: string, task: TeamTask, planOnly = false): string {
102
+ const strings = getTeamsStrings(style);
73
103
  const footer = planOnly
74
104
  ? "Produce a detailed implementation plan only. Do NOT make any changes or implement anything yet. Your plan will be reviewed before you can proceed."
75
105
  : "Do the work now. When finished, reply with a concise summary and any key outputs.";
106
+
107
+ const actor = style === "soviet" ? strings.memberTitle.toLowerCase() : "teammate";
76
108
  return [
77
- `You are teammate '${agentName}'.`,
109
+ `You are ${actor} '${agentName}'.`,
78
110
  `You have been assigned task #${task.id}.`,
79
111
  `Subject: ${task.subject}`,
80
112
  "",
@@ -84,124 +116,36 @@ function buildTaskPrompt(agentName: string, task: TeamTask, planOnly = false): s
84
116
  ].join("\n");
85
117
  }
86
118
 
87
- function isTaskAssignmentMessage(text: string): { taskId: string; subject?: string; description?: string; assignedBy?: string } | null {
88
- try {
89
- const obj = JSON.parse(text);
90
- if (!obj || typeof obj !== "object") return null;
91
- if (obj.type !== "task_assignment") return null;
92
- if (typeof obj.taskId !== "string") return null;
93
- return {
94
- taskId: obj.taskId,
95
- subject: typeof obj.subject === "string" ? obj.subject : undefined,
96
- description: typeof obj.description === "string" ? obj.description : undefined,
97
- assignedBy: typeof obj.assignedBy === "string" ? obj.assignedBy : undefined,
98
- };
99
- } catch {
100
- return null;
101
- }
102
- }
103
-
104
- function isShutdownRequestMessage(text: string): { requestId: string; from?: string; reason?: string; timestamp?: string } | null {
105
- try {
106
- const obj = JSON.parse(text);
107
- if (!obj || typeof obj !== "object") return null;
108
- if (obj.type !== "shutdown_request") return null;
109
- if (typeof obj.requestId !== "string") return null;
110
- return {
111
- requestId: obj.requestId,
112
- from: typeof obj.from === "string" ? obj.from : undefined,
113
- reason: typeof obj.reason === "string" ? obj.reason : undefined,
114
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
115
- };
116
- } catch {
117
- return null;
118
- }
119
- }
120
-
121
- function isSetSessionNameMessage(text: string): { name: string } | null {
122
- try {
123
- const obj = JSON.parse(text);
124
- if (!obj || typeof obj !== "object") return null;
125
- if (obj.type !== "set_session_name") return null;
126
- if (typeof obj.name !== "string") return null;
127
- return { name: obj.name };
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- function isAbortRequestMessage(
134
- text: string,
135
- ): { requestId: string; from?: string; taskId?: string; reason?: string; timestamp?: string } | null {
136
- try {
137
- const obj = JSON.parse(text);
138
- if (!obj || typeof obj !== "object") return null;
139
- if (obj.type !== "abort_request") return null;
140
- if (typeof obj.requestId !== "string") return null;
141
- return {
142
- requestId: obj.requestId,
143
- from: typeof obj.from === "string" ? obj.from : undefined,
144
- taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
145
- reason: typeof obj.reason === "string" ? obj.reason : undefined,
146
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
147
- };
148
- } catch {
149
- return null;
150
- }
151
- }
152
-
153
- function isPlanApprovedMessage(text: string): { requestId: string; from: string; timestamp: string } | null {
154
- try {
155
- const obj = JSON.parse(text);
156
- if (!obj || typeof obj !== "object") return null;
157
- if (obj.type !== "plan_approved") return null;
158
- if (typeof obj.requestId !== "string" || typeof obj.from !== "string") return null;
159
- return {
160
- requestId: obj.requestId,
161
- from: obj.from,
162
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : "",
163
- };
164
- } catch {
165
- return null;
166
- }
167
- }
168
-
169
- function isPlanRejectedMessage(
170
- text: string,
171
- ): { requestId: string; from: string; feedback: string; timestamp: string } | null {
172
- try {
173
- const obj = JSON.parse(text);
174
- if (!obj || typeof obj !== "object") return null;
175
- if (obj.type !== "plan_rejected") return null;
176
- if (typeof obj.requestId !== "string" || typeof obj.from !== "string") return null;
177
- return {
178
- requestId: obj.requestId,
179
- from: obj.from,
180
- feedback: typeof obj.feedback === "string" ? obj.feedback : "",
181
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : "",
182
- };
183
- } catch {
184
- return null;
185
- }
186
- }
187
-
119
+ // Message parsers are shared with the leader implementation.
188
120
  export function runWorker(pi: ExtensionAPI): void {
189
121
  const env = teamDirFromEnv();
190
122
  if (!env) return;
191
123
 
192
- const { teamId, teamDir, taskListId, agentName, leadName, autoClaim } = env;
124
+ const { teamId, teamDir, taskListId, agentName, leadName, style, autoClaim } = env;
125
+
126
+ const TeamMessageToolParamsSchema = Type.Object({
127
+ recipient: Type.String({ description: "Name of the comrade to message" }),
128
+ message: Type.String({ description: "The message to send" }),
129
+ });
130
+ // Match the schema at compile-time.
131
+ type TeamMessageToolParams = Static<typeof TeamMessageToolParamsSchema>;
132
+ // Tool result details to match AgentToolResult<TDetails> contract.
133
+ type TeamMessageToolDetails = { recipient: string; timestamp: string };
193
134
 
194
135
  pi.registerTool({
195
136
  name: "team_message",
196
137
  label: "Team Message",
197
- description: "Send a message to a teammate. Use this to coordinate with peers on related tasks.",
198
- parameters: Type.Object({
199
- recipient: Type.String({ description: "Name of the teammate to message" }),
200
- message: Type.String({ description: "The message to send" }),
201
- }),
202
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
203
- const recipient = sanitize((params as any).recipient);
204
- const message = (params as any).message as string;
138
+ description: "Send a message to a comrade. Use this to coordinate with peers on related tasks.",
139
+ parameters: TeamMessageToolParamsSchema,
140
+ async execute(
141
+ _toolCallId,
142
+ params: TeamMessageToolParams,
143
+ _signal,
144
+ _onUpdate,
145
+ _ctx,
146
+ ): Promise<AgentToolResult<TeamMessageToolDetails>> {
147
+ const recipient = sanitizeName(params.recipient);
148
+ const message = params.message;
205
149
  const ts = new Date().toISOString();
206
150
  // Write to recipient's mailbox in team namespace
207
151
  await writeToMailbox(teamDir, TEAM_MAILBOX_NS, recipient, {
@@ -221,7 +165,10 @@ export function runWorker(pi: ExtensionAPI): void {
221
165
  }),
222
166
  timestamp: ts,
223
167
  });
224
- return { content: [{ type: "text", text: `Message sent to ${recipient}` }] };
168
+ return {
169
+ content: [{ type: "text", text: `Message sent to ${recipient}` }],
170
+ details: { recipient, timestamp: ts },
171
+ };
225
172
  },
226
173
  });
227
174
 
@@ -388,7 +335,8 @@ export function runWorker(pi: ExtensionAPI): void {
388
335
  // ignore polling errors
389
336
  }
390
337
 
391
- await sleep(350);
338
+ // Add a little jitter to avoid all workers polling/claiming in lock-step.
339
+ await sleep(350 + Math.floor(Math.random() * 200));
392
340
  }
393
341
  };
394
342
 
@@ -404,7 +352,8 @@ export function runWorker(pi: ExtensionAPI): void {
404
352
  // 1) Assigned tasks
405
353
  const requeue: string[] = [];
406
354
  while (pendingTaskAssignments.length) {
407
- const taskId = pendingTaskAssignments.shift()!;
355
+ const taskId = pendingTaskAssignments.shift();
356
+ if (!taskId) break;
408
357
  const task = await getTask(teamDir, taskListId, taskId);
409
358
  if (!task) continue;
410
359
  if (task.owner !== agentName) continue;
@@ -421,7 +370,7 @@ export function runWorker(pi: ExtensionAPI): void {
421
370
 
422
371
  currentTaskId = taskId;
423
372
  isStreaming = true; // optimistic; agent_start will follow
424
- pi.sendUserMessage(buildTaskPrompt(agentName, task, planMode && !planApproved));
373
+ pi.sendUserMessage(buildTaskPrompt(style, agentName, task, planMode && !planApproved));
425
374
  pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
426
375
  return;
427
376
  }
@@ -433,7 +382,7 @@ export function runWorker(pi: ExtensionAPI): void {
433
382
  pendingDmTexts = [];
434
383
  isStreaming = true;
435
384
  pi.sendUserMessage([
436
- { type: "text", text: "You have received teammate message(s):" },
385
+ { type: "text", text: "You have received comrade message(s):" },
437
386
  { type: "text", text },
438
387
  ]);
439
388
  return;
@@ -441,11 +390,15 @@ export function runWorker(pi: ExtensionAPI): void {
441
390
 
442
391
  // 3) Auto-claim
443
392
  if (autoClaim) {
393
+ // Small randomized delay improves fairness (reduces one fast worker hogging tasks)
394
+ // and reduces lock contention when many workers become idle simultaneously.
395
+ await sleep(Math.floor(Math.random() * 250));
396
+
444
397
  const claimed = await claimNextAvailableTask(teamDir, taskListId, agentName, { checkAgentBusy: true });
445
398
  if (claimed) {
446
399
  currentTaskId = claimed.id;
447
400
  isStreaming = true;
448
- pi.sendUserMessage(buildTaskPrompt(agentName, claimed, planMode && !planApproved));
401
+ pi.sendUserMessage(buildTaskPrompt(style, agentName, claimed, planMode && !planApproved));
449
402
  return;
450
403
  }
451
404
  }
@@ -459,7 +412,16 @@ export function runWorker(pi: ExtensionAPI): void {
459
412
  completedStatus?: "completed" | "failed",
460
413
  failureReason?: string,
461
414
  ) => {
462
- const payload: any = {
415
+ type IdleNotificationPayload = {
416
+ type: "idle_notification";
417
+ from: string;
418
+ timestamp: string;
419
+ completedTaskId?: string;
420
+ completedStatus?: "completed" | "failed";
421
+ failureReason?: string;
422
+ };
423
+
424
+ const payload: IdleNotificationPayload = {
463
425
  type: "idle_notification",
464
426
  from: agentName,
465
427
  timestamp: new Date().toISOString(),
@@ -494,7 +456,7 @@ export function runWorker(pi: ExtensionAPI): void {
494
456
 
495
457
  // Register ourselves in the shared team config so manual tmux workers are discoverable.
496
458
  try {
497
- const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName });
459
+ const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName, style });
498
460
  const now = new Date().toISOString();
499
461
  if (!cfg.members.some((m) => m.name === agentName)) {
500
462
  await upsertMember(teamDir, {
@@ -541,7 +503,7 @@ export function runWorker(pi: ExtensionAPI): void {
541
503
  // Plan submission: if in plan mode and not yet approved, send plan to leader for review
542
504
  // Only do this when we're working on a task and haven't already requested approval.
543
505
  if (planMode && !planApproved && currentTaskId && !planRequestId) {
544
- const lastAssistantText = extractLastAssistantText(event.messages as AgentMessage[]);
506
+ const lastAssistantText = extractLastAssistantText(event.messages);
545
507
  const reqId = randomUUID();
546
508
  planRequestId = reqId;
547
509
  const timestamp = new Date().toISOString();
@@ -570,7 +532,7 @@ export function runWorker(pi: ExtensionAPI): void {
570
532
 
571
533
  try {
572
534
  if (taskId) {
573
- const rawResult = extractLastAssistantText(event.messages as AgentMessage[]);
535
+ const rawResult = extractLastAssistantText(event.messages);
574
536
  const trimmed = rawResult.trim();
575
537
  const abortedByRequest = abortTaskId === taskId;
576
538
  const aborted = abortedByRequest || trimmed.length === 0;
@@ -1,10 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
-
5
- function sanitize(name: string): string {
6
- return name.replace(/[^a-zA-Z0-9_-]/g, "-");
7
- }
4
+ import { sanitizeName } from "./names.js";
8
5
 
9
6
  async function execGit(args: string[], opts: { cwd: string; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> {
10
7
  return await new Promise((resolve, reject) => {
@@ -71,8 +68,8 @@ export async function ensureWorktreeCwd(opts: {
71
68
  // ignore status errors
72
69
  }
73
70
 
74
- const safeAgent = sanitize(opts.agentName);
75
- const shortTeam = sanitize(opts.teamId).slice(0, 12) || "team";
71
+ const safeAgent = sanitizeName(opts.agentName);
72
+ const shortTeam = sanitizeName(opts.teamId).slice(0, 12) || "team";
76
73
  const branch = `pi-teams/${shortTeam}/${safeAgent}`;
77
74
 
78
75
  const worktreesDir = path.join(opts.teamDir, "worktrees");
@@ -88,7 +85,7 @@ export async function ensureWorktreeCwd(opts: {
88
85
  // Create worktree + new branch from HEAD
89
86
  await execGit(["worktree", "add", "-b", branch, worktreePath, "HEAD"], { cwd: repoRoot, timeoutMs: 120_000 });
90
87
  return { cwd: worktreePath, warnings, mode: "worktree" };
91
- } catch (err: any) {
88
+ } catch (err: unknown) {
92
89
  const msg = err instanceof Error ? err.message : String(err);
93
90
  // If the branch already exists (e.g. previous run), try adding worktree using the existing branch.
94
91
  if (msg.includes("already exists") || msg.includes("is already checked out")) {