pi-teams 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/extensions/index.ts +361 -0
- package/package.json +28 -0
- package/skills/teams.md +31 -0
- package/src/utils/lock.ts +30 -0
- package/src/utils/messaging.ts +73 -0
- package/src/utils/models.ts +45 -0
- package/src/utils/paths.ts +29 -0
- package/src/utils/tasks.ts +111 -0
- package/src/utils/teams.ts +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# pi-teams
|
|
2
|
+
|
|
3
|
+
Agent teams for `pi`, ported from `claude-code-teams-mcp`.
|
|
4
|
+
|
|
5
|
+
This package implements a team protocol for agents working together on a project using shared task lists and messaging, mediated via `tmux`. It allows a "Lead" agent to spawn "Teammates", assign them tasks, and communicate via a persistent inbox system.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Team Orchestration**: Create named teams with isolated configurations.
|
|
10
|
+
- **Agent Spawning**: Launch teammates in background `tmux` panes.
|
|
11
|
+
- **Messaging Protocol**: Lead can send direct messages and instructions; teammates can reply and report progress.
|
|
12
|
+
- **Shared Task List**: Track progress across the whole team with persistent task states (`pending`, `in_progress`, `completed`).
|
|
13
|
+
- **Identity Awareness**: Agents know if they are a Lead or a Teammate and adjust their system prompts accordingly.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-teams
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or from GitHub:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install github:burggraf/pi-teams
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Getting Started (Quick Start)
|
|
28
|
+
|
|
29
|
+
To get a team up and running, follow these steps:
|
|
30
|
+
|
|
31
|
+
1. **Initialize your team**:
|
|
32
|
+
"Start a new team called 'ui-refactor' for improving the React component architecture."
|
|
33
|
+
2. **Define your plan**:
|
|
34
|
+
"Create tasks for 'refactor-header', 'add-storybooks', and 'update-theme'."
|
|
35
|
+
3. **Spawn a specialist**:
|
|
36
|
+
"Spawn a teammate named 'storybook-expert' in the `packages/ui` directory. Their goal is to create Storybook stories for all components in the `src/components` directory."
|
|
37
|
+
4. **Follow progress**:
|
|
38
|
+
"Check on 'storybook-expert'. Any updates? Show me the current task list."
|
|
39
|
+
|
|
40
|
+
## How to Use in Pi
|
|
41
|
+
|
|
42
|
+
As a developer, you interact with `pi-teams` by giving natural language instructions to the pi agent. The agent will then use the underlying tools to execute your requests.
|
|
43
|
+
|
|
44
|
+
### 1. Starting a New Team
|
|
45
|
+
**You:** "Start a new team called 'backend-migration' for refactoring our auth service."
|
|
46
|
+
- **Pi will:** Call `team_create("backend-migration", "Refactoring auth service")`.
|
|
47
|
+
|
|
48
|
+
### 2. Adding Teammates
|
|
49
|
+
**You:** "Spawn a teammate named 'security-audit' in the `./auth` directory. Their goal is to find any hardcoded secrets."
|
|
50
|
+
- **Pi will:** Call `spawn_teammate("backend-migration", "security-audit", "Analyze all files in the current directory and find hardcoded secrets. Report back with a message to team-lead.", "./auth")`.
|
|
51
|
+
- **In your terminal:** A new `tmux` pane will open running a fresh `pi` instance with those instructions.
|
|
52
|
+
|
|
53
|
+
### 3. Coordinating via Tasks
|
|
54
|
+
**You:** "Create a task for 'security-audit' to check the `.env` handling and set it to 'in_progress'."
|
|
55
|
+
- **Pi will:** Call `task_create` and then `task_update` to assign it to the teammate.
|
|
56
|
+
|
|
57
|
+
### 4. Checking Progress
|
|
58
|
+
**You:** "Check on 'security-audit'. Do they have any updates for me?"
|
|
59
|
+
- **Pi will:** Call `check_teammate` to see if they are active and `read_inbox` to see if they sent any messages back to you (the lead).
|
|
60
|
+
|
|
61
|
+
### 5. Managing the Team
|
|
62
|
+
**You:** "List all tasks and show me the team config."
|
|
63
|
+
- **Pi will:** Call `task_list` and `read_config` to give you a status overview.
|
|
64
|
+
|
|
65
|
+
## Teammate Experience
|
|
66
|
+
|
|
67
|
+
When a teammate is spawned:
|
|
68
|
+
- A new `tmux` pane will open in your terminal.
|
|
69
|
+
- The teammate agent will be running a `pi` session with its initial instructions.
|
|
70
|
+
- The agent's status bar (if supported by your terminal) will indicate its role and team name.
|
|
71
|
+
- It will automatically start by checking its inbox for initial tasks or additional context.
|
|
72
|
+
- It will periodically check for new messages from the "Lead" agent.
|
|
73
|
+
|
|
74
|
+
## Communication Flow
|
|
75
|
+
1. **Lead to Teammate**: Use `send_message`. The teammate is programmed to check their inbox.
|
|
76
|
+
2. **Teammate to Lead**: Teammates are instructed to use `send_message` with `recipient="team-lead"`.
|
|
77
|
+
3. **Checking Messages**: You can ask Pi "Read my messages" or "Check for new messages from the team".
|
|
78
|
+
|
|
79
|
+
### Team Management
|
|
80
|
+
- `team_create(team_name, description?)`: Initialize a new team.
|
|
81
|
+
- `team_delete(team_name)`: Remove all team data and configuration.
|
|
82
|
+
- `read_config(team_name)`: View the current team members and status.
|
|
83
|
+
|
|
84
|
+
### Teammate Operations
|
|
85
|
+
- `spawn_teammate(team_name, name, prompt, cwd)`: Spawn a new `pi` agent in a `tmux` pane with specific instructions.
|
|
86
|
+
- `check_teammate(team_name, agent_name)`: Check if an agent is still alive in `tmux` and see their unread message count.
|
|
87
|
+
- `force_kill_teammate(team_name, agent_name)`: Forcibly terminate an agent's `tmux` pane and remove them from the team.
|
|
88
|
+
|
|
89
|
+
### Communication
|
|
90
|
+
- `send_message(team_name, recipient, content, summary)`: Send a message to any team member.
|
|
91
|
+
- `read_inbox(team_name, unread_only?)`: Check for incoming messages. Teammates use this to get their orders.
|
|
92
|
+
|
|
93
|
+
### Task Management
|
|
94
|
+
- `task_create(team_name, subject, description)`: Add a new task to the team board.
|
|
95
|
+
- `task_list(team_name)`: List all tasks and their owners/status.
|
|
96
|
+
- `task_get(team_name, task_id)`: Get full details for a specific task.
|
|
97
|
+
- `task_update(team_name, task_id, status?, owner?)`: Update status or assign/reassign a task.
|
|
98
|
+
|
|
99
|
+
## Troubleshooting
|
|
100
|
+
|
|
101
|
+
- **Tmux not found**: Ensure `tmux` is installed on your system.
|
|
102
|
+
- **Tmux pane doesn't open**: Make sure you have a `tmux` session active when spawning teammates.
|
|
103
|
+
- **Pi not in path**: Teammates are spawned with the command `pi`. Ensure the `pi` binary is in your PATH.
|
|
104
|
+
- **Storage Issues**: If team data is corrupted or you want a fresh start, you can manually delete the data at `~/.claude/teams/` and `~/.claude/tasks/`.
|
|
105
|
+
|
|
106
|
+
## Safety and Concurrency
|
|
107
|
+
|
|
108
|
+
`pi-teams` is designed for high reliability in multi-agent environments:
|
|
109
|
+
|
|
110
|
+
- **Granular Locking**: Uses a per-file locking mechanism (`.filename.lock`) to ensure that concurrent reads and writes from multiple agents don't corrupt team state or task lists.
|
|
111
|
+
- **Atomic Operations**: All state changes are wrapped in atomic file operations with automatic retries and exponential backoff.
|
|
112
|
+
- **Identity Protection**: Teammate sessions are isolated with their own environment variables (`PI_TEAM_NAME`, `PI_AGENT_NAME`) and tailored system prompts.
|
|
113
|
+
- **Tmux Integration**: Leverages `tmux` for robust process management and visual separation of agent activities.
|
|
114
|
+
|
|
115
|
+
## Requirements
|
|
116
|
+
|
|
117
|
+
- **tmux**: Must be installed on your system.
|
|
118
|
+
- **pi**: The `pi` binary must be accessible in your environment.
|
|
119
|
+
|
|
120
|
+
## Credits and Attribution
|
|
121
|
+
|
|
122
|
+
This project is a port of [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) by [cs50victor](https://github.com/cs50victor). It adapts the core team orchestration protocol and shared task management concepts from the original MCP server to work natively within the `pi` ecosystem.
|
|
123
|
+
|
|
124
|
+
Special thanks to the original author for the architectural inspiration and for deep-diving into the agent coordination patterns that make this package possible.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
4
|
+
import * as paths from "../src/utils/paths";
|
|
5
|
+
import * as teams from "../src/utils/teams";
|
|
6
|
+
import * as tasks from "../src/utils/tasks";
|
|
7
|
+
import * as messaging from "../src/utils/messaging";
|
|
8
|
+
import { Member } from "../src/utils/models";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
export default function (pi: ExtensionAPI) {
|
|
14
|
+
const isTeammate = !!process.env.PI_AGENT_NAME;
|
|
15
|
+
const agentName = process.env.PI_AGENT_NAME || "team-lead";
|
|
16
|
+
const teamName = process.env.PI_TEAM_NAME;
|
|
17
|
+
|
|
18
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
19
|
+
paths.ensureDirs();
|
|
20
|
+
if (isTeammate) {
|
|
21
|
+
ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
|
|
22
|
+
// Use a shorter, more prominent status at the beginning if possible
|
|
23
|
+
ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
|
|
24
|
+
|
|
25
|
+
// Also set the tmux pane title for better visibility
|
|
26
|
+
try {
|
|
27
|
+
execSync(`tmux select-pane -T "${agentName}"`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Auto-trigger the first turn for teammates
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
|
|
35
|
+
}, 1000);
|
|
36
|
+
|
|
37
|
+
// Periodically check for new messages when idle
|
|
38
|
+
setInterval(async () => {
|
|
39
|
+
if (ctx.isIdle() && teamName) {
|
|
40
|
+
const unread = await messaging.readInbox(teamName, agentName, true, false);
|
|
41
|
+
if (unread.length > 0) {
|
|
42
|
+
pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}, 30000);
|
|
46
|
+
} else if (teamName) {
|
|
47
|
+
ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let firstTurn = true;
|
|
52
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
53
|
+
if (isTeammate && firstTurn) {
|
|
54
|
+
firstTurn = false;
|
|
55
|
+
return {
|
|
56
|
+
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.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Tools
|
|
62
|
+
pi.registerTool({
|
|
63
|
+
name: "team_create",
|
|
64
|
+
label: "Create Team",
|
|
65
|
+
description: "Create a new agent team.",
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
team_name: Type.String(),
|
|
68
|
+
description: Type.Optional(Type.String()),
|
|
69
|
+
}),
|
|
70
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
71
|
+
const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: `Team ${params.team_name} created.` }],
|
|
74
|
+
details: { config },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerTool({
|
|
80
|
+
name: "spawn_teammate",
|
|
81
|
+
label: "Spawn Teammate",
|
|
82
|
+
description: "Spawn a new teammate in a tmux pane.",
|
|
83
|
+
parameters: Type.Object({
|
|
84
|
+
team_name: Type.String(),
|
|
85
|
+
name: Type.String(),
|
|
86
|
+
prompt: Type.String(),
|
|
87
|
+
cwd: Type.String(),
|
|
88
|
+
}),
|
|
89
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
90
|
+
if (!teams.teamExists(params.team_name)) {
|
|
91
|
+
throw new Error(`Team ${params.team_name} does not exist`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const member: Member = {
|
|
95
|
+
agentId: `${params.name}@${params.team_name}`,
|
|
96
|
+
name: params.name,
|
|
97
|
+
agentType: "teammate",
|
|
98
|
+
model: "sonnet",
|
|
99
|
+
joinedAt: Date.now(),
|
|
100
|
+
tmuxPaneId: "",
|
|
101
|
+
cwd: params.cwd,
|
|
102
|
+
subscriptions: [],
|
|
103
|
+
prompt: params.prompt,
|
|
104
|
+
color: "blue",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
await teams.addMember(params.team_name, member);
|
|
108
|
+
await messaging.sendPlainMessage(params.team_name, "team-lead", params.name, params.prompt, "Initial prompt");
|
|
109
|
+
|
|
110
|
+
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
|
+
|
|
113
|
+
let paneId = "";
|
|
114
|
+
try {
|
|
115
|
+
const tmuxCmd = `tmux split-window -h -dP -F "#{pane_id}" "cd ${params.cwd} && ${cmd}"`;
|
|
116
|
+
paneId = execSync(tmuxCmd).toString().trim();
|
|
117
|
+
execSync(`tmux select-layout even-horizontal`);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw new Error(`Failed to spawn tmux pane: ${e}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Update member with paneId
|
|
123
|
+
await teams.updateMember(params.team_name, params.name, { tmuxPaneId: paneId });
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: `Teammate ${params.name} spawned in pane ${paneId}.` }],
|
|
127
|
+
details: { agentId: member.agentId, paneId },
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.registerTool({
|
|
133
|
+
name: "send_message",
|
|
134
|
+
label: "Send Message",
|
|
135
|
+
description: "Send a message to a teammate.",
|
|
136
|
+
parameters: Type.Object({
|
|
137
|
+
team_name: Type.String(),
|
|
138
|
+
recipient: Type.String(),
|
|
139
|
+
content: Type.String(),
|
|
140
|
+
summary: Type.String(),
|
|
141
|
+
}),
|
|
142
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
143
|
+
await messaging.sendPlainMessage(params.team_name, agentName, params.recipient, params.content, params.summary);
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: `Message sent to ${params.recipient}.` }],
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.registerTool({
|
|
151
|
+
name: "read_inbox",
|
|
152
|
+
label: "Read Inbox",
|
|
153
|
+
description: "Read messages from an agent's inbox.",
|
|
154
|
+
parameters: Type.Object({
|
|
155
|
+
team_name: Type.String(),
|
|
156
|
+
agent_name: Type.Optional(Type.String({ description: "Whose inbox to read. Defaults to your own." })),
|
|
157
|
+
unread_only: Type.Optional(Type.Boolean({ default: true })),
|
|
158
|
+
}),
|
|
159
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
160
|
+
const targetAgent = params.agent_name || agentName;
|
|
161
|
+
const msgs = await messaging.readInbox(params.team_name, targetAgent, params.unread_only);
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: "text", text: JSON.stringify(msgs, null, 2) }],
|
|
164
|
+
details: { messages: msgs },
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.registerTool({
|
|
170
|
+
name: "task_create",
|
|
171
|
+
label: "Create Task",
|
|
172
|
+
description: "Create a new team task.",
|
|
173
|
+
parameters: Type.Object({
|
|
174
|
+
team_name: Type.String(),
|
|
175
|
+
subject: Type.String(),
|
|
176
|
+
description: Type.String(),
|
|
177
|
+
}),
|
|
178
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
179
|
+
const task = await tasks.createTask(params.team_name, params.subject, params.description);
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: "text", text: `Task ${task.id} created.` }],
|
|
182
|
+
details: { task },
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
pi.registerTool({
|
|
188
|
+
name: "task_list",
|
|
189
|
+
label: "List Tasks",
|
|
190
|
+
description: "List all team tasks.",
|
|
191
|
+
parameters: Type.Object({
|
|
192
|
+
team_name: Type.String(),
|
|
193
|
+
}),
|
|
194
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
195
|
+
const taskList = await tasks.listTasks(params.team_name);
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text", text: JSON.stringify(taskList, null, 2) }],
|
|
198
|
+
details: { tasks: taskList },
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.registerTool({
|
|
204
|
+
name: "task_update",
|
|
205
|
+
label: "Update Task",
|
|
206
|
+
description: "Update a task's status or owner.",
|
|
207
|
+
parameters: Type.Object({
|
|
208
|
+
team_name: Type.String(),
|
|
209
|
+
task_id: Type.String(),
|
|
210
|
+
status: Type.Optional(StringEnum(["pending", "in_progress", "completed", "deleted"])),
|
|
211
|
+
owner: Type.Optional(Type.String()),
|
|
212
|
+
}),
|
|
213
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
214
|
+
const updated = await tasks.updateTask(params.team_name, params.task_id, {
|
|
215
|
+
status: params.status as any,
|
|
216
|
+
owner: params.owner,
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text", text: `Task ${params.task_id} updated.` }],
|
|
220
|
+
details: { task: updated },
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.registerTool({
|
|
226
|
+
name: "team_delete",
|
|
227
|
+
label: "Delete Team",
|
|
228
|
+
description: "Delete a team and all its data.",
|
|
229
|
+
parameters: Type.Object({
|
|
230
|
+
team_name: Type.String(),
|
|
231
|
+
}),
|
|
232
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
233
|
+
const dir = paths.teamDir(params.team_name);
|
|
234
|
+
const tasksDir = paths.taskDir(params.team_name);
|
|
235
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
|
|
236
|
+
if (fs.existsSync(tasksDir)) fs.rmSync(tasksDir, { recursive: true });
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text", text: `Team ${params.team_name} deleted.` }],
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
pi.registerTool({
|
|
244
|
+
name: "read_config",
|
|
245
|
+
label: "Read Config",
|
|
246
|
+
description: "Read the current team configuration.",
|
|
247
|
+
parameters: Type.Object({
|
|
248
|
+
team_name: Type.String(),
|
|
249
|
+
}),
|
|
250
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
251
|
+
const config = await teams.readConfig(params.team_name);
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
|
|
254
|
+
details: { config },
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
pi.registerTool({
|
|
260
|
+
name: "task_get",
|
|
261
|
+
label: "Get Task",
|
|
262
|
+
description: "Get full details of a specific task by ID.",
|
|
263
|
+
parameters: Type.Object({
|
|
264
|
+
team_name: Type.String(),
|
|
265
|
+
task_id: Type.String(),
|
|
266
|
+
}),
|
|
267
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
268
|
+
const task = await tasks.readTask(params.team_name, params.task_id);
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
|
|
271
|
+
details: { task },
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
pi.registerTool({
|
|
277
|
+
name: "force_kill_teammate",
|
|
278
|
+
label: "Force Kill Teammate",
|
|
279
|
+
description: "Forcibly kill a teammate's tmux target.",
|
|
280
|
+
parameters: Type.Object({
|
|
281
|
+
team_name: Type.String(),
|
|
282
|
+
agent_name: Type.String(),
|
|
283
|
+
}),
|
|
284
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
285
|
+
const config = await teams.readConfig(params.team_name);
|
|
286
|
+
const member = config.members.find(m => m.name === params.agent_name);
|
|
287
|
+
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
|
|
288
|
+
if (member.tmuxPaneId) {
|
|
289
|
+
try {
|
|
290
|
+
execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
await teams.removeMember(params.team_name, params.agent_name);
|
|
296
|
+
await tasks.resetOwnerTasks(params.team_name, params.agent_name);
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: "text", text: `${params.agent_name} has been stopped.` }],
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
pi.registerTool({
|
|
304
|
+
name: "check_teammate",
|
|
305
|
+
label: "Check Teammate",
|
|
306
|
+
description: "Check a single teammate's status.",
|
|
307
|
+
parameters: Type.Object({
|
|
308
|
+
team_name: Type.String(),
|
|
309
|
+
agent_name: Type.String(),
|
|
310
|
+
}),
|
|
311
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
312
|
+
const config = await teams.readConfig(params.team_name);
|
|
313
|
+
const member = config.members.find(m => m.name === params.agent_name);
|
|
314
|
+
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
|
|
315
|
+
|
|
316
|
+
let alive = false;
|
|
317
|
+
if (member.tmuxPaneId) {
|
|
318
|
+
try {
|
|
319
|
+
execSync(`tmux has-session -t ${member.tmuxPaneId}`);
|
|
320
|
+
alive = true;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
alive = false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
content: [{ type: "text", text: JSON.stringify({ alive, unreadCount }, null, 2) }],
|
|
330
|
+
details: { alive, unreadCount },
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
pi.registerTool({
|
|
336
|
+
name: "process_shutdown_approved",
|
|
337
|
+
label: "Process Shutdown Approved",
|
|
338
|
+
description: "Process a teammate's shutdown.",
|
|
339
|
+
parameters: Type.Object({
|
|
340
|
+
team_name: Type.String(),
|
|
341
|
+
agent_name: Type.String(),
|
|
342
|
+
}),
|
|
343
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
344
|
+
const config = await teams.readConfig(params.team_name);
|
|
345
|
+
const member = config.members.find(m => m.name === params.agent_name);
|
|
346
|
+
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
|
|
347
|
+
if (member.tmuxPaneId) {
|
|
348
|
+
try {
|
|
349
|
+
execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
// ignore
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
await teams.removeMember(params.team_name, params.agent_name);
|
|
355
|
+
await tasks.resetOwnerTasks(params.team_name, params.agent_name);
|
|
356
|
+
return {
|
|
357
|
+
content: [{ type: "text", text: `${params.agent_name} removed from team.` }],
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-teams",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent teams for pi, ported from claude-code-teams-mcp",
|
|
5
|
+
"repository": "github:burggraf/pi-teams",
|
|
6
|
+
"author": "Mark Burggraf",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["pi-package"],
|
|
9
|
+
"main": "extensions/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"extensions",
|
|
12
|
+
"skills",
|
|
13
|
+
"src",
|
|
14
|
+
"package.json",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"uuid": "^11.1.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
22
|
+
"@sinclair/typebox": "*"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": ["extensions/index.ts"],
|
|
26
|
+
"skills": ["skills"]
|
|
27
|
+
}
|
|
28
|
+
}
|
package/skills/teams.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or iTerm2.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Agent Teams
|
|
6
|
+
|
|
7
|
+
Coordinate multiple agents working on a project using shared task lists and messaging.
|
|
8
|
+
|
|
9
|
+
## Workflow
|
|
10
|
+
|
|
11
|
+
1. **Create a team**: Use `team_create(team_name="my-team")`.
|
|
12
|
+
2. **Spawn teammates**: Use `spawn_teammate` to start additional agents. Give them specific roles and initial prompts.
|
|
13
|
+
3. **Manage tasks**: Use `task_create` to define work, and `task_update` to assign it to teammates.
|
|
14
|
+
4. **Communicate**: Use `send_message` to give instructions or receive updates. Teammates should use `read_inbox` to check for messages.
|
|
15
|
+
5. **Monitor**: Use `check_teammate` to see if they are still running and if they have sent messages back.
|
|
16
|
+
|
|
17
|
+
## Teammate Instructions
|
|
18
|
+
|
|
19
|
+
When you are spawned as a teammate:
|
|
20
|
+
- Your status bar will show "Teammate: name @ team".
|
|
21
|
+
- Always start by calling `read_inbox` to get your initial instructions.
|
|
22
|
+
- Regularly check `read_inbox` for updates from the lead.
|
|
23
|
+
- Use `send_message` to "team-lead" to report progress or ask questions.
|
|
24
|
+
- Update your assigned tasks using `task_update`.
|
|
25
|
+
|
|
26
|
+
## Best Practices for Teammates
|
|
27
|
+
|
|
28
|
+
- **Update Task Status**: As you work, use `task_update` to set your tasks to `in_progress` and then `completed`.
|
|
29
|
+
- **Frequent Communication**: Send short summaries of your work back to `team-lead` frequently.
|
|
30
|
+
- **Context Matters**: When you finish a task, send a message explaining your results and any new files you created.
|
|
31
|
+
- **Independence**: If you get stuck, try to solve it yourself first, but don't hesitate to ask `team-lead` for clarification.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function withLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
5
|
+
const lockFile = `${lockPath}.lock`;
|
|
6
|
+
let retries = 50;
|
|
7
|
+
while (retries > 0) {
|
|
8
|
+
try {
|
|
9
|
+
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
|
|
10
|
+
break;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
retries--;
|
|
13
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (retries === 0) {
|
|
18
|
+
throw new Error("Could not acquire lock");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
return await fn();
|
|
23
|
+
} finally {
|
|
24
|
+
try {
|
|
25
|
+
fs.unlinkSync(lockFile);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { InboxMessage } from "./models";
|
|
4
|
+
import { withLock } from "./lock";
|
|
5
|
+
import { inboxPath } from "./paths";
|
|
6
|
+
|
|
7
|
+
export function nowIso(): string {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function appendMessage(teamName: string, agentName: string, message: InboxMessage) {
|
|
12
|
+
const p = inboxPath(teamName, agentName);
|
|
13
|
+
const dir = path.dirname(p);
|
|
14
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
await withLock(p, async () => {
|
|
17
|
+
let msgs: InboxMessage[] = [];
|
|
18
|
+
if (fs.existsSync(p)) {
|
|
19
|
+
msgs = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
msgs.push(message);
|
|
22
|
+
fs.writeFileSync(p, JSON.stringify(msgs, null, 2));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readInbox(
|
|
27
|
+
teamName: string,
|
|
28
|
+
agentName: string,
|
|
29
|
+
unreadOnly = false,
|
|
30
|
+
markAsRead = true
|
|
31
|
+
): Promise<InboxMessage[]> {
|
|
32
|
+
const p = inboxPath(teamName, agentName);
|
|
33
|
+
if (!fs.existsSync(p)) return [];
|
|
34
|
+
|
|
35
|
+
return await withLock(p, async () => {
|
|
36
|
+
const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
37
|
+
let result = allMsgs;
|
|
38
|
+
|
|
39
|
+
if (unreadOnly) {
|
|
40
|
+
result = allMsgs.filter(m => !m.read);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (markAsRead && result.length > 0) {
|
|
44
|
+
for (const m of allMsgs) {
|
|
45
|
+
if (result.includes(m)) {
|
|
46
|
+
m.read = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function sendPlainMessage(
|
|
57
|
+
teamName: string,
|
|
58
|
+
fromName: string,
|
|
59
|
+
toName: string,
|
|
60
|
+
text: string,
|
|
61
|
+
summary: string,
|
|
62
|
+
color?: string
|
|
63
|
+
) {
|
|
64
|
+
const msg: InboxMessage = {
|
|
65
|
+
from: fromName,
|
|
66
|
+
text,
|
|
67
|
+
timestamp: nowIso(),
|
|
68
|
+
read: false,
|
|
69
|
+
summary,
|
|
70
|
+
color,
|
|
71
|
+
};
|
|
72
|
+
await appendMessage(teamName, toName, msg);
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface Member {
|
|
2
|
+
agentId: string;
|
|
3
|
+
name: string;
|
|
4
|
+
agentType: string;
|
|
5
|
+
model: string;
|
|
6
|
+
joinedAt: number;
|
|
7
|
+
tmuxPaneId: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
subscriptions: any[];
|
|
10
|
+
prompt?: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
planModeRequired?: boolean;
|
|
13
|
+
backendType?: string;
|
|
14
|
+
isActive?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TeamConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
leadAgentId: string;
|
|
22
|
+
leadSessionId: string;
|
|
23
|
+
members: Member[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TaskFile {
|
|
27
|
+
id: string;
|
|
28
|
+
subject: string;
|
|
29
|
+
description: string;
|
|
30
|
+
activeForm?: string;
|
|
31
|
+
status: "pending" | "in_progress" | "completed" | "deleted";
|
|
32
|
+
blocks: string[];
|
|
33
|
+
blockedBy: string[];
|
|
34
|
+
owner?: string;
|
|
35
|
+
metadata?: Record<string, any>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InboxMessage {
|
|
39
|
+
from: string;
|
|
40
|
+
text: string;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
read: boolean;
|
|
43
|
+
summary?: string;
|
|
44
|
+
color?: string;
|
|
45
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
6
|
+
export const TEAMS_DIR = path.join(CLAUDE_DIR, "teams");
|
|
7
|
+
export const TASKS_DIR = path.join(CLAUDE_DIR, "tasks");
|
|
8
|
+
|
|
9
|
+
export function ensureDirs() {
|
|
10
|
+
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR);
|
|
11
|
+
if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR);
|
|
12
|
+
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function teamDir(teamName: string) {
|
|
16
|
+
return path.join(TEAMS_DIR, teamName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function taskDir(teamName: string) {
|
|
20
|
+
return path.join(TASKS_DIR, teamName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function inboxPath(teamName: string, agentName: string) {
|
|
24
|
+
return path.join(teamDir(teamName), "inboxes", `${agentName}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function configPath(teamName: string) {
|
|
28
|
+
return path.join(teamDir(teamName), "config.json");
|
|
29
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TaskFile } from "./models";
|
|
4
|
+
import { taskDir } from "./paths";
|
|
5
|
+
import { teamExists } from "./teams";
|
|
6
|
+
import { withLock } from "./lock";
|
|
7
|
+
|
|
8
|
+
export function getTaskId(teamName: string): string {
|
|
9
|
+
const dir = taskDir(teamName);
|
|
10
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
11
|
+
const ids = files.map(f => parseInt(path.parse(f).name, 10)).filter(id => !isNaN(id));
|
|
12
|
+
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createTask(
|
|
16
|
+
teamName: string,
|
|
17
|
+
subject: string,
|
|
18
|
+
description: string,
|
|
19
|
+
activeForm = "",
|
|
20
|
+
metadata?: Record<string, any>
|
|
21
|
+
): Promise<TaskFile> {
|
|
22
|
+
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
|
|
23
|
+
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
|
|
24
|
+
|
|
25
|
+
const dir = taskDir(teamName);
|
|
26
|
+
const lockPath = dir;
|
|
27
|
+
|
|
28
|
+
return await withLock(lockPath, async () => {
|
|
29
|
+
const id = getTaskId(teamName);
|
|
30
|
+
const task: TaskFile = {
|
|
31
|
+
id,
|
|
32
|
+
subject,
|
|
33
|
+
description,
|
|
34
|
+
activeForm,
|
|
35
|
+
status: "pending",
|
|
36
|
+
blocks: [],
|
|
37
|
+
blockedBy: [],
|
|
38
|
+
metadata,
|
|
39
|
+
};
|
|
40
|
+
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2));
|
|
41
|
+
return task;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function updateTask(
|
|
46
|
+
teamName: string,
|
|
47
|
+
taskId: string,
|
|
48
|
+
updates: Partial<TaskFile>
|
|
49
|
+
): Promise<TaskFile> {
|
|
50
|
+
const dir = taskDir(teamName);
|
|
51
|
+
const lockPath = path.join(dir, taskId);
|
|
52
|
+
const p = path.join(dir, `${taskId}.json`);
|
|
53
|
+
|
|
54
|
+
return await withLock(lockPath, async () => {
|
|
55
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
56
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
57
|
+
const updated = { ...task, ...updates };
|
|
58
|
+
|
|
59
|
+
if (updates.status === "deleted") {
|
|
60
|
+
fs.unlinkSync(p);
|
|
61
|
+
return updated;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
65
|
+
return updated;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function readTask(teamName: string, taskId: string): Promise<TaskFile> {
|
|
70
|
+
const dir = taskDir(teamName);
|
|
71
|
+
const p = path.join(dir, `${taskId}.json`);
|
|
72
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
73
|
+
return await withLock(p, async () => {
|
|
74
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function listTasks(teamName: string): Promise<TaskFile[]> {
|
|
79
|
+
const dir = taskDir(teamName);
|
|
80
|
+
return await withLock(dir, async () => {
|
|
81
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
82
|
+
const tasks: TaskFile[] = files
|
|
83
|
+
.map(f => {
|
|
84
|
+
const id = parseInt(path.parse(f).name, 10);
|
|
85
|
+
if (isNaN(id)) return null;
|
|
86
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
87
|
+
})
|
|
88
|
+
.filter(t => t !== null);
|
|
89
|
+
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function resetOwnerTasks(teamName: string, agentName: string) {
|
|
94
|
+
const dir = taskDir(teamName);
|
|
95
|
+
const lockPath = dir;
|
|
96
|
+
|
|
97
|
+
await withLock(lockPath, async () => {
|
|
98
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
const p = path.join(dir, f);
|
|
101
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
102
|
+
if (task.owner === agentName) {
|
|
103
|
+
task.owner = undefined;
|
|
104
|
+
if (task.status !== "completed") {
|
|
105
|
+
task.status = "pending";
|
|
106
|
+
}
|
|
107
|
+
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TeamConfig, Member } from "./models";
|
|
4
|
+
import { configPath, teamDir, taskDir } from "./paths";
|
|
5
|
+
import { withLock } from "./lock";
|
|
6
|
+
|
|
7
|
+
export function teamExists(teamName: string) {
|
|
8
|
+
return fs.existsSync(configPath(teamName));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createTeam(
|
|
12
|
+
name: string,
|
|
13
|
+
sessionId: string,
|
|
14
|
+
leadAgentId: string,
|
|
15
|
+
description = ""
|
|
16
|
+
): TeamConfig {
|
|
17
|
+
const dir = teamDir(name);
|
|
18
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
|
|
20
|
+
const tasksDir = taskDir(name);
|
|
21
|
+
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
const leadMember: Member = {
|
|
24
|
+
agentId: leadAgentId,
|
|
25
|
+
name: "team-lead",
|
|
26
|
+
agentType: "lead",
|
|
27
|
+
model: "unknown",
|
|
28
|
+
joinedAt: Date.now(),
|
|
29
|
+
tmuxPaneId: process.env.TMUX_PANE || "",
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
subscriptions: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const config: TeamConfig = {
|
|
35
|
+
name,
|
|
36
|
+
description,
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
leadAgentId,
|
|
39
|
+
leadSessionId: sessionId,
|
|
40
|
+
members: [leadMember],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readConfigRaw(p: string): TeamConfig {
|
|
48
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readConfig(teamName: string): Promise<TeamConfig> {
|
|
52
|
+
const p = configPath(teamName);
|
|
53
|
+
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
|
|
54
|
+
return await withLock(p, async () => {
|
|
55
|
+
return readConfigRaw(p);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function addMember(teamName: string, member: Member) {
|
|
60
|
+
const p = configPath(teamName);
|
|
61
|
+
await withLock(p, async () => {
|
|
62
|
+
const config = readConfigRaw(p);
|
|
63
|
+
config.members.push(member);
|
|
64
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removeMember(teamName: string, agentName: string) {
|
|
69
|
+
const p = configPath(teamName);
|
|
70
|
+
await withLock(p, async () => {
|
|
71
|
+
const config = readConfigRaw(p);
|
|
72
|
+
config.members = config.members.filter(m => m.name !== agentName);
|
|
73
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function updateMember(teamName: string, agentName: string, updates: Partial<Member>) {
|
|
78
|
+
const p = configPath(teamName);
|
|
79
|
+
await withLock(p, async () => {
|
|
80
|
+
const config = readConfigRaw(p);
|
|
81
|
+
const m = config.members.find(m => m.name === agentName);
|
|
82
|
+
if (m) {
|
|
83
|
+
Object.assign(m, updates);
|
|
84
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|