pi-teams 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -5
- package/extensions/index.ts +68 -16
- package/package.json +19 -4
- package/skills/teams.md +22 -4
- package/src/utils/lock.test.ts +56 -0
- package/src/utils/lock.ts +17 -0
- package/src/utils/messaging.test.ts +69 -0
- package/src/utils/paths.ts +11 -3
- package/src/utils/security.test.ts +42 -0
- package/src/utils/tasks.test.ts +84 -0
- package/src/utils/tasks.ts +6 -5
package/README.md
CHANGED
|
@@ -45,22 +45,59 @@ You don't need to learn complex code commands. Just talk to Pi in plain English!
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## 🛠 Available Tools
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Pi automatically uses these tools when you give instructions like the examples above.
|
|
51
51
|
|
|
52
|
-
###
|
|
52
|
+
### Team Management
|
|
53
|
+
- `team_create`: Start a new team.
|
|
54
|
+
- `team_delete`: Delete a team and its data.
|
|
55
|
+
- `read_config`: Get details about the team and its members.
|
|
56
|
+
|
|
57
|
+
### Teammates
|
|
58
|
+
- `spawn_teammate`: Launch a new agent into a `tmux` pane with a role and instructions.
|
|
59
|
+
- `check_teammate`: See if a teammate is still running or has unread messages.
|
|
60
|
+
- `force_kill_teammate`: Stop a teammate and remove them from the team.
|
|
61
|
+
- `process_shutdown_approved`: Orderly shutdown for a finished teammate.
|
|
62
|
+
|
|
63
|
+
### Task Management
|
|
64
|
+
- `task_create`: Create a new task.
|
|
65
|
+
- `task_list`: List all tasks and their current status.
|
|
66
|
+
- `task_get`: Get full details of a specific task.
|
|
67
|
+
- `task_update`: Update a task's status or owner.
|
|
68
|
+
|
|
69
|
+
### Messaging
|
|
70
|
+
- `send_message`: Send a message to a teammate or lead.
|
|
71
|
+
- `read_inbox`: Read incoming messages for an agent.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 🤖 Automated Behavior
|
|
76
|
+
|
|
77
|
+
- **Initial Greeting**: When a teammate is spawned, they will automatically send a message saying they've started and are checking their inbox.
|
|
78
|
+
- **Idle Polling**: Teammates check for new messages every 30 seconds if they are idle.
|
|
79
|
+
- **Context Injection**: Each teammate is given a custom system prompt that defines their role and instructions for the team environment.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🪟 Requirements: tmux or Zellij
|
|
84
|
+
|
|
85
|
+
To show multiple agents on one screen, **pi-teams** requires a terminal multiplexer. It supports both **tmux** and **Zellij**.
|
|
86
|
+
|
|
87
|
+
### Option 1: tmux (Recommended)
|
|
88
|
+
|
|
89
|
+
#### 1. Install tmux
|
|
53
90
|
- **macOS**: `brew install tmux`
|
|
54
91
|
- **Linux**: `sudo apt install tmux`
|
|
55
92
|
|
|
56
|
-
|
|
93
|
+
#### 2. How to run it
|
|
57
94
|
Before you start a team, you **must** be inside a tmux session. Simply type:
|
|
58
95
|
```bash
|
|
59
96
|
tmux
|
|
60
97
|
```
|
|
61
98
|
Then start `pi` inside that window.
|
|
62
99
|
|
|
63
|
-
|
|
100
|
+
#### 3. Navigating Panes (Vanilla tmux)
|
|
64
101
|
When your screen splits into multiple agents, you use "Prefix" commands to move around. By default, the prefix is **`Ctrl+b`**.
|
|
65
102
|
|
|
66
103
|
- **Switch to next agent**: Press `Ctrl+b` then `o`.
|
|
@@ -72,6 +109,13 @@ When your screen splits into multiple agents, you use "Prefix" commands to move
|
|
|
72
109
|
|
|
73
110
|
*Note: You can greatly improve this experience by enabling "mouse mode" in your `~/.tmux.conf` file.*
|
|
74
111
|
|
|
112
|
+
### Option 2: Zellij
|
|
113
|
+
|
|
114
|
+
If you prefer **Zellij**, simply start `pi` inside a Zellij session. **pi-teams** will detect it via the `ZELLIJ` environment variable and use `zellij run` to spawn teammates in new panes.
|
|
115
|
+
|
|
116
|
+
- **Switching Panes**: Use `Alt` + `Arrow Keys` (default) to move between agent panes.
|
|
117
|
+
- **Closing Panes**: Use `Ctrl+t` then `p` then `x` (or your configured shortcut) to close a teammate pane if needed, though `force_kill_teammate` is recommended.
|
|
118
|
+
|
|
75
119
|
---
|
|
76
120
|
|
|
77
121
|
## 📜 Credits & Attribution
|
package/extensions/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as teams from "../src/utils/teams";
|
|
|
6
6
|
import * as tasks from "../src/utils/tasks";
|
|
7
7
|
import * as messaging from "../src/utils/messaging";
|
|
8
8
|
import { Member } from "../src/utils/models";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
9
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
|
|
@@ -18,13 +18,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
18
18
|
pi.on("session_start", async (_event, ctx) => {
|
|
19
19
|
paths.ensureDirs();
|
|
20
20
|
if (isTeammate) {
|
|
21
|
+
if (teamName) {
|
|
22
|
+
const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
|
|
23
|
+
fs.writeFileSync(pidFile, process.pid.toString());
|
|
24
|
+
}
|
|
21
25
|
ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
|
|
22
26
|
// Use a shorter, more prominent status at the beginning if possible
|
|
23
27
|
ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
|
|
24
28
|
|
|
25
29
|
// Also set the tmux pane title for better visibility
|
|
26
30
|
try {
|
|
27
|
-
|
|
31
|
+
spawnSync("tmux", ["select-pane", "-T", agentName]);
|
|
28
32
|
} catch (e) {
|
|
29
33
|
// ignore
|
|
30
34
|
}
|
|
@@ -87,13 +91,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
87
91
|
cwd: Type.String(),
|
|
88
92
|
}),
|
|
89
93
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
90
|
-
|
|
94
|
+
const safeName = paths.sanitizeName(params.name);
|
|
95
|
+
const safeTeamName = paths.sanitizeName(params.team_name);
|
|
96
|
+
|
|
97
|
+
if (!teams.teamExists(safeTeamName)) {
|
|
91
98
|
throw new Error(`Team ${params.team_name} does not exist`);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
const member: Member = {
|
|
95
|
-
agentId: `${
|
|
96
|
-
name:
|
|
102
|
+
agentId: `${safeName}@${safeTeamName}`,
|
|
103
|
+
name: safeName,
|
|
97
104
|
agentType: "teammate",
|
|
98
105
|
model: "sonnet",
|
|
99
106
|
joinedAt: Date.now(),
|
|
@@ -104,19 +111,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
104
111
|
color: "blue",
|
|
105
112
|
};
|
|
106
113
|
|
|
107
|
-
await teams.addMember(
|
|
108
|
-
await messaging.sendPlainMessage(
|
|
114
|
+
await teams.addMember(safeTeamName, member);
|
|
115
|
+
await messaging.sendPlainMessage(safeTeamName, "team-lead", safeName, params.prompt, "Initial prompt");
|
|
109
116
|
|
|
110
117
|
const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi"; // Assumed on path
|
|
111
|
-
const cmd = `PI_TEAM_NAME=${params.team_name} PI_AGENT_NAME=${params.name} ${piBinary}`;
|
|
112
118
|
|
|
113
119
|
let paneId = "";
|
|
114
120
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
if (process.env.ZELLIJ && !process.env.TMUX) {
|
|
122
|
+
const zellijArgs = [
|
|
123
|
+
"run",
|
|
124
|
+
"--name", safeName,
|
|
125
|
+
"--cwd", params.cwd,
|
|
126
|
+
"--close-on-exit",
|
|
127
|
+
"--",
|
|
128
|
+
"env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
|
|
129
|
+
"sh", "-c", piBinary
|
|
130
|
+
];
|
|
131
|
+
spawnSync("zellij", zellijArgs);
|
|
132
|
+
paneId = `zellij_${safeName}`;
|
|
133
|
+
} else {
|
|
134
|
+
const tmuxArgs = [
|
|
135
|
+
"split-window",
|
|
136
|
+
"-h", "-dP",
|
|
137
|
+
"-F", "#{pane_id}",
|
|
138
|
+
"-c", params.cwd,
|
|
139
|
+
"env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
|
|
140
|
+
"sh", "-c", piBinary
|
|
141
|
+
];
|
|
142
|
+
const result = spawnSync("tmux", tmuxArgs);
|
|
143
|
+
if (result.status !== 0) throw new Error(`tmux failed with status ${result.status}: ${result.stderr.toString()}`);
|
|
144
|
+
paneId = result.stdout.toString().trim();
|
|
145
|
+
spawnSync("tmux", ["select-layout", "even-horizontal"]);
|
|
146
|
+
}
|
|
118
147
|
} catch (e) {
|
|
119
|
-
throw new Error(`Failed to spawn tmux pane: ${e}`);
|
|
148
|
+
throw new Error(`Failed to spawn ${process.env.ZELLIJ && !process.env.TMUX ? "zellij" : "tmux"} pane: ${e}`);
|
|
120
149
|
}
|
|
121
150
|
|
|
122
151
|
// Update member with paneId
|
|
@@ -285,9 +314,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
285
314
|
const config = await teams.readConfig(params.team_name);
|
|
286
315
|
const member = config.members.find(m => m.name === params.agent_name);
|
|
287
316
|
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
|
|
317
|
+
|
|
318
|
+
const pidFile = path.join(paths.teamDir(params.team_name), `${params.agent_name}.pid`);
|
|
319
|
+
if (fs.existsSync(pidFile)) {
|
|
320
|
+
try {
|
|
321
|
+
const pid = fs.readFileSync(pidFile, "utf-8").trim();
|
|
322
|
+
execSync(`kill -9 ${pid}`);
|
|
323
|
+
fs.unlinkSync(pidFile);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
// ignore if process already dead
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
288
329
|
if (member.tmuxPaneId) {
|
|
289
330
|
try {
|
|
290
|
-
|
|
331
|
+
if (!member.tmuxPaneId.startsWith("zellij_")) {
|
|
332
|
+
execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
|
|
333
|
+
}
|
|
291
334
|
} catch (e) {
|
|
292
335
|
// ignore
|
|
293
336
|
}
|
|
@@ -316,8 +359,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
316
359
|
let alive = false;
|
|
317
360
|
if (member.tmuxPaneId) {
|
|
318
361
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
362
|
+
if (member.tmuxPaneId.startsWith("zellij_")) {
|
|
363
|
+
// Assume alive if it's zellij for now
|
|
364
|
+
alive = true;
|
|
365
|
+
} else {
|
|
366
|
+
execSync(`tmux has-session -t ${member.tmuxPaneId}`);
|
|
367
|
+
alive = true;
|
|
368
|
+
}
|
|
321
369
|
} catch (e) {
|
|
322
370
|
alive = false;
|
|
323
371
|
}
|
|
@@ -346,7 +394,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
346
394
|
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
|
|
347
395
|
if (member.tmuxPaneId) {
|
|
348
396
|
try {
|
|
349
|
-
|
|
397
|
+
if (member.tmuxPaneId.startsWith("zellij_")) {
|
|
398
|
+
// zellij doesn't easily support closing a specific pane by name yet
|
|
399
|
+
} else {
|
|
400
|
+
execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
|
|
401
|
+
}
|
|
350
402
|
} catch (e) {
|
|
351
403
|
// ignore
|
|
352
404
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"keywords": [
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
9
14
|
"main": "extensions/index.ts",
|
|
10
15
|
"files": [
|
|
11
16
|
"extensions",
|
|
@@ -23,7 +28,17 @@
|
|
|
23
28
|
},
|
|
24
29
|
"pi": {
|
|
25
30
|
"image": "https://raw.githubusercontent.com/burggraf/pi-teams/main/pi-team-in-action.png",
|
|
26
|
-
"extensions": [
|
|
27
|
-
|
|
31
|
+
"extensions": [
|
|
32
|
+
"extensions/index.ts"
|
|
33
|
+
],
|
|
34
|
+
"skills": [
|
|
35
|
+
"skills"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.3.0",
|
|
40
|
+
"ts-node": "^10.9.2",
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"vitest": "^4.0.18"
|
|
28
43
|
}
|
|
29
44
|
}
|
package/skills/teams.md
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or
|
|
2
|
+
description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or Zellij.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Agent Teams
|
|
6
6
|
|
|
7
|
-
Coordinate multiple agents working on a project using shared task lists and messaging
|
|
7
|
+
Coordinate multiple agents working on a project using shared task lists and messaging via **tmux** or **Zellij**.
|
|
8
8
|
|
|
9
9
|
## Workflow
|
|
10
10
|
|
|
11
11
|
1. **Create a team**: Use `team_create(team_name="my-team")`.
|
|
12
12
|
2. **Spawn teammates**: Use `spawn_teammate` to start additional agents. Give them specific roles and initial prompts.
|
|
13
|
-
3. **Manage tasks**:
|
|
13
|
+
3. **Manage tasks**:
|
|
14
|
+
* `task_create`: Define work for the team.
|
|
15
|
+
* `task_list`: List all tasks to monitor progress or find available work.
|
|
16
|
+
* `task_get`: Get full details of a specific task by ID.
|
|
17
|
+
* `task_update`: Update a task's status (`pending`, `in_progress`, `completed`, `deleted`) or owner.
|
|
14
18
|
4. **Communicate**: Use `send_message` to give instructions or receive updates. Teammates should use `read_inbox` to check for messages.
|
|
15
19
|
5. **Monitor**: Use `check_teammate` to see if they are still running and if they have sent messages back.
|
|
20
|
+
6. **Cleanup**:
|
|
21
|
+
* `force_kill_teammate`: Forcibly stop a teammate and remove them from the team.
|
|
22
|
+
* `process_shutdown_approved`: Orderly removal of a teammate after they've finished.
|
|
23
|
+
* `team_delete`: Remove a team and all its associated data.
|
|
16
24
|
|
|
17
25
|
## Teammate Instructions
|
|
18
26
|
|
|
19
27
|
When you are spawned as a teammate:
|
|
20
28
|
- Your status bar will show "Teammate: name @ team".
|
|
21
|
-
-
|
|
29
|
+
- You will automatically start by calling `read_inbox` to get your initial instructions.
|
|
22
30
|
- Regularly check `read_inbox` for updates from the lead.
|
|
23
31
|
- Use `send_message` to "team-lead" to report progress or ask questions.
|
|
24
32
|
- Update your assigned tasks using `task_update`.
|
|
33
|
+
- If you are idle for more than 30 seconds, you will automatically check your inbox for new messages.
|
|
25
34
|
|
|
26
35
|
## Best Practices for Teammates
|
|
27
36
|
|
|
@@ -29,3 +38,12 @@ When you are spawned as a teammate:
|
|
|
29
38
|
- **Frequent Communication**: Send short summaries of your work back to `team-lead` frequently.
|
|
30
39
|
- **Context Matters**: When you finish a task, send a message explaining your results and any new files you created.
|
|
31
40
|
- **Independence**: If you get stuck, try to solve it yourself first, but don't hesitate to ask `team-lead` for clarification.
|
|
41
|
+
- **Orderly Shutdown**: When you've finished all your work and have no more instructions, notify the lead and wait for shutdown approval.
|
|
42
|
+
|
|
43
|
+
## Best Practices for Team Leads
|
|
44
|
+
|
|
45
|
+
- **Clear Assignments**: Use `task_create` for all significant work items.
|
|
46
|
+
- **Contextual Prompts**: Provide enough context in `spawn_teammate` for the teammate to understand their specific role.
|
|
47
|
+
- **Task List Monitoring**: Regularly call `task_list` to see the status of all work.
|
|
48
|
+
- **Direct Feedback**: Use `send_message` to provide course corrections or new instructions to teammates.
|
|
49
|
+
- **Read Config**: Use `read_config` to see the full team roster and their current status.
|
|
@@ -0,0 +1,56 @@
|
|
|
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", () => {
|
|
8
|
+
const testDir = path.join(os.tmpdir(), "pi-lock-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
|
+
vi.restoreAllMocks();
|
|
18
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should successfully acquire and release the lock", async () => {
|
|
22
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
23
|
+
const result = await withLock(lockPath, fn);
|
|
24
|
+
|
|
25
|
+
expect(result).toBe("result");
|
|
26
|
+
expect(fn).toHaveBeenCalled();
|
|
27
|
+
expect(fs.existsSync(lockFile)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should fail to acquire lock if already held", async () => {
|
|
31
|
+
// Manually create lock file
|
|
32
|
+
fs.writeFileSync(lockFile, "9999");
|
|
33
|
+
|
|
34
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
35
|
+
|
|
36
|
+
// We expect it to fail after timeout (50 * 100ms = 5s)
|
|
37
|
+
vi.useFakeTimers();
|
|
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");
|
|
45
|
+
expect(fn).not.toHaveBeenCalled();
|
|
46
|
+
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should release lock even if function fails", async () => {
|
|
51
|
+
const fn = vi.fn().mockRejectedValue(new Error("failure"));
|
|
52
|
+
|
|
53
|
+
await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
|
|
54
|
+
expect(fs.existsSync(lockFile)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/utils/lock.ts
CHANGED
|
@@ -3,9 +3,26 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
export async function withLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
5
5
|
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
|
+
|
|
6
9
|
let retries = 50;
|
|
7
10
|
while (retries > 0) {
|
|
8
11
|
try {
|
|
12
|
+
// Check if lock exists and is stale
|
|
13
|
+
if (fs.existsSync(lockFile)) {
|
|
14
|
+
const stats = fs.statSync(lockFile);
|
|
15
|
+
const age = Date.now() - stats.mtimeMs;
|
|
16
|
+
if (age > STALE_LOCK_TIMEOUT) {
|
|
17
|
+
// Attempt to remove stale lock
|
|
18
|
+
try {
|
|
19
|
+
fs.unlinkSync(lockFile);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
// ignore, another process might have already removed it
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
|
|
10
27
|
break;
|
|
11
28
|
} catch (e) {
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { appendMessage, readInbox, sendPlainMessage } from "./messaging";
|
|
6
|
+
import * as paths from "./paths";
|
|
7
|
+
|
|
8
|
+
// Mock the paths to use a temporary directory
|
|
9
|
+
const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
|
|
10
|
+
|
|
11
|
+
describe("Messaging Utilities", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
14
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// Override paths to use testDir
|
|
17
|
+
vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => {
|
|
18
|
+
return path.join(testDir, "inboxes", `${agentName}.json`);
|
|
19
|
+
});
|
|
20
|
+
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should append a message successfully", async () => {
|
|
29
|
+
const msg = { from: "sender", text: "hello", timestamp: "now", read: false };
|
|
30
|
+
await appendMessage("test-team", "receiver", msg);
|
|
31
|
+
|
|
32
|
+
const inbox = await readInbox("test-team", "receiver", false, false);
|
|
33
|
+
expect(inbox.length).toBe(1);
|
|
34
|
+
expect(inbox[0].text).toBe("hello");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should handle concurrent appends (Stress Test)", async () => {
|
|
38
|
+
const numMessages = 100;
|
|
39
|
+
const promises = [];
|
|
40
|
+
for (let i = 0; i < numMessages; i++) {
|
|
41
|
+
promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await Promise.all(promises);
|
|
45
|
+
|
|
46
|
+
const inbox = await readInbox("test-team", "receiver", false, false);
|
|
47
|
+
expect(inbox.length).toBe(numMessages);
|
|
48
|
+
|
|
49
|
+
// Verify all messages are present
|
|
50
|
+
const texts = inbox.map(m => m.text).sort();
|
|
51
|
+
for (let i = 0; i < numMessages; i++) {
|
|
52
|
+
expect(texts).toContain(`msg-${i}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should mark messages as read", async () => {
|
|
57
|
+
await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1");
|
|
58
|
+
await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2");
|
|
59
|
+
|
|
60
|
+
// Read only unread messages
|
|
61
|
+
const unread = await readInbox("test-team", "receiver", true, true);
|
|
62
|
+
expect(unread.length).toBe(2);
|
|
63
|
+
|
|
64
|
+
// Now all should be read
|
|
65
|
+
const all = await readInbox("test-team", "receiver", false, false);
|
|
66
|
+
expect(all.length).toBe(2);
|
|
67
|
+
expect(all.every(m => m.read)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/utils/paths.ts
CHANGED
|
@@ -12,16 +12,24 @@ export function ensureDirs() {
|
|
|
12
12
|
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function sanitizeName(name: string): string {
|
|
16
|
+
// Allow only alphanumeric characters, hyphens, and underscores.
|
|
17
|
+
if (/[^a-zA-Z0-9_-]/.test(name)) {
|
|
18
|
+
throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
|
|
19
|
+
}
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export function teamDir(teamName: string) {
|
|
16
|
-
return path.join(TEAMS_DIR, teamName);
|
|
24
|
+
return path.join(TEAMS_DIR, sanitizeName(teamName));
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export function taskDir(teamName: string) {
|
|
20
|
-
return path.join(TASKS_DIR, teamName);
|
|
28
|
+
return path.join(TASKS_DIR, sanitizeName(teamName));
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export function inboxPath(teamName: string, agentName: string) {
|
|
24
|
-
return path.join(teamDir(teamName), "inboxes", `${agentName}.json`);
|
|
32
|
+
return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`);
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export function configPath(teamName: string) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { teamDir, inboxPath } from "./paths";
|
|
6
|
+
|
|
7
|
+
describe("Security Audit - Path Traversal (Prevention Check)", () => {
|
|
8
|
+
it("should throw an error for path traversal via teamName", () => {
|
|
9
|
+
const maliciousTeamName = "../../etc";
|
|
10
|
+
expect(() => teamDir(maliciousTeamName)).toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should throw an error for path traversal via agentName", () => {
|
|
14
|
+
const teamName = "audit-team";
|
|
15
|
+
const maliciousAgentName = "../../../.ssh/id_rsa";
|
|
16
|
+
expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should throw an error for path traversal via taskId", () => {
|
|
20
|
+
const teamName = "audit-team";
|
|
21
|
+
const maliciousTaskId = "../../../etc/passwd";
|
|
22
|
+
// We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
|
|
23
|
+
// But since we already tested sanitizeName via other paths, this is just for completeness.
|
|
24
|
+
expect(() => sanitizeName(maliciousTaskId)).toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Security Audit - Command Injection (Drafting)", () => {
|
|
29
|
+
it("should be vulnerable to command injection in spawn_teammate (via parameters)", () => {
|
|
30
|
+
const maliciousCwd = "; rm -rf / ;";
|
|
31
|
+
const name = "attacker";
|
|
32
|
+
const team_name = "audit-team";
|
|
33
|
+
const piBinary = "pi";
|
|
34
|
+
const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
|
|
35
|
+
|
|
36
|
+
// Simulating what happens in spawn_teammate (extensions/index.ts)
|
|
37
|
+
const tmuxCmd = `tmux split-window -h -dP -F "#{pane_id}" "cd ${maliciousCwd} && ${cmd}"`;
|
|
38
|
+
|
|
39
|
+
// The command becomes: tmux split-window -h -dP -F "#{pane_id}" "cd ; rm -rf / ; && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi"
|
|
40
|
+
expect(tmuxCmd).toContain("cd ; rm -rf / ; &&");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { createTask, updateTask, readTask, listTasks } from "./tasks";
|
|
6
|
+
import * as paths from "./paths";
|
|
7
|
+
import * as teams from "./teams";
|
|
8
|
+
|
|
9
|
+
// Mock the paths to use a temporary directory
|
|
10
|
+
const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
|
|
11
|
+
|
|
12
|
+
describe("Tasks Utilities", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
15
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
// Override paths to use testDir
|
|
18
|
+
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
|
|
19
|
+
vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
|
|
20
|
+
|
|
21
|
+
// Create a dummy team config
|
|
22
|
+
fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should create a task successfully", async () => {
|
|
31
|
+
const task = await createTask("test-team", "Test Subject", "Test Description");
|
|
32
|
+
expect(task.id).toBe("1");
|
|
33
|
+
expect(task.subject).toBe("Test Subject");
|
|
34
|
+
expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should update a task successfully", async () => {
|
|
38
|
+
await createTask("test-team", "Test Subject", "Test Description");
|
|
39
|
+
const updated = await updateTask("test-team", "1", { status: "in_progress" });
|
|
40
|
+
expect(updated.status).toBe("in_progress");
|
|
41
|
+
|
|
42
|
+
const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8"));
|
|
43
|
+
expect(taskData.status).toBe("in_progress");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should list tasks", async () => {
|
|
47
|
+
await createTask("test-team", "Task 1", "Desc 1");
|
|
48
|
+
await createTask("test-team", "Task 2", "Desc 2");
|
|
49
|
+
const tasksList = await listTasks("test-team");
|
|
50
|
+
expect(tasksList.length).toBe(2);
|
|
51
|
+
expect(tasksList[0].id).toBe("1");
|
|
52
|
+
expect(tasksList[1].id).toBe("2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should have consistent lock paths (Fixed BUG 2)", async () => {
|
|
56
|
+
// This test verifies that both updateTask and readTask now use the same lock path
|
|
57
|
+
// Both should now lock `${taskId}.json.lock`
|
|
58
|
+
|
|
59
|
+
await createTask("test-team", "Bug Test", "Testing lock consistency");
|
|
60
|
+
const taskId = "1";
|
|
61
|
+
|
|
62
|
+
const taskFile = path.join(testDir, `${taskId}.json`);
|
|
63
|
+
const commonLockFile = `${taskFile}.lock`;
|
|
64
|
+
|
|
65
|
+
// 1. Holding the common lock
|
|
66
|
+
fs.writeFileSync(commonLockFile, "9999");
|
|
67
|
+
|
|
68
|
+
// 2. Try updateTask, it should fail
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
const updatePromise = updateTask("test-team", taskId, { status: "in_progress" });
|
|
71
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
72
|
+
await expect(updatePromise).rejects.toThrow("Could not acquire lock");
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
|
|
75
|
+
// 3. Try readTask, it should fail too
|
|
76
|
+
vi.useFakeTimers();
|
|
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();
|
|
81
|
+
|
|
82
|
+
fs.unlinkSync(commonLockFile);
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/utils/tasks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { TaskFile } from "./models";
|
|
4
|
-
import { taskDir } from "./paths";
|
|
4
|
+
import { taskDir, sanitizeName } from "./paths";
|
|
5
5
|
import { teamExists } from "./teams";
|
|
6
6
|
import { withLock } from "./lock";
|
|
7
7
|
|
|
@@ -48,10 +48,10 @@ export async function updateTask(
|
|
|
48
48
|
updates: Partial<TaskFile>
|
|
49
49
|
): Promise<TaskFile> {
|
|
50
50
|
const dir = taskDir(teamName);
|
|
51
|
-
const
|
|
52
|
-
const p = path.join(dir, `${
|
|
51
|
+
const safeTaskId = sanitizeName(taskId);
|
|
52
|
+
const p = path.join(dir, `${safeTaskId}.json`);
|
|
53
53
|
|
|
54
|
-
return await withLock(
|
|
54
|
+
return await withLock(p, async () => {
|
|
55
55
|
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
56
56
|
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
57
57
|
const updated = { ...task, ...updates };
|
|
@@ -68,7 +68,8 @@ export async function updateTask(
|
|
|
68
68
|
|
|
69
69
|
export async function readTask(teamName: string, taskId: string): Promise<TaskFile> {
|
|
70
70
|
const dir = taskDir(teamName);
|
|
71
|
-
const
|
|
71
|
+
const safeTaskId = sanitizeName(taskId);
|
|
72
|
+
const p = path.join(dir, `${safeTaskId}.json`);
|
|
72
73
|
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
73
74
|
return await withLock(p, async () => {
|
|
74
75
|
return JSON.parse(fs.readFileSync(p, "utf-8"));
|