pi-teams 0.5.0 → 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 +53 -7
- package/extensions/index.ts +130 -11
- package/package.json +1 -1
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lock.race.test.ts +45 -0
- package/src/utils/lock.test.ts +3 -11
- package/src/utils/lock.ts +5 -4
- package/src/utils/messaging.test.ts +36 -1
- package/src/utils/messaging.ts +35 -0
- package/src/utils/models.ts +4 -1
- package/src/utils/security.test.ts +7 -6
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +69 -11
- package/src/utils/tasks.ts +83 -10
package/README.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
**pi-teams** turns your single Pi agent into a coordinated software engineering team. It allows you to spawn multiple "Teammate" agents in separate terminal panes that work autonomously, communicate with each other, and manage a shared task board—all mediated through **tmux**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### 🖥️ pi-teams in Action
|
|
6
|
+
|
|
7
|
+
| iTerm2 | tmux | Zellij |
|
|
8
|
+
| :---: | :---: | :---: |
|
|
9
|
+
| <a href="iTerm2.png"><img src="iTerm2.png" width="300" alt="pi-teams in iTerm2"></a> | <a href="tmux.png"><img src="tmux.png" width="300" alt="pi-teams in tmux"></a> | <a href="zellij.png"><img src="zellij.png" width="300" alt="pi-teams in Zellij"></a> |
|
|
6
10
|
|
|
7
11
|
## 🛠 Installation
|
|
8
12
|
|
|
@@ -19,6 +23,9 @@ pi install npm:pi-teams
|
|
|
19
23
|
- **Spawn Specialists**: Create agents like "Security Expert" or "Frontend Pro" to handle sub-tasks in parallel.
|
|
20
24
|
- **Shared Task Board**: Keep everyone on the same page with a persistent list of tasks and their status.
|
|
21
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).
|
|
22
29
|
- **Autonomous Work**: Teammates automatically "wake up," read their instructions, and poll their inboxes for new work while idle.
|
|
23
30
|
- **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
|
|
24
31
|
|
|
@@ -40,14 +47,44 @@ You don't need to learn complex code commands. Just talk to Pi in plain English!
|
|
|
40
47
|
**Pro Tip:** You can specify a different model for a specific teammate!
|
|
41
48
|
> **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
|
|
42
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
|
+
|
|
43
69
|
### 3. Assign a Task
|
|
44
70
|
> **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
|
|
45
71
|
|
|
46
|
-
### 4.
|
|
47
|
-
|
|
48
|
-
>
|
|
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."
|
|
49
75
|
|
|
50
|
-
### 5.
|
|
76
|
+
### 5. Broadcast a Message
|
|
77
|
+
> **You:** "Broadcast to the entire team: 'The API endpoint has changed to /v2. Please update your work accordingly.'"
|
|
78
|
+
|
|
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
|
|
51
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.
|
|
52
89
|
|
|
53
90
|
### 6. Check on Progress
|
|
@@ -68,7 +105,10 @@ Pi automatically uses these tools when you give instructions like the examples a
|
|
|
68
105
|
- `read_config`: Get details about the team and its members.
|
|
69
106
|
|
|
70
107
|
### Teammates
|
|
71
|
-
- `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
|
|
72
112
|
- `check_teammate`: See if a teammate is still running or has unread messages.
|
|
73
113
|
- `force_kill_teammate`: Stop a teammate and remove them from the team.
|
|
74
114
|
- `process_shutdown_approved`: Orderly shutdown for a finished teammate.
|
|
@@ -77,18 +117,24 @@ Pi automatically uses these tools when you give instructions like the examples a
|
|
|
77
117
|
- `task_create`: Create a new task.
|
|
78
118
|
- `task_list`: List all tasks and their current status.
|
|
79
119
|
- `task_get`: Get full details of a specific task.
|
|
80
|
-
- `task_update`: Update a task's status or owner.
|
|
120
|
+
- `task_update`: Update a task's status (pending, planning, in_progress, etc.) or owner.
|
|
81
121
|
|
|
82
122
|
### Messaging
|
|
83
123
|
- `send_message`: Send a message to a teammate or lead.
|
|
124
|
+
- `broadcast_message`: Send a message to the entire team.
|
|
84
125
|
- `read_inbox`: Read incoming messages for an agent.
|
|
85
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
|
+
|
|
86
131
|
---
|
|
87
132
|
|
|
88
133
|
## 🤖 Automated Behavior
|
|
89
134
|
|
|
90
135
|
- **Initial Greeting**: When a teammate is spawned, they will automatically send a message saying they've started and are checking their inbox.
|
|
91
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`.
|
|
92
138
|
- **Context Injection**: Each teammate is given a custom system prompt that defines their role and instructions for the team environment.
|
|
93
139
|
|
|
94
140
|
---
|
package/extensions/index.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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",
|
|
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
|
|
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",
|
|
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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { withLock } from "./lock";
|
|
6
|
+
|
|
7
|
+
describe("withLock race conditions", () => {
|
|
8
|
+
const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now());
|
|
9
|
+
const lockPath = path.join(testDir, "test");
|
|
10
|
+
const lockFile = `${lockPath}.lock`;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should handle multiple concurrent attempts to acquire the lock", async () => {
|
|
21
|
+
let counter = 0;
|
|
22
|
+
const iterations = 20;
|
|
23
|
+
const concurrentCount = 5;
|
|
24
|
+
|
|
25
|
+
const runTask = async () => {
|
|
26
|
+
for (let i = 0; i < iterations; i++) {
|
|
27
|
+
await withLock(lockPath, async () => {
|
|
28
|
+
const current = counter;
|
|
29
|
+
// Add a small delay to increase the chance of race conditions if locking fails
|
|
30
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 10));
|
|
31
|
+
counter = current + 1;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const promises = [];
|
|
37
|
+
for (let i = 0; i < concurrentCount; i++) {
|
|
38
|
+
promises.push(runTask());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await Promise.all(promises);
|
|
42
|
+
|
|
43
|
+
expect(counter).toBe(iterations * concurrentCount);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/utils/lock.test.ts
CHANGED
|
@@ -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";
|
|
@@ -33,18 +34,9 @@ describe("withLock", () => {
|
|
|
33
34
|
|
|
34
35
|
const fn = vi.fn().mockResolvedValue("result");
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const promise = withLock(lockPath, fn);
|
|
40
|
-
|
|
41
|
-
// Fast-forward 6000ms to be safe
|
|
42
|
-
await vi.advanceTimersByTimeAsync(6000);
|
|
43
|
-
|
|
44
|
-
await expect(promise).rejects.toThrow("Could not acquire lock");
|
|
37
|
+
// Test with only 2 retries to speed up the failure
|
|
38
|
+
await expect(withLock(lockPath, fn, 2)).rejects.toThrow("Could not acquire lock");
|
|
45
39
|
expect(fn).not.toHaveBeenCalled();
|
|
46
|
-
|
|
47
|
-
vi.useRealTimers();
|
|
48
40
|
});
|
|
49
41
|
|
|
50
42
|
it("should release lock even if function fails", async () => {
|
package/src/utils/lock.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
// Project: pi-teams
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
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
|
+
|
|
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
|
-
let retries = 50;
|
|
10
11
|
while (retries > 0) {
|
|
11
12
|
try {
|
|
12
13
|
// Check if lock exists and is stale
|
|
@@ -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
|
});
|
package/src/utils/messaging.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/models.ts
CHANGED
|
@@ -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;
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
-
import { teamDir, inboxPath } from "./paths";
|
|
5
|
+
import { teamDir, inboxPath, sanitizeName } from "./paths";
|
|
6
6
|
|
|
7
7
|
describe("Security Audit - Path Traversal (Prevention Check)", () => {
|
|
8
8
|
it("should throw an error for path traversal via teamName", () => {
|
|
@@ -25,8 +25,8 @@ describe("Security Audit - Path Traversal (Prevention Check)", () => {
|
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
describe("Security Audit - Command Injection (
|
|
29
|
-
it("should be vulnerable to command injection in spawn_teammate (via parameters)", () => {
|
|
28
|
+
describe("Security Audit - Command Injection (Fixed)", () => {
|
|
29
|
+
it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => {
|
|
30
30
|
const maliciousCwd = "; rm -rf / ;";
|
|
31
31
|
const name = "attacker";
|
|
32
32
|
const team_name = "audit-team";
|
|
@@ -34,9 +34,10 @@ describe("Security Audit - Command Injection (Drafting)", () => {
|
|
|
34
34
|
const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
|
|
35
35
|
|
|
36
36
|
// Simulating what happens in spawn_teammate (extensions/index.ts)
|
|
37
|
-
const
|
|
37
|
+
const itermCmd = `cd '${maliciousCwd}' && ${cmd}`;
|
|
38
38
|
|
|
39
|
-
// The command becomes:
|
|
40
|
-
expect(
|
|
39
|
+
// The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi
|
|
40
|
+
expect(itermCmd).toContain("cd '; rm -rf / ;' &&");
|
|
41
|
+
expect(itermCmd).not.toContain("cd ; rm -rf / ; &&");
|
|
41
42
|
});
|
|
42
43
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { createTask, listTasks } from "./tasks";
|
|
6
|
+
import * as paths from "./paths";
|
|
7
|
+
|
|
8
|
+
const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now());
|
|
9
|
+
|
|
10
|
+
describe("Tasks Race Condition Bug", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
13
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
|
16
|
+
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
|
17
|
+
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
|
|
26
|
+
const numTasks = 20;
|
|
27
|
+
const promises = [];
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < numTasks; i++) {
|
|
30
|
+
promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results = await Promise.all(promises);
|
|
34
|
+
const ids = results.map(r => r.id);
|
|
35
|
+
const uniqueIds = new Set(ids);
|
|
36
|
+
|
|
37
|
+
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
|
|
38
|
+
// this test might still pass because createTask locks the directory.
|
|
39
|
+
// WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
|
|
40
|
+
// Let's re-verify createTask in src/utils/tasks.ts
|
|
41
|
+
|
|
42
|
+
expect(uniqueIds.size).toBe(numTasks);
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/utils/tasks.test.ts
CHANGED
|
@@ -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");
|
|
@@ -66,19 +85,58 @@ describe("Tasks Utilities", () => {
|
|
|
66
85
|
fs.writeFileSync(commonLockFile, "9999");
|
|
67
86
|
|
|
68
87
|
// 2. Try updateTask, it should fail
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await vi.advanceTimersByTimeAsync(6000);
|
|
72
|
-
await expect(updatePromise).rejects.toThrow("Could not acquire lock");
|
|
73
|
-
vi.useRealTimers();
|
|
88
|
+
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout
|
|
89
|
+
await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow("Could not acquire lock");
|
|
74
90
|
|
|
75
91
|
// 3. Try readTask, it should fail too
|
|
76
|
-
|
|
77
|
-
const readPromise = readTask("test-team", taskId);
|
|
78
|
-
await vi.advanceTimersByTimeAsync(6000);
|
|
79
|
-
await expect(readPromise).rejects.toThrow("Could not acquire lock");
|
|
80
|
-
vi.useRealTimers();
|
|
92
|
+
await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock");
|
|
81
93
|
|
|
82
94
|
fs.unlinkSync(commonLockFile);
|
|
83
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
|
+
});
|
|
84
142
|
});
|
package/src/utils/tasks.ts
CHANGED
|
@@ -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,
|
|
@@ -45,11 +53,10 @@ export async function createTask(
|
|
|
45
53
|
export async function updateTask(
|
|
46
54
|
teamName: string,
|
|
47
55
|
taskId: string,
|
|
48
|
-
updates: Partial<TaskFile
|
|
56
|
+
updates: Partial<TaskFile>,
|
|
57
|
+
retries?: number
|
|
49
58
|
): Promise<TaskFile> {
|
|
50
|
-
const
|
|
51
|
-
const safeTaskId = sanitizeName(taskId);
|
|
52
|
-
const p = path.join(dir, `${safeTaskId}.json`);
|
|
59
|
+
const p = getTaskPath(teamName, taskId);
|
|
53
60
|
|
|
54
61
|
return await withLock(p, async () => {
|
|
55
62
|
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
@@ -62,18 +69,84 @@ export async function updateTask(
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
72
|
+
|
|
73
|
+
if (updates.status === "completed") {
|
|
74
|
+
await runHook(teamName, "task_completed", updated);
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
return updated;
|
|
66
|
-
});
|
|
78
|
+
}, retries);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 };
|
|
139
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
140
|
+
return updated;
|
|
141
|
+
}, retries);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
|
|
145
|
+
const p = getTaskPath(teamName, taskId);
|
|
73
146
|
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
74
147
|
return await withLock(p, async () => {
|
|
75
148
|
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
76
|
-
});
|
|
149
|
+
}, retries);
|
|
77
150
|
}
|
|
78
151
|
|
|
79
152
|
export async function listTasks(teamName: string): Promise<TaskFile[]> {
|