@tmustier/pi-agent-teams 0.1.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/LICENSE +21 -0
- package/README.md +166 -0
- package/docs/claude-parity.md +109 -0
- package/docs/field-notes-teams-setup.md +105 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +71 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader.ts +1640 -0
- package/extensions/teams/mailbox.ts +106 -0
- package/extensions/teams/paths.ts +22 -0
- package/extensions/teams/task-store.ts +529 -0
- package/extensions/teams/tasks.ts +95 -0
- package/extensions/teams/team-config.ts +228 -0
- package/extensions/teams/teammate-rpc.ts +203 -0
- package/extensions/teams/worker.ts +488 -0
- package/extensions/teams/worktree.ts +106 -0
- package/package.json +29 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/smoke-test.mjs +199 -0
- package/scripts/start-tmux-team.sh +91 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
4
|
+
import { getTeamDir } from "./paths.js";
|
|
5
|
+
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
6
|
+
import {
|
|
7
|
+
claimNextAvailableTask,
|
|
8
|
+
completeTask,
|
|
9
|
+
getTask,
|
|
10
|
+
isTaskBlocked,
|
|
11
|
+
startAssignedTask,
|
|
12
|
+
unassignTasksForAgent,
|
|
13
|
+
updateTask,
|
|
14
|
+
type TeamTask,
|
|
15
|
+
} from "./task-store.js";
|
|
16
|
+
|
|
17
|
+
const TEAM_MAILBOX_NS = "team";
|
|
18
|
+
|
|
19
|
+
function sleep(ms: number): Promise<void> {
|
|
20
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitize(name: string): string {
|
|
24
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function teamDirFromEnv(): {
|
|
28
|
+
teamId: string;
|
|
29
|
+
teamDir: string;
|
|
30
|
+
taskListId: string;
|
|
31
|
+
agentName: string;
|
|
32
|
+
leadName: string;
|
|
33
|
+
autoClaim: boolean;
|
|
34
|
+
} | null {
|
|
35
|
+
const teamId = process.env.PI_TEAMS_TEAM_ID;
|
|
36
|
+
const agentNameRaw = process.env.PI_TEAMS_AGENT_NAME;
|
|
37
|
+
if (!teamId || !agentNameRaw) return null;
|
|
38
|
+
|
|
39
|
+
const agentName = sanitize(agentNameRaw);
|
|
40
|
+
const taskListId = process.env.PI_TEAMS_TASK_LIST_ID ?? teamId;
|
|
41
|
+
const leadName = sanitize(process.env.PI_TEAMS_LEAD_NAME ?? "team-lead");
|
|
42
|
+
const autoClaim = (process.env.PI_TEAMS_AUTO_CLAIM ?? "1") === "1";
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
teamId,
|
|
46
|
+
teamDir: getTeamDir(teamId),
|
|
47
|
+
taskListId,
|
|
48
|
+
agentName,
|
|
49
|
+
leadName,
|
|
50
|
+
autoClaim,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractLastAssistantText(messages: AgentMessage[]): string {
|
|
55
|
+
const assistant = messages.filter((m: any) => m && typeof m === "object" && m.role === "assistant");
|
|
56
|
+
const last: any = assistant[assistant.length - 1];
|
|
57
|
+
if (!last) return "";
|
|
58
|
+
|
|
59
|
+
const content = last.content;
|
|
60
|
+
if (typeof content === "string") return content;
|
|
61
|
+
if (Array.isArray(content)) {
|
|
62
|
+
return content
|
|
63
|
+
.filter((c) => c && typeof c === "object" && c.type === "text" && typeof (c as any).text === "string")
|
|
64
|
+
.map((c: any) => c.text)
|
|
65
|
+
.join("");
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildTaskPrompt(agentName: string, task: TeamTask): string {
|
|
71
|
+
return [
|
|
72
|
+
`You are teammate '${agentName}'.`,
|
|
73
|
+
`You have been assigned task #${task.id}.`,
|
|
74
|
+
`Subject: ${task.subject}`,
|
|
75
|
+
"",
|
|
76
|
+
`Description:\n${task.description}`,
|
|
77
|
+
"",
|
|
78
|
+
"Do the work now. When finished, reply with a concise summary and any key outputs.",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTaskAssignmentMessage(text: string): { taskId: string; subject?: string; description?: string; assignedBy?: string } | null {
|
|
83
|
+
try {
|
|
84
|
+
const obj = JSON.parse(text);
|
|
85
|
+
if (!obj || typeof obj !== "object") return null;
|
|
86
|
+
if (obj.type !== "task_assignment") return null;
|
|
87
|
+
if (typeof obj.taskId !== "string") return null;
|
|
88
|
+
return {
|
|
89
|
+
taskId: obj.taskId,
|
|
90
|
+
subject: typeof obj.subject === "string" ? obj.subject : undefined,
|
|
91
|
+
description: typeof obj.description === "string" ? obj.description : undefined,
|
|
92
|
+
assignedBy: typeof obj.assignedBy === "string" ? obj.assignedBy : undefined,
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isShutdownRequestMessage(text: string): { requestId: string; from?: string; reason?: string; timestamp?: string } | null {
|
|
100
|
+
try {
|
|
101
|
+
const obj = JSON.parse(text);
|
|
102
|
+
if (!obj || typeof obj !== "object") return null;
|
|
103
|
+
if (obj.type !== "shutdown_request") return null;
|
|
104
|
+
if (typeof obj.requestId !== "string") return null;
|
|
105
|
+
return {
|
|
106
|
+
requestId: obj.requestId,
|
|
107
|
+
from: typeof obj.from === "string" ? obj.from : undefined,
|
|
108
|
+
reason: typeof obj.reason === "string" ? obj.reason : undefined,
|
|
109
|
+
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isSetSessionNameMessage(text: string): { name: string } | null {
|
|
117
|
+
try {
|
|
118
|
+
const obj = JSON.parse(text);
|
|
119
|
+
if (!obj || typeof obj !== "object") return null;
|
|
120
|
+
if (obj.type !== "set_session_name") return null;
|
|
121
|
+
if (typeof obj.name !== "string") return null;
|
|
122
|
+
return { name: obj.name };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isAbortRequestMessage(
|
|
129
|
+
text: string,
|
|
130
|
+
): { requestId: string; from?: string; taskId?: string; reason?: string; timestamp?: string } | null {
|
|
131
|
+
try {
|
|
132
|
+
const obj = JSON.parse(text);
|
|
133
|
+
if (!obj || typeof obj !== "object") return null;
|
|
134
|
+
if (obj.type !== "abort_request") return null;
|
|
135
|
+
if (typeof obj.requestId !== "string") return null;
|
|
136
|
+
return {
|
|
137
|
+
requestId: obj.requestId,
|
|
138
|
+
from: typeof obj.from === "string" ? obj.from : undefined,
|
|
139
|
+
taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
|
|
140
|
+
reason: typeof obj.reason === "string" ? obj.reason : undefined,
|
|
141
|
+
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function runWorker(pi: ExtensionAPI): void {
|
|
149
|
+
const env = teamDirFromEnv();
|
|
150
|
+
if (!env) return;
|
|
151
|
+
|
|
152
|
+
let ctxRef: ExtensionContext | null = null;
|
|
153
|
+
let isStreaming = false;
|
|
154
|
+
let isDeciding = false;
|
|
155
|
+
let currentTaskId: string | null = null;
|
|
156
|
+
let pendingTaskAssignments: string[] = [];
|
|
157
|
+
let pendingDmTexts: string[] = [];
|
|
158
|
+
let pollAbort = false;
|
|
159
|
+
let shutdownInProgress = false;
|
|
160
|
+
const seenShutdownRequestIds = new Set<string>();
|
|
161
|
+
|
|
162
|
+
let abortTaskId: string | null = null;
|
|
163
|
+
let abortReason: string | undefined;
|
|
164
|
+
let abortRequestId: string | null = null;
|
|
165
|
+
const seenAbortRequestIds = new Set<string>();
|
|
166
|
+
|
|
167
|
+
const { teamId, teamDir, taskListId, agentName, leadName, autoClaim } = env;
|
|
168
|
+
|
|
169
|
+
const poll = async () => {
|
|
170
|
+
while (!pollAbort) {
|
|
171
|
+
try {
|
|
172
|
+
// Two namespaces (Claude-style):
|
|
173
|
+
// - team namespace for DM/idle notifications
|
|
174
|
+
// - taskListId namespace for task_assignment pings
|
|
175
|
+
const [teamMsgs, taskMsgs] = await Promise.all([
|
|
176
|
+
popUnreadMessages(teamDir, TEAM_MAILBOX_NS, agentName),
|
|
177
|
+
popUnreadMessages(teamDir, taskListId, agentName),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
for (const m of [...taskMsgs, ...teamMsgs]) {
|
|
181
|
+
const shutdown = isShutdownRequestMessage(m.text);
|
|
182
|
+
if (shutdown && !seenShutdownRequestIds.has(shutdown.requestId)) {
|
|
183
|
+
seenShutdownRequestIds.add(shutdown.requestId);
|
|
184
|
+
shutdownInProgress = true;
|
|
185
|
+
pollAbort = true;
|
|
186
|
+
|
|
187
|
+
const ts = new Date().toISOString();
|
|
188
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
|
|
189
|
+
from: agentName,
|
|
190
|
+
text: JSON.stringify({
|
|
191
|
+
type: "shutdown_approved",
|
|
192
|
+
requestId: shutdown.requestId,
|
|
193
|
+
from: agentName,
|
|
194
|
+
timestamp: ts,
|
|
195
|
+
}),
|
|
196
|
+
timestamp: ts,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await cleanup("shutdown requested");
|
|
201
|
+
} catch {
|
|
202
|
+
// ignore
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
ctxRef?.abort();
|
|
207
|
+
} catch {
|
|
208
|
+
// ignore
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
ctxRef?.shutdown();
|
|
212
|
+
} catch {
|
|
213
|
+
// ignore
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const setName = isSetSessionNameMessage(m.text);
|
|
219
|
+
if (setName) {
|
|
220
|
+
const desired = setName.name.trim();
|
|
221
|
+
if (desired) {
|
|
222
|
+
try {
|
|
223
|
+
const existing = pi.getSessionName?.();
|
|
224
|
+
// Only overwrite sessions that are unnamed or already managed by us.
|
|
225
|
+
if (!existing || existing.startsWith("pi agent teams -")) {
|
|
226
|
+
if (existing !== desired) pi.setSessionName(desired);
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const abortReq = isAbortRequestMessage(m.text);
|
|
236
|
+
if (abortReq && !seenAbortRequestIds.has(abortReq.requestId)) {
|
|
237
|
+
seenAbortRequestIds.add(abortReq.requestId);
|
|
238
|
+
|
|
239
|
+
// If the request targets a specific task and we're busy on a different one, ignore.
|
|
240
|
+
if (abortReq.taskId && currentTaskId && abortReq.taskId !== currentTaskId) continue;
|
|
241
|
+
|
|
242
|
+
if (currentTaskId) {
|
|
243
|
+
abortTaskId = currentTaskId;
|
|
244
|
+
abortReason = abortReq.reason;
|
|
245
|
+
abortRequestId = abortReq.requestId;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
ctxRef?.abort();
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const assign = isTaskAssignmentMessage(m.text);
|
|
257
|
+
if (assign) {
|
|
258
|
+
pendingTaskAssignments.push(assign.taskId);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Plain DM (or unknown structured message)
|
|
262
|
+
pendingDmTexts.push(m.text);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!shutdownInProgress) await maybeStartNextWork();
|
|
266
|
+
} catch {
|
|
267
|
+
// ignore polling errors
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await sleep(350);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const maybeStartNextWork = async () => {
|
|
275
|
+
if (!ctxRef) return;
|
|
276
|
+
if (shutdownInProgress) return;
|
|
277
|
+
if (isStreaming) return;
|
|
278
|
+
if (currentTaskId) return;
|
|
279
|
+
if (isDeciding) return;
|
|
280
|
+
|
|
281
|
+
isDeciding = true;
|
|
282
|
+
try {
|
|
283
|
+
// 1) Assigned tasks
|
|
284
|
+
const requeue: string[] = [];
|
|
285
|
+
while (pendingTaskAssignments.length) {
|
|
286
|
+
const taskId = pendingTaskAssignments.shift()!;
|
|
287
|
+
const task = await getTask(teamDir, taskListId, taskId);
|
|
288
|
+
if (!task) continue;
|
|
289
|
+
if (task.owner !== agentName) continue;
|
|
290
|
+
if (task.status === "completed") continue;
|
|
291
|
+
|
|
292
|
+
// Respect deps: don't start assigned tasks until unblocked.
|
|
293
|
+
if (await isTaskBlocked(teamDir, taskListId, task)) {
|
|
294
|
+
requeue.push(taskId);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Mark in_progress if needed
|
|
299
|
+
if (task.status === "pending") await startAssignedTask(teamDir, taskListId, taskId, agentName);
|
|
300
|
+
|
|
301
|
+
currentTaskId = taskId;
|
|
302
|
+
isStreaming = true; // optimistic; agent_start will follow
|
|
303
|
+
pi.sendUserMessage(buildTaskPrompt(agentName, task));
|
|
304
|
+
pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
|
|
308
|
+
|
|
309
|
+
// 2) DMs
|
|
310
|
+
if (pendingDmTexts.length) {
|
|
311
|
+
const text = pendingDmTexts.join("\n\n---\n\n");
|
|
312
|
+
pendingDmTexts = [];
|
|
313
|
+
isStreaming = true;
|
|
314
|
+
pi.sendUserMessage([
|
|
315
|
+
{ type: "text", text: "You have received teammate message(s):" },
|
|
316
|
+
{ type: "text", text },
|
|
317
|
+
]);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 3) Auto-claim
|
|
322
|
+
if (autoClaim) {
|
|
323
|
+
const claimed = await claimNextAvailableTask(teamDir, taskListId, agentName, { checkAgentBusy: true });
|
|
324
|
+
if (claimed) {
|
|
325
|
+
currentTaskId = claimed.id;
|
|
326
|
+
isStreaming = true;
|
|
327
|
+
pi.sendUserMessage(buildTaskPrompt(agentName, claimed));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} finally {
|
|
332
|
+
isDeciding = false;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const sendIdleNotification = async (
|
|
337
|
+
completedTaskId?: string,
|
|
338
|
+
completedStatus?: "completed" | "failed",
|
|
339
|
+
failureReason?: string,
|
|
340
|
+
) => {
|
|
341
|
+
const payload: any = {
|
|
342
|
+
type: "idle_notification",
|
|
343
|
+
from: agentName,
|
|
344
|
+
timestamp: new Date().toISOString(),
|
|
345
|
+
};
|
|
346
|
+
if (completedTaskId) payload.completedTaskId = completedTaskId;
|
|
347
|
+
if (completedStatus) payload.completedStatus = completedStatus;
|
|
348
|
+
if (failureReason) payload.failureReason = failureReason;
|
|
349
|
+
|
|
350
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
|
|
351
|
+
from: agentName,
|
|
352
|
+
text: JSON.stringify(payload),
|
|
353
|
+
timestamp: new Date().toISOString(),
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const cleanup = async (reason: string) => {
|
|
358
|
+
try {
|
|
359
|
+
await unassignTasksForAgent(teamDir, taskListId, agentName, reason);
|
|
360
|
+
} catch {
|
|
361
|
+
// ignore
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
366
|
+
ctxRef = ctx;
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
// Register ourselves in the shared team config so manual tmux workers are discoverable.
|
|
370
|
+
try {
|
|
371
|
+
const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName });
|
|
372
|
+
const now = new Date().toISOString();
|
|
373
|
+
if (!cfg.members.some((m) => m.name === agentName)) {
|
|
374
|
+
await upsertMember(teamDir, {
|
|
375
|
+
name: agentName,
|
|
376
|
+
role: "worker",
|
|
377
|
+
status: "online",
|
|
378
|
+
lastSeenAt: now,
|
|
379
|
+
cwd: ctx.cwd,
|
|
380
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
await setMemberStatus(teamDir, agentName, "online", { lastSeenAt: now });
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
// ignore config errors
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
void poll();
|
|
390
|
+
await maybeStartNextWork();
|
|
391
|
+
// Claude-style: let the leader know we're idle even if no task was completed yet.
|
|
392
|
+
if (!isStreaming && !currentTaskId) {
|
|
393
|
+
await sendIdleNotification();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
pi.on("session_shutdown", async () => {
|
|
398
|
+
pollAbort = true;
|
|
399
|
+
await cleanup("worker shutdown");
|
|
400
|
+
try {
|
|
401
|
+
await setMemberStatus(teamDir, agentName, "offline", { meta: { offlineReason: "worker shutdown" } });
|
|
402
|
+
} catch {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
await sendIdleNotification(undefined, undefined, "worker shutdown");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
pi.on("agent_start", async () => {
|
|
409
|
+
isStreaming = true;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
pi.on("agent_end", async (event) => {
|
|
413
|
+
isStreaming = false;
|
|
414
|
+
const taskId = currentTaskId;
|
|
415
|
+
currentTaskId = null;
|
|
416
|
+
|
|
417
|
+
let completedTaskId: string | undefined;
|
|
418
|
+
let completedStatus: "completed" | "failed" | undefined;
|
|
419
|
+
let failureReason: string | undefined;
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
if (taskId) {
|
|
423
|
+
const rawResult = extractLastAssistantText(event.messages as AgentMessage[]);
|
|
424
|
+
const trimmed = rawResult.trim();
|
|
425
|
+
const abortedByRequest = abortTaskId === taskId;
|
|
426
|
+
const aborted = abortedByRequest || trimmed.length === 0;
|
|
427
|
+
|
|
428
|
+
if (aborted) {
|
|
429
|
+
const ts = new Date().toISOString();
|
|
430
|
+
const extra: Record<string, unknown> = {
|
|
431
|
+
abortedAt: ts,
|
|
432
|
+
abortedBy: agentName,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
if (abortedByRequest) {
|
|
436
|
+
if (abortRequestId) extra.abortRequestId = abortRequestId;
|
|
437
|
+
extra.abortReason = abortReason ?? "abort requested";
|
|
438
|
+
if (trimmed.length > 0) extra.partialResult = rawResult;
|
|
439
|
+
} else {
|
|
440
|
+
extra.abortReason = "no assistant result";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
await updateTask(teamDir, taskListId, taskId, (cur) => {
|
|
444
|
+
if (cur.owner !== agentName) return cur;
|
|
445
|
+
if (cur.status === "completed") return cur;
|
|
446
|
+
|
|
447
|
+
const metadata = { ...(cur.metadata ?? {}) };
|
|
448
|
+
Object.assign(metadata, extra);
|
|
449
|
+
|
|
450
|
+
// Reset to pending, but keep owner. This avoids immediate re-claim loops after an abort.
|
|
451
|
+
return { ...cur, status: "pending", metadata };
|
|
452
|
+
});
|
|
453
|
+
completedTaskId = taskId;
|
|
454
|
+
completedStatus = "failed";
|
|
455
|
+
} else {
|
|
456
|
+
await completeTask(teamDir, taskListId, taskId, agentName, rawResult);
|
|
457
|
+
completedTaskId = taskId;
|
|
458
|
+
completedStatus = "completed";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
abortTaskId = null;
|
|
463
|
+
abortReason = undefined;
|
|
464
|
+
abortRequestId = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
await maybeStartNextWork();
|
|
468
|
+
|
|
469
|
+
// Only tell the leader we're idle if we truly didn't start more work.
|
|
470
|
+
if (!isStreaming && !currentTaskId) {
|
|
471
|
+
await sendIdleNotification(completedTaskId, completedStatus, failureReason);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Best-effort cleanup on SIGTERM (leader kill).
|
|
476
|
+
process.on("SIGTERM", () => {
|
|
477
|
+
pollAbort = true;
|
|
478
|
+
void (async () => {
|
|
479
|
+
await cleanup("SIGTERM");
|
|
480
|
+
try {
|
|
481
|
+
await setMemberStatus(teamDir, agentName, "offline", { meta: { offlineReason: "SIGTERM" } });
|
|
482
|
+
} catch {
|
|
483
|
+
// ignore
|
|
484
|
+
}
|
|
485
|
+
await sendIdleNotification(undefined, undefined, "SIGTERM");
|
|
486
|
+
})().finally(() => process.exit(0));
|
|
487
|
+
});
|
|
488
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
function sanitize(name: string): string {
|
|
6
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function execGit(args: string[], opts: { cwd: string; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> {
|
|
10
|
+
return await new Promise((resolve, reject) => {
|
|
11
|
+
execFile(
|
|
12
|
+
"git",
|
|
13
|
+
args,
|
|
14
|
+
{ cwd: opts.cwd, timeout: opts.timeoutMs ?? 30_000, maxBuffer: 10 * 1024 * 1024 },
|
|
15
|
+
(err, stdout, stderr) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
const msg = [
|
|
18
|
+
`git ${args.join(" ")} failed`,
|
|
19
|
+
`cwd=${opts.cwd}`,
|
|
20
|
+
stderr ? `stderr=${String(stderr).trim()}` : "",
|
|
21
|
+
err instanceof Error ? `error=${err.message}` : `error=${String(err)}`,
|
|
22
|
+
]
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join("\n");
|
|
25
|
+
reject(new Error(msg));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type WorktreeResult = {
|
|
35
|
+
cwd: string;
|
|
36
|
+
warnings: string[];
|
|
37
|
+
mode: "worktree" | "shared";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensure a per-teammate git worktree exists, returning the cwd to use for that teammate.
|
|
42
|
+
*
|
|
43
|
+
* Behavior:
|
|
44
|
+
* - If not in a git repo, falls back to shared cwd with a warning.
|
|
45
|
+
* - If git repo is dirty, still creates a worktree but warns that uncommitted changes are not included.
|
|
46
|
+
*/
|
|
47
|
+
export async function ensureWorktreeCwd(opts: {
|
|
48
|
+
leaderCwd: string;
|
|
49
|
+
teamDir: string;
|
|
50
|
+
teamId: string;
|
|
51
|
+
agentName: string;
|
|
52
|
+
}): Promise<WorktreeResult> {
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
let repoRoot: string;
|
|
55
|
+
try {
|
|
56
|
+
repoRoot = (await execGit(["rev-parse", "--show-toplevel"], { cwd: opts.leaderCwd })).stdout.trim();
|
|
57
|
+
if (!repoRoot) throw new Error("empty git toplevel");
|
|
58
|
+
} catch {
|
|
59
|
+
warnings.push("Not a git repository (or git unavailable). Using shared workspace instead of worktree.");
|
|
60
|
+
return { cwd: opts.leaderCwd, warnings, mode: "shared" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const status = (await execGit(["status", "--porcelain"], { cwd: repoRoot })).stdout;
|
|
65
|
+
if (status.trim().length) {
|
|
66
|
+
warnings.push(
|
|
67
|
+
"Git working directory is not clean. Worktree will be created from current HEAD and will NOT include your uncommitted changes.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore status errors
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const safeAgent = sanitize(opts.agentName);
|
|
75
|
+
const shortTeam = sanitize(opts.teamId).slice(0, 12) || "team";
|
|
76
|
+
const branch = `pi-teams/${shortTeam}/${safeAgent}`;
|
|
77
|
+
|
|
78
|
+
const worktreesDir = path.join(opts.teamDir, "worktrees");
|
|
79
|
+
const worktreePath = path.join(worktreesDir, safeAgent);
|
|
80
|
+
await fs.promises.mkdir(worktreesDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
// Reuse if it already exists.
|
|
83
|
+
if (fs.existsSync(worktreePath)) {
|
|
84
|
+
return { cwd: worktreePath, warnings, mode: "worktree" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Create worktree + new branch from HEAD
|
|
89
|
+
await execGit(["worktree", "add", "-b", branch, worktreePath, "HEAD"], { cwd: repoRoot, timeoutMs: 120_000 });
|
|
90
|
+
return { cwd: worktreePath, warnings, mode: "worktree" };
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
// If the branch already exists (e.g. previous run), try adding worktree using the existing branch.
|
|
94
|
+
if (msg.includes("already exists") || msg.includes("is already checked out")) {
|
|
95
|
+
try {
|
|
96
|
+
await execGit(["worktree", "add", worktreePath, branch], { cwd: repoRoot, timeoutMs: 120_000 });
|
|
97
|
+
return { cwd: worktreePath, warnings, mode: "worktree" };
|
|
98
|
+
} catch {
|
|
99
|
+
// fall through
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
warnings.push(`Failed to create git worktree (${branch}). Using shared workspace instead.`);
|
|
104
|
+
return { cwd: opts.leaderCwd, warnings, mode: "shared" };
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tmustier/pi-agent-teams",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Claude Code agent teams style workflow for Pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Thomas Mustier",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"keywords": ["pi-package"],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/tmustier/pi-agent-teams.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": "https://github.com/tmustier/pi-agent-teams/issues",
|
|
15
|
+
"homepage": "https://github.com/tmustier/pi-agent-teams",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./extensions"]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
24
|
+
"@mariozechner/pi-tui": "*",
|
|
25
|
+
"@mariozechner/pi-ai": "*",
|
|
26
|
+
"@mariozechner/pi-agent-core": "*",
|
|
27
|
+
"@sinclair/typebox": "*"
|
|
28
|
+
}
|
|
29
|
+
}
|