@tmustier/pi-agent-teams 0.1.2 → 0.2.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 (35) hide show
  1. package/.github/workflows/ci.yml +32 -0
  2. package/README.md +36 -6
  3. package/docs/claude-parity.md +16 -14
  4. package/docs/field-notes-teams-setup.md +6 -4
  5. package/docs/smoke-test-plan.md +130 -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 +330 -0
  11. package/extensions/teams/leader-messaging-commands.ts +149 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +73 -0
  14. package/extensions/teams/leader-task-commands.ts +417 -0
  15. package/extensions/teams/leader-teams-tool.ts +238 -0
  16. package/extensions/teams/leader.ts +396 -1422
  17. package/extensions/teams/mailbox.ts +54 -29
  18. package/extensions/teams/names.ts +87 -0
  19. package/extensions/teams/protocol.ts +221 -0
  20. package/extensions/teams/task-store.ts +32 -21
  21. package/extensions/teams/team-config.ts +71 -25
  22. package/extensions/teams/teammate-rpc.ts +56 -22
  23. package/extensions/teams/teams-panel.ts +698 -0
  24. package/extensions/teams/teams-style.ts +62 -0
  25. package/extensions/teams/teams-widget.ts +235 -0
  26. package/extensions/teams/worker.ts +100 -138
  27. package/extensions/teams/worktree.ts +4 -7
  28. package/package.json +25 -3
  29. package/scripts/integration-claim-test.mts +227 -0
  30. package/scripts/integration-todo-test.mts +583 -0
  31. package/scripts/smoke-test.mjs +1 -1
  32. package/scripts/smoke-test.mts +424 -0
  33. package/skills/agent-teams/SKILL.md +136 -0
  34. package/tsconfig.strict.json +22 -0
  35. package/extensions/teams/tasks.ts +0 -95
@@ -0,0 +1,62 @@
1
+ export type TeamsStyle = "normal" | "soviet";
2
+
3
+ export const TEAMS_STYLES: readonly TeamsStyle[] = ["normal", "soviet"] as const;
4
+
5
+ function coerceTeamsStyle(v: unknown): TeamsStyle | null {
6
+ return v === "normal" || v === "soviet" ? v : null;
7
+ }
8
+
9
+ /**
10
+ * Resolve teams UI style.
11
+ *
12
+ * Priority:
13
+ * 1) PI_TEAMS_STYLE env
14
+ * 2) default "normal"
15
+ */
16
+ export function getTeamsStyleFromEnv(env: NodeJS.ProcessEnv = process.env): TeamsStyle {
17
+ const raw = env.PI_TEAMS_STYLE;
18
+ const coerced = coerceTeamsStyle(raw);
19
+ return coerced ?? "normal";
20
+ }
21
+
22
+ export function isSovietStyle(style: TeamsStyle): boolean {
23
+ return style === "soviet";
24
+ }
25
+
26
+ export type TeamsStrings = {
27
+ leaderTitle: string;
28
+ memberTitle: string;
29
+ memberPrefix: string;
30
+ teamNoun: string;
31
+ joinedVerb: string;
32
+ leftVerb: string;
33
+ killedVerb: string;
34
+ };
35
+
36
+ export function getTeamsStrings(style: TeamsStyle): TeamsStrings {
37
+ if (style === "soviet") {
38
+ return {
39
+ leaderTitle: "Chairman",
40
+ memberTitle: "Comrade",
41
+ memberPrefix: "Comrade ",
42
+ teamNoun: "Party",
43
+ joinedVerb: "has joined the Party",
44
+ leftVerb: "has left the Party",
45
+ killedVerb: "sent to the gulag",
46
+ };
47
+ }
48
+ return {
49
+ leaderTitle: "Team leader",
50
+ memberTitle: "Teammate",
51
+ memberPrefix: "Teammate ",
52
+ teamNoun: "team",
53
+ joinedVerb: "joined the team",
54
+ leftVerb: "left the team",
55
+ killedVerb: "stopped",
56
+ };
57
+ }
58
+
59
+ export function formatMemberDisplayName(style: TeamsStyle, name: string): string {
60
+ const s = getTeamsStrings(style);
61
+ return s.memberPrefix ? `${s.memberPrefix}${name}` : name;
62
+ }
@@ -0,0 +1,235 @@
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
+
11
+ export interface WidgetDeps {
12
+ getTeammates(): Map<string, TeammateRpc>;
13
+ getTracker(): ActivityTracker;
14
+ getTasks(): TeamTask[];
15
+ getTeamConfig(): TeamConfig | null;
16
+ getStyle(): TeamsStyle;
17
+ isDelegateMode(): boolean;
18
+ }
19
+
20
+ export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
21
+
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
+ interface WidgetRow {
74
+ icon: string; // raw char (before styling)
75
+ iconColor: ThemeColor;
76
+ displayName: string;
77
+ statusKey: TeammateStatus;
78
+ pending: number;
79
+ completed: number;
80
+ tokensStr: string; // "\u2014" for chairman
81
+ activityText: string;
82
+ }
83
+
84
+ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
85
+ return (_tui: TUI, theme: Theme): Component => {
86
+ return {
87
+ render(width: number): string[] {
88
+ const teammates = deps.getTeammates();
89
+ const tracker = deps.getTracker();
90
+ const tasks = deps.getTasks();
91
+ const teamConfig = deps.getTeamConfig();
92
+ const style = deps.getStyle();
93
+ const strings = getTeamsStrings(style);
94
+ const delegateMode = deps.isDelegateMode();
95
+
96
+ // Hide when no active team state
97
+ const hasOnlineMembers = (teamConfig?.members ?? []).some(
98
+ (m) => m.role === "worker" && m.status === "online",
99
+ );
100
+ if (teammates.size === 0 && tasks.length === 0 && !hasOnlineMembers) {
101
+ return [];
102
+ }
103
+
104
+ const lines: string[] = [];
105
+
106
+ // ── Header line ──
107
+ let header = " " + theme.bold(theme.fg("accent", "Teams"));
108
+ if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
109
+ lines.push(truncateToWidth(header, width));
110
+
111
+ // ── Build row data ──
112
+ const cfgMembers = teamConfig?.members ?? [];
113
+ const cfgByName = new Map<string, TeamMember>();
114
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
115
+
116
+ const rows: WidgetRow[] = [];
117
+
118
+ // Chairman row (always first when team is active)
119
+ const leadName = teamConfig?.leadName;
120
+ if (leadName) {
121
+ const leadTasks = tasks.filter((t) => t.owner === leadName);
122
+ rows.push({
123
+ icon: "\u25c6",
124
+ iconColor: "accent",
125
+ displayName: strings.leaderTitle,
126
+ statusKey: "idle",
127
+ pending: leadTasks.filter((t) => t.status === "pending").length,
128
+ completed: leadTasks.filter((t) => t.status === "completed").length,
129
+ tokensStr: "\u2014",
130
+ activityText: "",
131
+ });
132
+ }
133
+
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) {
145
+ lines.push(
146
+ truncateToWidth(
147
+ " " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
148
+ width,
149
+ ),
150
+ );
151
+ } else {
152
+ const sortedNames = Array.from(visibleNames).sort();
153
+ for (const name of sortedNames) {
154
+ const rpc = teammates.get(name);
155
+ const cfg = cfgByName.get(name);
156
+ const statusKey = resolveStatus(rpc, cfg);
157
+ const activity = tracker.get(name);
158
+ const owned = tasks.filter((t) => t.owner === name);
159
+
160
+ rows.push({
161
+ icon: STATUS_ICON[statusKey],
162
+ iconColor: STATUS_COLOR[statusKey],
163
+ displayName: formatMemberDisplayName(style, name),
164
+ statusKey,
165
+ pending: owned.filter((t) => t.status === "pending").length,
166
+ completed: owned.filter((t) => t.status === "completed").length,
167
+ tokensStr: formatTokens(activity.totalTokens),
168
+ activityText: toolActivity(activity.currentToolName),
169
+ });
170
+ }
171
+
172
+ // ── Compute column widths ──
173
+ const totalPending = tasks.filter((t) => t.status === "pending").length;
174
+ const totalCompleted = tasks.filter((t) => t.status === "completed").length;
175
+ let totalTokensRaw = 0;
176
+ for (const name of sortedNames) totalTokensRaw += tracker.get(name).totalTokens;
177
+ const totalTokensStr = formatTokens(totalTokensRaw);
178
+
179
+ const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
180
+ const pW = Math.max(...rows.map((r) => String(r.pending).length), String(totalPending).length);
181
+ const cW = Math.max(...rows.map((r) => String(r.completed).length), String(totalCompleted).length);
182
+ const tokW = Math.max(...rows.map((r) => r.tokensStr.length), totalTokensStr.length);
183
+
184
+ // ── Render rows ──
185
+ for (const r of rows) {
186
+ const icon = theme.fg(r.iconColor, r.icon);
187
+ const styledName = theme.bold(r.displayName);
188
+ const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
189
+ const pNum = String(r.pending).padStart(pW);
190
+ const cNum = String(r.completed).padStart(cW);
191
+ const tokStr = r.tokensStr.padStart(tokW);
192
+ const cols = theme.fg(
193
+ "dim",
194
+ ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
195
+ );
196
+ const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
197
+
198
+ const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
199
+ lines.push(truncateToWidth(row, width));
200
+ }
201
+
202
+ // ── Total row ──
203
+ const leftWidth = nameColWidth + 13;
204
+ const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
205
+ lines.push(truncateToWidth(sepLine, width));
206
+
207
+ const totalLabel = theme.bold("Total");
208
+ const totalTaskCount = totalPending + totalCompleted;
209
+ const pct = totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
210
+ const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
211
+ const tpNum = String(totalPending).padStart(pW);
212
+ const tcNum = String(totalCompleted).padStart(cW);
213
+ const ttokStr = totalTokensStr.padStart(tokW);
214
+ const totalSuffix = theme.fg(
215
+ "muted",
216
+ ` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
217
+ );
218
+ // nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
219
+ const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
220
+ lines.push(truncateToWidth(totalRow, width));
221
+ }
222
+
223
+ // ── Hints line ──
224
+ const hints = theme.fg(
225
+ "dim",
226
+ " /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
227
+ );
228
+ lines.push(truncateToWidth(hints, width));
229
+
230
+ return lines;
231
+ },
232
+ invalidate() {},
233
+ };
234
+ };
235
+ }
@@ -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, formatMemberDisplayName, 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;