pi-teams 0.5.2 → 0.7.2

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 CHANGED
@@ -23,6 +23,9 @@ pi install npm:pi-teams
23
23
  - **Spawn Specialists**: Create agents like "Security Expert" or "Frontend Pro" to handle sub-tasks in parallel.
24
24
  - **Shared Task Board**: Keep everyone on the same page with a persistent list of tasks and their status.
25
25
  - **Agent Messaging**: Agents can send direct messages to each other and to you (the Team Lead) to report progress.
26
+ - **Broadcast Messaging**: Send a message to the entire team at once for global coordination.
27
+ - **Plan Approval Mode**: Require teammates to submit their implementation plans for lead approval before they touch any code.
28
+ - **Quality Gate Hooks**: Automated shell scripts can run when tasks are completed (e.g., to run tests or linting).
26
29
  - **Autonomous Work**: Teammates automatically "wake up," read their instructions, and poll their inboxes for new work while idle.
27
30
  - **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
28
31
 
@@ -44,14 +47,44 @@ You don't need to learn complex code commands. Just talk to Pi in plain English!
44
47
  **Pro Tip:** You can specify a different model for a specific teammate!
45
48
  > **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
46
49
 
50
+ **New: Plan Approval Mode**
51
+ > **You:** "Spawn a teammate named 'refactor-bot' and require plan approval before they make any changes."
52
+
53
+ **Customize Teammate Model & Thinking**
54
+ > **You:** "Spawn a teammate named 'architect-bot' using 'gpt-4o' with 'high' thinking level for deep reasoning."
55
+ > **You:** "Spawn a teammate named 'frontend-dev' using 'haiku' with 'low' thinking level for quick iterations."
56
+ > **You:** "Spawn a teammate named 'code-reviewer' using 'gpt-4o' with 'medium' thinking level."
57
+
58
+ You can customize **both** the model and thinking level for each teammate independently:
59
+ - **Model**: Override team's default model for a specific teammate (e.g., `gpt-4o`, `haiku`, `glm-4.7`)
60
+ - **Thinking Level**: Balance speed vs. depth per teammate:
61
+ - `off`: No thinking blocks (fastest)
62
+ - `minimal`: Minimal reasoning overhead
63
+ - `low`: Light reasoning for quick decisions
64
+ - `medium`: Balanced reasoning (default for most work)
65
+ - `high`: Extended reasoning for complex problems
66
+
67
+ This lets you build teams with varied capabilities—fast, lightweight teammates for simple tasks, and powerful, thoughtful teammates for complex work.
68
+
47
69
  ### 3. Assign a Task
48
70
  > **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
49
71
 
50
- ### 4. Send a Message
51
- > **You:** "Tell security-bot to focus on the 'config/' directory first."
52
- > *The lead agent uses `send_message` to put an instruction in the teammate's inbox.*
72
+ ### 4. Submit and Evaluate a Plan
73
+ Teammates in `planning` mode will use `task_submit_plan`. As the lead, you can then:
74
+ > **You:** "Review refactor-bot's plan for task 5. If it looks good, approve it. If not, reject it with feedback on the test coverage."
75
+
76
+ ### 5. Broadcast a Message
77
+ > **You:** "Broadcast to the entire team: 'The API endpoint has changed to /v2. Please update your work accordingly.'"
53
78
 
54
- ### 5. Inter-Agent Communication
79
+ ### 6. Automated Hooks
80
+ Add a script at `.pi/team-hooks/task_completed.sh` to run automated checks when any task is finished.
81
+ ```bash
82
+ #!/bin/bash
83
+ # Example: Run tests when a task is completed
84
+ npm test
85
+ ```
86
+
87
+ ### 7. Inter-Agent Communication
55
88
  > Teammates can also talk to each other! For example, a `frontend-bot` can message a `backend-bot` to coordinate on an API schema without your intervention.
56
89
 
57
90
  ### 6. Check on Progress
@@ -72,7 +105,10 @@ Pi automatically uses these tools when you give instructions like the examples a
72
105
  - `read_config`: Get details about the team and its members.
73
106
 
74
107
  ### Teammates
75
- - `spawn_teammate`: Launch a new agent into a `tmux` pane with a role and instructions. (Optional: `model`)
108
+ - `spawn_teammate`: Launch a new agent into a `tmux` pane with a role and instructions. (Optional: `model`, `thinking`, `plan_mode_required`)
109
+ - **`model`**: Specify which AI model this teammate should use (e.g., `gpt-4o`, `haiku`, `glm-4.7`, `glm-5`). If not specified, uses the team's default model. You can mix different models across teammates for cost/performance optimization.
110
+ - **`thinking`**: Set the agent's thinking level (`off`, `minimal`, `low`, `medium`, `high`). This controls how much time the agent spends reasoning before responding. If not specified, inherited from team/global settings. Different teammates can have different thinking levels.
111
+ - **`plan_mode_required`**: If true, teammate must submit plans for lead approval before making code changes
76
112
  - `check_teammate`: See if a teammate is still running or has unread messages.
77
113
  - `force_kill_teammate`: Stop a teammate and remove them from the team.
78
114
  - `process_shutdown_approved`: Orderly shutdown for a finished teammate.
@@ -81,18 +117,24 @@ Pi automatically uses these tools when you give instructions like the examples a
81
117
  - `task_create`: Create a new task.
82
118
  - `task_list`: List all tasks and their current status.
83
119
  - `task_get`: Get full details of a specific task.
84
- - `task_update`: Update a task's status or owner.
120
+ - `task_update`: Update a task's status (pending, planning, in_progress, etc.) or owner.
85
121
 
86
122
  ### Messaging
87
123
  - `send_message`: Send a message to a teammate or lead.
124
+ - `broadcast_message`: Send a message to the entire team.
88
125
  - `read_inbox`: Read incoming messages for an agent.
89
126
 
127
+ ### Task Planning & Approval
128
+ - `task_submit_plan`: For teammates to submit their implementation plans.
129
+ - `task_evaluate_plan`: For the lead to approve or reject a plan (with feedback).
130
+
90
131
  ---
91
132
 
92
133
  ## 🤖 Automated Behavior
93
134
 
94
135
  - **Initial Greeting**: When a teammate is spawned, they will automatically send a message saying they've started and are checking their inbox.
95
136
  - **Idle Polling**: Teammates check for new messages every 30 seconds if they are idle.
137
+ - **Automated Hooks**: If `.pi/team-hooks/task_completed.sh` exists, it will automatically execute whenever a task status is changed to `completed`.
96
138
  - **Context Injection**: Each teammate is given a custom system prompt that defines their role and instructions for the team environment.
97
139
 
98
140
  ---
@@ -60,8 +60,27 @@ export default function (pi: ExtensionAPI) {
60
60
  pi.on("before_agent_start", async (event, ctx) => {
61
61
  if (isTeammate && firstTurn) {
62
62
  firstTurn = false;
63
+
64
+ // Get the teammate's model and thinking level from team config for accurate reporting
65
+ let modelInfo = "";
66
+ if (teamName) {
67
+ try {
68
+ const teamConfig = await teams.readConfig(teamName);
69
+ const member = teamConfig.members.find(m => m.name === agentName);
70
+ if (member && member.model) {
71
+ modelInfo = `\nYou are currently using model: ${member.model}`;
72
+ if (member.thinking) {
73
+ modelInfo += ` with thinking level: ${member.thinking}`;
74
+ }
75
+ modelInfo += `. When reporting your model or thinking level, use these exact values.`;
76
+ }
77
+ } catch (e) {
78
+ // If config can't be read, that's okay - proceed without model info
79
+ }
80
+ }
81
+
63
82
  return {
64
- systemPrompt: event.systemPrompt + `\n\nYou are teammate '${agentName}' on team '${teamName}'.\nYour lead is 'team-lead'.\nStart by calling read_inbox(team_name="${teamName}") to get your initial instructions.`,
83
+ systemPrompt: event.systemPrompt + `\n\nYou are teammate '${agentName}' on team '${teamName}'.\nYour lead is 'team-lead'.${modelInfo}\nStart by calling read_inbox(team_name="${teamName}") to get your initial instructions.`,
65
84
  };
66
85
  }
67
86
  });
@@ -119,7 +138,7 @@ end tell`;
119
138
  description: Type.Optional(Type.String()),
120
139
  default_model: Type.Optional(Type.String()),
121
140
  }),
122
- async execute(toolCallId, params, signal, onUpdate, ctx) {
141
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
123
142
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model);
124
143
  return {
125
144
  content: [{ type: "text", text: `Team ${params.team_name} created.` }],
@@ -138,8 +157,10 @@ end tell`;
138
157
  prompt: Type.String(),
139
158
  cwd: Type.String(),
140
159
  model: Type.Optional(Type.String()),
160
+ thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high"])),
161
+ plan_mode_required: Type.Optional(Type.Boolean({ default: false })),
141
162
  }),
142
- async execute(toolCallId, params, signal, onUpdate, ctx) {
163
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
143
164
  const safeName = paths.sanitizeName(params.name);
144
165
  const safeTeamName = paths.sanitizeName(params.team_name);
145
166
 
@@ -161,14 +182,48 @@ end tell`;
161
182
  subscriptions: [],
162
183
  prompt: params.prompt,
163
184
  color: "blue",
185
+ thinking: params.thinking,
186
+ planModeRequired: params.plan_mode_required,
164
187
  };
165
188
 
166
189
  await teams.addMember(safeTeamName, member);
167
190
  await messaging.sendPlainMessage(safeTeamName, "team-lead", safeName, params.prompt, "Initial prompt");
168
191
 
169
192
  const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi"; // Assumed on path
170
- const piCmd = piBinary;
171
-
193
+ let piCmd = piBinary;
194
+
195
+ // Build model command with thinking level if specified
196
+ if (chosenModel) {
197
+ // If model doesn't include provider prefix (provider/model), use the team's defaultModel or fallback
198
+ let modelWithProvider = chosenModel;
199
+ if (!chosenModel.includes('/')) {
200
+ // Check if team has a defaultModel with a provider prefix
201
+ if (teamConfig.defaultModel && teamConfig.defaultModel.includes('/')) {
202
+ const [provider] = teamConfig.defaultModel.split('/');
203
+ modelWithProvider = `${provider}/${chosenModel}`;
204
+ } else {
205
+ // Use zai as default provider for glm models (matching user's pi settings)
206
+ if (chosenModel.startsWith('glm-')) {
207
+ modelWithProvider = `zai/${chosenModel}`;
208
+ }
209
+ }
210
+ }
211
+
212
+ if (params.thinking) {
213
+ piCmd = `${piBinary} --model ${modelWithProvider}:${params.thinking}`;
214
+ } else {
215
+ piCmd = `${piBinary} --model ${modelWithProvider}`;
216
+ }
217
+ } else if (params.thinking) {
218
+ piCmd = `${piBinary} --thinking ${params.thinking}`;
219
+ }
220
+
221
+ const env: Record<string, string> = {
222
+ ...process.env,
223
+ PI_TEAM_NAME: safeTeamName,
224
+ PI_AGENT_NAME: safeName,
225
+ };
226
+
172
227
  let paneId = "";
173
228
  try {
174
229
  if (process.env.ZELLIJ && !process.env.TMUX) {
@@ -178,13 +233,17 @@ end tell`;
178
233
  "--cwd", params.cwd,
179
234
  "--close-on-exit",
180
235
  "--",
181
- "env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
236
+ "env", ...Object.entries(env).filter(([k]) => k.startsWith("PI_")).map(([k, v]) => `${k}=${v}`),
182
237
  "sh", "-c", piCmd
183
238
  ];
184
239
  spawnSync("zellij", zellijArgs);
185
240
  paneId = `zellij_${safeName}`;
186
241
  } else if (process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ) {
187
- const itermCmd = `cd '${params.cwd}' && PI_TEAM_NAME=${safeTeamName} PI_AGENT_NAME=${safeName} ${piCmd}`;
242
+ const envStr = Object.entries(env)
243
+ .filter(([k]) => k.startsWith("PI_"))
244
+ .map(([k, v]) => `${k}=${v}`)
245
+ .join(" ");
246
+ const itermCmd = `cd '${params.cwd}' && ${envStr} ${piCmd}`;
188
247
  const teammates = teamConfig.members.filter(m => m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"));
189
248
  const lastTeammate = teammates.length > 0 ? teammates[teammates.length - 1] : null;
190
249
 
@@ -225,12 +284,15 @@ end tell`;
225
284
  if (result.status !== 0) throw new Error(`osascript failed with status ${result.status}: ${result.stderr.toString()}`);
226
285
  paneId = `iterm_${result.stdout.toString().trim()}`;
227
286
  } else {
287
+ const envArgs = Object.entries(env)
288
+ .filter(([k]) => k.startsWith("PI_"))
289
+ .map(([k, v]) => `${k}=${v}`);
228
290
  const tmuxArgs = [
229
291
  "split-window",
230
292
  "-h", "-dP",
231
293
  "-F", "#{pane_id}",
232
294
  "-c", params.cwd,
233
- "env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
295
+ "env", ...envArgs,
234
296
  "sh", "-c", piCmd
235
297
  ];
236
298
  const result = spawnSync("tmux", tmuxArgs);
@@ -264,10 +326,30 @@ end tell`;
264
326
  content: Type.String(),
265
327
  summary: Type.String(),
266
328
  }),
267
- async execute(toolCallId, params, signal, onUpdate, ctx) {
329
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
268
330
  await messaging.sendPlainMessage(params.team_name, agentName, params.recipient, params.content, params.summary);
269
331
  return {
270
332
  content: [{ type: "text", text: `Message sent to ${params.recipient}.` }],
333
+ details: {},
334
+ };
335
+ },
336
+ });
337
+
338
+ pi.registerTool({
339
+ name: "broadcast_message",
340
+ label: "Broadcast Message",
341
+ description: "Broadcast a message to all team members except the sender.",
342
+ parameters: Type.Object({
343
+ team_name: Type.String(),
344
+ content: Type.String(),
345
+ summary: Type.String(),
346
+ color: Type.Optional(Type.String()),
347
+ }),
348
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
349
+ await messaging.broadcastMessage(params.team_name, agentName, params.content, params.summary, params.color);
350
+ return {
351
+ content: [{ type: "text", text: `Message broadcasted to all team members.` }],
352
+ details: {},
271
353
  };
272
354
  },
273
355
  });
@@ -309,6 +391,43 @@ end tell`;
309
391
  },
310
392
  });
311
393
 
394
+ pi.registerTool({
395
+ name: "task_submit_plan",
396
+ label: "Submit Plan",
397
+ description: "Submit a plan for a task, updating its status to 'planning'.",
398
+ parameters: Type.Object({
399
+ team_name: Type.String(),
400
+ task_id: Type.String(),
401
+ plan: Type.String(),
402
+ }),
403
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
404
+ const updated = await tasks.submitPlan(params.team_name, params.task_id, params.plan);
405
+ return {
406
+ content: [{ type: "text", text: `Plan submitted for task ${params.task_id}.` }],
407
+ details: { task: updated },
408
+ };
409
+ },
410
+ });
411
+
412
+ pi.registerTool({
413
+ name: "task_evaluate_plan",
414
+ label: "Evaluate Plan",
415
+ description: "Evaluate a submitted plan for a task.",
416
+ parameters: Type.Object({
417
+ team_name: Type.String(),
418
+ task_id: Type.String(),
419
+ action: StringEnum(["approve", "reject"]),
420
+ feedback: Type.Optional(Type.String({ description: "Required for rejection" })),
421
+ }),
422
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
423
+ const updated = await tasks.evaluatePlan(params.team_name, params.task_id, params.action as any, params.feedback);
424
+ return {
425
+ content: [{ type: "text", text: `Plan for task ${params.task_id} has been ${params.action}d.` }],
426
+ details: { task: updated },
427
+ };
428
+ },
429
+ });
430
+
312
431
  pi.registerTool({
313
432
  name: "task_list",
314
433
  label: "List Tasks",
@@ -332,10 +451,10 @@ end tell`;
332
451
  parameters: Type.Object({
333
452
  team_name: Type.String(),
334
453
  task_id: Type.String(),
335
- status: Type.Optional(StringEnum(["pending", "in_progress", "completed", "deleted"])),
454
+ status: Type.Optional(StringEnum(["pending", "planning", "in_progress", "completed", "deleted"])),
336
455
  owner: Type.Optional(Type.String()),
337
456
  }),
338
- async execute(toolCallId, params, signal, onUpdate, ctx) {
457
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
339
458
  const updated = await tasks.updateTask(params.team_name, params.task_id, {
340
459
  status: params.status as any,
341
460
  owner: params.owner,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.5.2",
3
+ "version": "0.7.2",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": "github:burggraf/pi-teams",
6
6
  "author": "Mark Burggraf",
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { runHook } from "./hooks";
4
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
5
+
6
+ describe("runHook", () => {
7
+ const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
8
+
9
+ beforeAll(() => {
10
+ if (!fs.existsSync(hooksDir)) {
11
+ fs.mkdirSync(hooksDir, { recursive: true });
12
+ }
13
+ });
14
+
15
+ afterAll(() => {
16
+ // Optional: Clean up created scripts
17
+ const files = ["success_hook.sh", "fail_hook.sh"];
18
+ files.forEach(f => {
19
+ const p = path.join(hooksDir, f);
20
+ if (fs.existsSync(p)) fs.unlinkSync(p);
21
+ });
22
+ });
23
+
24
+ it("should return true if hook script does not exist", async () => {
25
+ const result = await runHook("test_team", "non_existent_hook", { data: "test" });
26
+ expect(result).toBe(true);
27
+ });
28
+
29
+ it("should return true if hook script succeeds", async () => {
30
+ const hookName = "success_hook";
31
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
32
+
33
+ // Create a simple script that exits with 0
34
+ fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
35
+
36
+ const result = await runHook("test_team", hookName, { data: "test" });
37
+ expect(result).toBe(true);
38
+ });
39
+
40
+ it("should return false if hook script fails", async () => {
41
+ const hookName = "fail_hook";
42
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
43
+
44
+ // Create a simple script that exits with 1
45
+ fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
46
+
47
+ // Mock console.error to avoid noise in test output
48
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
49
+
50
+ const result = await runHook("test_team", hookName, { data: "test" });
51
+ expect(result).toBe(false);
52
+
53
+ consoleSpy.mockRestore();
54
+ });
55
+
56
+ it("should pass the payload to the hook script", async () => {
57
+ const hookName = "payload_hook";
58
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
59
+ const outputFile = path.join(hooksDir, "payload_output.txt");
60
+
61
+ // Create a script that writes its first argument to a file
62
+ fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 });
63
+
64
+ const payload = { key: "value", "special'char": true };
65
+ const result = await runHook("test_team", hookName, payload);
66
+
67
+ expect(result).toBe(true);
68
+ const output = fs.readFileSync(outputFile, "utf-8").trim();
69
+ expect(JSON.parse(output)).toEqual(payload);
70
+
71
+ // Clean up
72
+ fs.unlinkSync(scriptPath);
73
+ if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
74
+ });
75
+ });
@@ -0,0 +1,35 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Runs a hook script asynchronously if it exists.
10
+ * Hooks are located in .pi/team-hooks/{hookName}.sh relative to the CWD.
11
+ *
12
+ * @param teamName The name of the team.
13
+ * @param hookName The name of the hook to run (e.g., 'task_completed').
14
+ * @param payload The payload to pass to the hook script as the first argument.
15
+ * @returns true if the hook doesn't exist or executes successfully; false otherwise.
16
+ */
17
+ export async function runHook(teamName: string, hookName: string, payload: any): Promise<boolean> {
18
+ const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
19
+
20
+ if (!fs.existsSync(hookPath)) {
21
+ return true;
22
+ }
23
+
24
+ try {
25
+ const payloadStr = JSON.stringify(payload);
26
+ // Use execFile: More secure (no shell interpolation) and asynchronous
27
+ await execFileAsync(hookPath, [payloadStr], {
28
+ env: { ...process.env, PI_TEAM: teamName },
29
+ });
30
+ return true;
31
+ } catch (error) {
32
+ console.error(`Hook ${hookName} failed:`, error);
33
+ return false;
34
+ }
35
+ }
@@ -1,3 +1,4 @@
1
+ // Project: pi-teams
1
2
  import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
package/src/utils/lock.ts CHANGED
@@ -1,10 +1,12 @@
1
+ // Project: pi-teams
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
 
5
+ const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
6
+ const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
7
+
4
8
  export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retries: number = 50): Promise<T> {
5
9
  const lockFile = `${lockPath}.lock`;
6
- const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
7
- const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
8
10
 
9
11
  while (retries > 0) {
10
12
  try {
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- import { appendMessage, readInbox, sendPlainMessage } from "./messaging";
5
+ import { appendMessage, readInbox, sendPlainMessage, broadcastMessage } from "./messaging";
6
6
  import * as paths from "./paths";
7
7
 
8
8
  // Mock the paths to use a temporary directory
@@ -18,6 +18,9 @@ describe("Messaging Utilities", () => {
18
18
  return path.join(testDir, "inboxes", `${agentName}.json`);
19
19
  });
20
20
  vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
21
+ vi.spyOn(paths, "configPath").mockImplementation((teamName) => {
22
+ return path.join(testDir, "config.json");
23
+ });
21
24
  });
22
25
 
23
26
  afterEach(() => {
@@ -66,4 +69,36 @@ describe("Messaging Utilities", () => {
66
69
  expect(all.length).toBe(2);
67
70
  expect(all.every(m => m.read)).toBe(true);
68
71
  });
72
+
73
+ it("should broadcast message to all members except the sender", async () => {
74
+ // Setup team config
75
+ const config = {
76
+ name: "test-team",
77
+ members: [
78
+ { name: "sender" },
79
+ { name: "member1" },
80
+ { name: "member2" }
81
+ ]
82
+ };
83
+ const configFilePath = path.join(testDir, "config.json");
84
+ fs.writeFileSync(configFilePath, JSON.stringify(config));
85
+
86
+ await broadcastMessage("test-team", "sender", "broadcast text", "summary");
87
+
88
+ // Check member1's inbox
89
+ const inbox1 = await readInbox("test-team", "member1", false, false);
90
+ expect(inbox1.length).toBe(1);
91
+ expect(inbox1[0].text).toBe("broadcast text");
92
+ expect(inbox1[0].from).toBe("sender");
93
+
94
+ // Check member2's inbox
95
+ const inbox2 = await readInbox("test-team", "member2", false, false);
96
+ expect(inbox2.length).toBe(1);
97
+ expect(inbox2[0].text).toBe("broadcast text");
98
+ expect(inbox2[0].from).toBe("sender");
99
+
100
+ // Check sender's inbox (should be empty)
101
+ const inboxSender = await readInbox("test-team", "sender", false, false);
102
+ expect(inboxSender.length).toBe(0);
103
+ });
69
104
  });
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { InboxMessage } from "./models";
4
4
  import { withLock } from "./lock";
5
5
  import { inboxPath } from "./paths";
6
+ import { readConfig } from "./teams";
6
7
 
7
8
  export function nowIso(): string {
8
9
  return new Date().toISOString();
@@ -71,3 +72,37 @@ export async function sendPlainMessage(
71
72
  };
72
73
  await appendMessage(teamName, toName, msg);
73
74
  }
75
+
76
+ /**
77
+ * Broadcasts a message to all team members except the sender.
78
+ * @param teamName The name of the team
79
+ * @param fromName The name of the sender
80
+ * @param text The message text
81
+ * @param summary A short summary of the message
82
+ * @param color An optional color for the message
83
+ */
84
+ export async function broadcastMessage(
85
+ teamName: string,
86
+ fromName: string,
87
+ text: string,
88
+ summary: string,
89
+ color?: string
90
+ ) {
91
+ const config = await readConfig(teamName);
92
+
93
+ // Create an array of delivery promises for all members except the sender
94
+ const deliveryPromises = config.members
95
+ .filter((member) => member.name !== fromName)
96
+ .map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color));
97
+
98
+ // Execute deliveries in parallel and wait for all to settle
99
+ const results = await Promise.allSettled(deliveryPromises);
100
+
101
+ // Log failures for diagnostics
102
+ const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
103
+ if (failures.length > 0) {
104
+ console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`);
105
+ // Optionally log individual errors
106
+ failures.forEach((f) => console.error(`- Delivery error:`, f.reason));
107
+ }
108
+ }
@@ -9,6 +9,7 @@ export interface Member {
9
9
  subscriptions: any[];
10
10
  prompt?: string;
11
11
  color?: string;
12
+ thinking?: "off" | "minimal" | "low" | "medium" | "high";
12
13
  planModeRequired?: boolean;
13
14
  backendType?: string;
14
15
  isActive?: boolean;
@@ -29,7 +30,9 @@ export interface TaskFile {
29
30
  subject: string;
30
31
  description: string;
31
32
  activeForm?: string;
32
- status: "pending" | "in_progress" | "completed" | "deleted";
33
+ status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
34
+ plan?: string;
35
+ planFeedback?: string;
33
36
  blocks: string[];
34
37
  blockedBy: string[];
35
38
  owner?: string;
@@ -1,8 +1,9 @@
1
+ // Project: pi-teams
1
2
  import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import os from "node:os";
5
- import { createTask, updateTask, readTask, listTasks } from "./tasks";
6
+ import { createTask, updateTask, readTask, listTasks, submitPlan, evaluatePlan } from "./tasks";
6
7
  import * as paths from "./paths";
7
8
  import * as teams from "./teams";
8
9
 
@@ -43,6 +44,24 @@ describe("Tasks Utilities", () => {
43
44
  expect(taskData.status).toBe("in_progress");
44
45
  });
45
46
 
47
+ it("should submit a plan successfully", async () => {
48
+ const task = await createTask("test-team", "Test Subject", "Test Description");
49
+ const plan = "Step 1: Do something\nStep 2: Profit";
50
+ const updated = await submitPlan("test-team", task.id, plan);
51
+ expect(updated.status).toBe("planning");
52
+ expect(updated.plan).toBe(plan);
53
+
54
+ const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"));
55
+ expect(taskData.status).toBe("planning");
56
+ expect(taskData.plan).toBe(plan);
57
+ });
58
+
59
+ it("should fail to submit an empty plan", async () => {
60
+ const task = await createTask("test-team", "Empty Test", "Should fail");
61
+ await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty");
62
+ await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty");
63
+ });
64
+
46
65
  it("should list tasks", async () => {
47
66
  await createTask("test-team", "Task 1", "Desc 1");
48
67
  await createTask("test-team", "Task 2", "Desc 2");
@@ -74,4 +93,50 @@ describe("Tasks Utilities", () => {
74
93
 
75
94
  fs.unlinkSync(commonLockFile);
76
95
  });
96
+
97
+ it("should approve a plan successfully", async () => {
98
+ const task = await createTask("test-team", "Plan Test", "Should be approved");
99
+ await submitPlan("test-team", task.id, "Wait for it...");
100
+
101
+ const approved = await evaluatePlan("test-team", task.id, "approve");
102
+ expect(approved.status).toBe("in_progress");
103
+ expect(approved.planFeedback).toBe("");
104
+ });
105
+
106
+ it("should reject a plan with feedback", async () => {
107
+ const task = await createTask("test-team", "Plan Test", "Should be rejected");
108
+ await submitPlan("test-team", task.id, "Wait for it...");
109
+
110
+ const feedback = "Not good enough!";
111
+ const rejected = await evaluatePlan("test-team", task.id, "reject", feedback);
112
+ expect(rejected.status).toBe("planning");
113
+ expect(rejected.planFeedback).toBe(feedback);
114
+ });
115
+
116
+ it("should fail to evaluate a task not in 'planning' status", async () => {
117
+ const task = await createTask("test-team", "Status Test", "Invalid status for eval");
118
+ // status is "pending"
119
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status");
120
+ });
121
+
122
+ it("should fail to evaluate a task without a plan", async () => {
123
+ const task = await createTask("test-team", "Plan Missing Test", "No plan submitted");
124
+ await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
125
+ await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted");
126
+ });
127
+
128
+ it("should fail to reject a plan without feedback", async () => {
129
+ const task = await createTask("test-team", "Feedback Test", "Should require feedback");
130
+ await submitPlan("test-team", task.id, "My plan");
131
+ await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow("Feedback is required when rejecting a plan");
132
+ await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow("Feedback is required when rejecting a plan");
133
+ });
134
+
135
+ it("should sanitize task IDs in all file operations", async () => {
136
+ const dirtyId = "../evil-id";
137
+ // sanitizeName should throw on this dirtyId
138
+ await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/);
139
+ await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow(/Invalid name: "..\/evil-id"/);
140
+ await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
141
+ });
77
142
  });
@@ -1,9 +1,11 @@
1
+ // Project: pi-teams
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { TaskFile } from "./models";
4
5
  import { taskDir, sanitizeName } from "./paths";
5
6
  import { teamExists } from "./teams";
6
7
  import { withLock } from "./lock";
8
+ import { runHook } from "./hooks";
7
9
 
8
10
  export function getTaskId(teamName: string): string {
9
11
  const dir = taskDir(teamName);
@@ -12,6 +14,12 @@ export function getTaskId(teamName: string): string {
12
14
  return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
13
15
  }
14
16
 
17
+ function getTaskPath(teamName: string, taskId: string): string {
18
+ const dir = taskDir(teamName);
19
+ const safeTaskId = sanitizeName(taskId);
20
+ return path.join(dir, `${safeTaskId}.json`);
21
+ }
22
+
15
23
  export async function createTask(
16
24
  teamName: string,
17
25
  subject: string,
@@ -48,9 +56,7 @@ export async function updateTask(
48
56
  updates: Partial<TaskFile>,
49
57
  retries?: number
50
58
  ): Promise<TaskFile> {
51
- const dir = taskDir(teamName);
52
- const safeTaskId = sanitizeName(taskId);
53
- const p = path.join(dir, `${safeTaskId}.json`);
59
+ const p = getTaskPath(teamName, taskId);
54
60
 
55
61
  return await withLock(p, async () => {
56
62
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
@@ -62,15 +68,81 @@ export async function updateTask(
62
68
  return updated;
63
69
  }
64
70
 
71
+ fs.writeFileSync(p, JSON.stringify(updated, null, 2));
72
+
73
+ if (updates.status === "completed") {
74
+ await runHook(teamName, "task_completed", updated);
75
+ }
76
+
77
+ return updated;
78
+ }, retries);
79
+ }
80
+
81
+ /**
82
+ * Submits a plan for a task, updating its status to "planning".
83
+ * @param teamName The name of the team
84
+ * @param taskId The ID of the task
85
+ * @param plan The content of the plan
86
+ * @returns The updated task
87
+ */
88
+ export async function submitPlan(teamName: string, taskId: string, plan: string): Promise<TaskFile> {
89
+ if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
90
+ return await updateTask(teamName, taskId, { status: "planning", plan });
91
+ }
92
+
93
+ /**
94
+ * Evaluates a submitted plan for a task.
95
+ * @param teamName The name of the team
96
+ * @param taskId The ID of the task
97
+ * @param action The evaluation action: "approve" or "reject"
98
+ * @param feedback Optional feedback for the evaluation (required for rejection)
99
+ * @param retries Number of times to retry acquiring the lock
100
+ * @returns The updated task
101
+ */
102
+ export async function evaluatePlan(
103
+ teamName: string,
104
+ taskId: string,
105
+ action: "approve" | "reject",
106
+ feedback?: string,
107
+ retries?: number
108
+ ): Promise<TaskFile> {
109
+ const p = getTaskPath(teamName, taskId);
110
+
111
+ return await withLock(p, async () => {
112
+ if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
113
+ const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
114
+
115
+ // 1. Validate state: Only "planning" tasks can be evaluated
116
+ if (task.status !== "planning") {
117
+ throw new Error(
118
+ `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
119
+ `Tasks must be in 'planning' status to be evaluated.`
120
+ );
121
+ }
122
+
123
+ // 2. Validate plan presence
124
+ if (!task.plan || !task.plan.trim()) {
125
+ throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`);
126
+ }
127
+
128
+ // 3. Require feedback for rejections
129
+ if (action === "reject" && (!feedback || !feedback.trim())) {
130
+ throw new Error("Feedback is required when rejecting a plan.");
131
+ }
132
+
133
+ // 4. Perform update
134
+ const updates: Partial<TaskFile> = action === "approve"
135
+ ? { status: "in_progress", planFeedback: "" }
136
+ : { status: "planning", planFeedback: feedback };
137
+
138
+ const updated = { ...task, ...updates };
65
139
  fs.writeFileSync(p, JSON.stringify(updated, null, 2));
66
140
  return updated;
67
141
  }, retries);
68
142
  }
69
143
 
70
144
  export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
71
- const dir = taskDir(teamName);
72
- const safeTaskId = sanitizeName(taskId);
73
- const p = path.join(dir, `${safeTaskId}.json`);
145
+ const p = getTaskPath(teamName, taskId);
74
146
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
75
147
  return await withLock(p, async () => {
76
148
  return JSON.parse(fs.readFileSync(p, "utf-8"));