indus-swarms 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.
Files changed (52) hide show
  1. package/AGENTS.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/claude-parity.md +151 -0
  5. package/docs/field-notes-teams-setup.md +107 -0
  6. package/docs/smoke-test-plan.md +146 -0
  7. package/eslint.config.js +74 -0
  8. package/extensions/teams/README.md +23 -0
  9. package/extensions/teams/activity-tracker.ts +234 -0
  10. package/extensions/teams/cleanup.ts +31 -0
  11. package/extensions/teams/fs-lock.ts +87 -0
  12. package/extensions/teams/hooks.ts +363 -0
  13. package/extensions/teams/index.ts +18 -0
  14. package/extensions/teams/leader-attach-commands.ts +221 -0
  15. package/extensions/teams/leader-inbox.ts +214 -0
  16. package/extensions/teams/leader-info-commands.ts +140 -0
  17. package/extensions/teams/leader-lifecycle-commands.ts +559 -0
  18. package/extensions/teams/leader-messaging-commands.ts +148 -0
  19. package/extensions/teams/leader-plan-commands.ts +95 -0
  20. package/extensions/teams/leader-spawn-command.ts +149 -0
  21. package/extensions/teams/leader-task-commands.ts +435 -0
  22. package/extensions/teams/leader-team-command.ts +382 -0
  23. package/extensions/teams/leader-teams-tool.ts +1075 -0
  24. package/extensions/teams/leader.ts +925 -0
  25. package/extensions/teams/mailbox.ts +131 -0
  26. package/extensions/teams/model-policy.ts +142 -0
  27. package/extensions/teams/names.ts +121 -0
  28. package/extensions/teams/paths.ts +37 -0
  29. package/extensions/teams/protocol.ts +241 -0
  30. package/extensions/teams/spawn-types.ts +36 -0
  31. package/extensions/teams/task-store.ts +544 -0
  32. package/extensions/teams/team-attach-claim.ts +205 -0
  33. package/extensions/teams/team-config.ts +335 -0
  34. package/extensions/teams/team-discovery.ts +59 -0
  35. package/extensions/teams/teammate-rpc.ts +261 -0
  36. package/extensions/teams/teams-panel.ts +1186 -0
  37. package/extensions/teams/teams-style.ts +322 -0
  38. package/extensions/teams/teams-ui-shared.ts +89 -0
  39. package/extensions/teams/teams-widget.ts +212 -0
  40. package/extensions/teams/worker.ts +605 -0
  41. package/extensions/teams/worktree.ts +103 -0
  42. package/package.json +53 -0
  43. package/scripts/e2e-rpc-test.mjs +277 -0
  44. package/scripts/integration-claim-test.mts +157 -0
  45. package/scripts/integration-hooks-remediation-test.mts +382 -0
  46. package/scripts/integration-spawn-overrides-test.mts +398 -0
  47. package/scripts/integration-todo-test.mts +533 -0
  48. package/scripts/lib/pi-workers.ts +105 -0
  49. package/scripts/smoke-test.mts +764 -0
  50. package/scripts/start-tmux-team.sh +91 -0
  51. package/skills/agent-teams/SKILL.md +180 -0
  52. package/tsconfig.strict.json +22 -0
@@ -0,0 +1,605 @@
1
+ import type { AgentMessage, AgentToolResult } from "indusagi/agent";
2
+ import type { ExtensionAPI, ExtensionContext } from "indusagi-coding-agent";
3
+ import { Type, type Static } from "@sinclair/typebox";
4
+ import { randomUUID } from "node:crypto";
5
+ import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
6
+ import { sanitizeName } from "./names.js";
7
+ import { getTeamsStyleFromEnv, type TeamsStyle, getTeamsStrings } from "./teams-style.js";
8
+ import {
9
+ TEAM_MAILBOX_NS,
10
+ isAbortRequestMessage,
11
+ isPlanApprovedMessage,
12
+ isPlanRejectedMessage,
13
+ isSetSessionNameMessage,
14
+ isShutdownRequestMessage,
15
+ isTaskAssignmentMessage,
16
+ } from "./protocol.js";
17
+ import { getTeamDir } from "./paths.js";
18
+ import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
19
+ import {
20
+ claimNextAvailableTask,
21
+ completeTask,
22
+ getTask,
23
+ isTaskBlocked,
24
+ startAssignedTask,
25
+ unassignTasksForAgent,
26
+ updateTask,
27
+ type TeamTask,
28
+ } from "./task-store.js";
29
+
30
+ function sleep(ms: number): Promise<void> {
31
+ return new Promise((r) => setTimeout(r, ms));
32
+ }
33
+
34
+ function teamDirFromEnv(): {
35
+ teamId: string;
36
+ teamDir: string;
37
+ taskListId: string;
38
+ agentName: string;
39
+ leadName: string;
40
+ styleId: TeamsStyle;
41
+ autoClaim: boolean;
42
+ } | null {
43
+ const teamId = process.env.PI_TEAMS_TEAM_ID;
44
+ const agentNameRaw = process.env.PI_TEAMS_AGENT_NAME;
45
+ if (!teamId || !agentNameRaw) return null;
46
+
47
+ const agentName = sanitizeName(agentNameRaw);
48
+ const taskListId = process.env.PI_TEAMS_TASK_LIST_ID ?? teamId;
49
+ const styleId = getTeamsStyleFromEnv(process.env);
50
+ const leadName = sanitizeName(process.env.PI_TEAMS_LEAD_NAME ?? "team-lead");
51
+ const autoClaim = (process.env.PI_TEAMS_AUTO_CLAIM ?? "1") === "1";
52
+
53
+ return {
54
+ teamId,
55
+ teamDir: getTeamDir(teamId),
56
+ taskListId,
57
+ agentName,
58
+ leadName,
59
+ styleId,
60
+ autoClaim,
61
+ };
62
+ }
63
+
64
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null;
66
+ }
67
+
68
+ function hasProperty<K extends string>(value: unknown, key: K): value is Record<K, unknown> & Record<string, unknown> {
69
+ return isObjectRecord(value) && key in value;
70
+ }
71
+
72
+ function hasStringProperty<K extends string>(value: unknown, key: K): value is Record<K, string> & Record<string, unknown> {
73
+ return isObjectRecord(value) && typeof value[key] === "string";
74
+ }
75
+
76
+ type AssistantMessageWithContent = Record<"role", "assistant"> & Record<"content", unknown> & Record<string, unknown>;
77
+
78
+ function isAssistantMessageWithContent(message: unknown): message is AssistantMessageWithContent {
79
+ return hasStringProperty(message, "role") && message.role === "assistant" && hasProperty(message, "content");
80
+ }
81
+
82
+ type TextBlock = { type: "text"; text: string };
83
+
84
+ function isTextBlock(block: unknown): block is TextBlock {
85
+ return hasStringProperty(block, "type") && block.type === "text" && hasStringProperty(block, "text");
86
+ }
87
+
88
+ function extractLastAssistantText(messages: AgentMessage[]): string {
89
+ const assistant = messages.filter((m) => isAssistantMessageWithContent(m));
90
+ const last = assistant.at(-1);
91
+ if (!last) return "";
92
+
93
+ const content = last.content;
94
+ if (typeof content === "string") return content;
95
+ if (Array.isArray(content)) {
96
+ return content.filter((c) => isTextBlock(c)).map((c) => c.text).join("");
97
+ }
98
+ return "";
99
+ }
100
+
101
+ function buildTaskPrompt(style: TeamsStyle, agentName: string, task: TeamTask, planOnly = false): string {
102
+ const strings = getTeamsStrings(style);
103
+ const footer = planOnly
104
+ ? "Produce a detailed implementation plan only. Do NOT make any changes or implement anything yet. Your plan will be reviewed before you can proceed."
105
+ : "Do the work now. When finished, reply with a concise summary and any key outputs.";
106
+
107
+ const actor = strings.memberTitle.toLowerCase();
108
+ return [
109
+ `You are ${actor} '${agentName}'.`,
110
+ `You have been assigned task #${task.id}.`,
111
+ `Subject: ${task.subject}`,
112
+ "",
113
+ `Description:\n${task.description}`,
114
+ "",
115
+ footer,
116
+ ].join("\n");
117
+ }
118
+
119
+ // Message parsers are shared with the leader implementation.
120
+ export function runWorker(pi: ExtensionAPI): void {
121
+ const env = teamDirFromEnv();
122
+ if (!env) return;
123
+
124
+ const { teamId, teamDir, taskListId, agentName, leadName, styleId, autoClaim } = env;
125
+
126
+ // Prefer persisted team config style (leader-controlled) over env default.
127
+ // This keeps manual workers consistent with the current team terminology.
128
+ let style: TeamsStyle = styleId;
129
+
130
+ const TeamMessageToolParamsSchema = Type.Object({
131
+ recipient: Type.String({ description: "Name of the comrade to message" }),
132
+ message: Type.String({ description: "The message to send" }),
133
+ });
134
+ // Match the schema at compile-time.
135
+ type TeamMessageToolParams = Static<typeof TeamMessageToolParamsSchema>;
136
+ // Tool result details to match AgentToolResult<TDetails> contract.
137
+ type TeamMessageToolDetails = { recipient: string; timestamp: string };
138
+
139
+ pi.registerTool({
140
+ name: "team_message",
141
+ label: "Team Message",
142
+ description: "Send a message to a comrade. Use this to coordinate with peers on related tasks.",
143
+ parameters: TeamMessageToolParamsSchema,
144
+ async execute(
145
+ _toolCallId,
146
+ params: TeamMessageToolParams,
147
+ _signal,
148
+ _onUpdate,
149
+ _ctx,
150
+ ): Promise<AgentToolResult<TeamMessageToolDetails>> {
151
+ const recipient = sanitizeName(params.recipient);
152
+ const message = params.message;
153
+ const ts = new Date().toISOString();
154
+ // Write to recipient's mailbox in team namespace
155
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, recipient, {
156
+ from: agentName,
157
+ text: message,
158
+ timestamp: ts,
159
+ });
160
+ // CC leader with peer_dm_sent notification
161
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
162
+ from: agentName,
163
+ text: JSON.stringify({
164
+ type: "peer_dm_sent",
165
+ from: agentName,
166
+ to: recipient,
167
+ summary: message.slice(0, 100),
168
+ timestamp: ts,
169
+ }),
170
+ timestamp: ts,
171
+ });
172
+ return {
173
+ content: [{ type: "text", text: `Message sent to ${recipient}` }],
174
+ details: { recipient, timestamp: ts },
175
+ };
176
+ },
177
+ });
178
+
179
+ let ctxRef: ExtensionContext | null = null;
180
+ let isStreaming = false;
181
+ let isDeciding = false;
182
+ let currentTaskId: string | null = null;
183
+ let pendingTaskAssignments: string[] = [];
184
+ let pendingDmTexts: string[] = [];
185
+ let pollAbort = false;
186
+ let shutdownInProgress = false;
187
+ const seenShutdownRequestIds = new Set<string>();
188
+
189
+ let abortTaskId: string | null = null;
190
+ let abortReason: string | undefined;
191
+ let abortRequestId: string | null = null;
192
+ const seenAbortRequestIds = new Set<string>();
193
+
194
+ // Plan-required mode
195
+ let planMode = process.env.PI_TEAMS_PLAN_REQUIRED === "1";
196
+ let planApproved = false;
197
+ let planRequestId: string | null = null;
198
+ /** Tools that were active before plan-mode restriction, so we can restore them on approval. */
199
+ let prePlanTools: string[] | null = null;
200
+
201
+ const poll = async () => {
202
+ while (!pollAbort) {
203
+ try {
204
+ // Two namespaces (Claude-style):
205
+ // - team namespace for DM/idle notifications
206
+ // - taskListId namespace for task_assignment pings
207
+ const [teamMsgs, taskMsgs] = await Promise.all([
208
+ popUnreadMessages(teamDir, TEAM_MAILBOX_NS, agentName),
209
+ popUnreadMessages(teamDir, taskListId, agentName),
210
+ ]);
211
+
212
+ for (const m of [...taskMsgs, ...teamMsgs]) {
213
+ const shutdown = isShutdownRequestMessage(m.text);
214
+ if (shutdown && !seenShutdownRequestIds.has(shutdown.requestId)) {
215
+ seenShutdownRequestIds.add(shutdown.requestId);
216
+
217
+ const ts = new Date().toISOString();
218
+
219
+ // Reject shutdown if currently busy (including plan-mode waiting for approval)
220
+ if (currentTaskId) {
221
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
222
+ from: agentName,
223
+ text: JSON.stringify({
224
+ type: "shutdown_rejected",
225
+ requestId: shutdown.requestId,
226
+ from: agentName,
227
+ reason: `Currently working on task #${currentTaskId}`,
228
+ timestamp: ts,
229
+ }),
230
+ timestamp: ts,
231
+ });
232
+ continue;
233
+ }
234
+
235
+ // Idle — approve shutdown
236
+ shutdownInProgress = true;
237
+ pollAbort = true;
238
+
239
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
240
+ from: agentName,
241
+ text: JSON.stringify({
242
+ type: "shutdown_approved",
243
+ requestId: shutdown.requestId,
244
+ from: agentName,
245
+ timestamp: ts,
246
+ }),
247
+ timestamp: ts,
248
+ });
249
+
250
+ try {
251
+ await cleanup("shutdown requested");
252
+ } catch {
253
+ // ignore
254
+ }
255
+
256
+ try {
257
+ ctxRef?.abort();
258
+ } catch {
259
+ // ignore
260
+ }
261
+ try {
262
+ ctxRef?.shutdown();
263
+ } catch {
264
+ // ignore
265
+ }
266
+ return;
267
+ }
268
+
269
+ const setName = isSetSessionNameMessage(m.text);
270
+ if (setName) {
271
+ const desired = setName.name.trim();
272
+ if (desired) {
273
+ try {
274
+ const existing = pi.getSessionName?.();
275
+ // Only overwrite sessions that are unnamed or already managed by us.
276
+ if (!existing || existing.startsWith("pi agent teams -")) {
277
+ if (existing !== desired) pi.setSessionName(desired);
278
+ }
279
+ } catch {
280
+ // ignore
281
+ }
282
+ }
283
+ continue;
284
+ }
285
+
286
+ const abortReq = isAbortRequestMessage(m.text);
287
+ if (abortReq && !seenAbortRequestIds.has(abortReq.requestId)) {
288
+ seenAbortRequestIds.add(abortReq.requestId);
289
+
290
+ // If the request targets a specific task and we're busy on a different one, ignore.
291
+ if (abortReq.taskId && currentTaskId && abortReq.taskId !== currentTaskId) continue;
292
+
293
+ if (currentTaskId) {
294
+ abortTaskId = currentTaskId;
295
+ abortReason = abortReq.reason;
296
+ abortRequestId = abortReq.requestId;
297
+ }
298
+
299
+ try {
300
+ ctxRef?.abort();
301
+ } catch {
302
+ // ignore
303
+ }
304
+ continue;
305
+ }
306
+
307
+ // Plan approval/rejection handling
308
+ const planApproval = isPlanApprovedMessage(m.text);
309
+ if (planApproval && planRequestId && planApproval.requestId === planRequestId) {
310
+ pi.setActiveTools(prePlanTools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"]);
311
+ prePlanTools = null;
312
+ planApproved = true;
313
+ planMode = false;
314
+ planRequestId = null;
315
+ pi.sendUserMessage("Your plan has been approved. Proceed with implementation.");
316
+ continue;
317
+ }
318
+
319
+ const planRejection = isPlanRejectedMessage(m.text);
320
+ if (planRejection && planRequestId && planRejection.requestId === planRequestId) {
321
+ planRequestId = null;
322
+ pi.sendUserMessage(
323
+ `Your plan was rejected. Feedback: ${planRejection.feedback}\nPlease revise your plan.`,
324
+ );
325
+ continue;
326
+ }
327
+
328
+ const assign = isTaskAssignmentMessage(m.text);
329
+ if (assign) {
330
+ pendingTaskAssignments.push(assign.taskId);
331
+ continue;
332
+ }
333
+ // Plain DM (or unknown structured message)
334
+ pendingDmTexts.push(m.text);
335
+ }
336
+
337
+ if (!shutdownInProgress) await maybeStartNextWork();
338
+ } catch {
339
+ // ignore polling errors
340
+ }
341
+
342
+ // Add a little jitter to avoid all workers polling/claiming in lock-step.
343
+ await sleep(350 + Math.floor(Math.random() * 200));
344
+ }
345
+ };
346
+
347
+ const maybeStartNextWork = async () => {
348
+ if (!ctxRef) return;
349
+ if (shutdownInProgress) return;
350
+ if (isStreaming) return;
351
+ if (currentTaskId) return;
352
+ if (isDeciding) return;
353
+
354
+ isDeciding = true;
355
+ try {
356
+ // 1) Assigned tasks
357
+ const requeue: string[] = [];
358
+ while (pendingTaskAssignments.length) {
359
+ const taskId = pendingTaskAssignments.shift();
360
+ if (!taskId) break;
361
+ const task = await getTask(teamDir, taskListId, taskId);
362
+ if (!task) continue;
363
+ if (task.owner !== agentName) continue;
364
+ if (task.status === "completed") continue;
365
+
366
+ // Respect deps: don't start assigned tasks until unblocked.
367
+ if (await isTaskBlocked(teamDir, taskListId, task)) {
368
+ requeue.push(taskId);
369
+ continue;
370
+ }
371
+
372
+ // Mark in_progress if needed
373
+ if (task.status === "pending") await startAssignedTask(teamDir, taskListId, taskId, agentName);
374
+
375
+ currentTaskId = taskId;
376
+ isStreaming = true; // optimistic; agent_start will follow
377
+ pi.sendUserMessage(buildTaskPrompt(style, agentName, task, planMode && !planApproved));
378
+ pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
379
+ return;
380
+ }
381
+ pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
382
+
383
+ // 2) DMs
384
+ if (pendingDmTexts.length) {
385
+ const text = pendingDmTexts.join("\n\n---\n\n");
386
+ pendingDmTexts = [];
387
+ isStreaming = true;
388
+ pi.sendUserMessage([
389
+ { type: "text", text: "You have received comrade message(s):" },
390
+ { type: "text", text },
391
+ ]);
392
+ return;
393
+ }
394
+
395
+ // 3) Auto-claim
396
+ if (autoClaim) {
397
+ // Small randomized delay improves fairness (reduces one fast worker hogging tasks)
398
+ // and reduces lock contention when many workers become idle simultaneously.
399
+ await sleep(Math.floor(Math.random() * 250));
400
+
401
+ const claimed = await claimNextAvailableTask(teamDir, taskListId, agentName, { checkAgentBusy: true });
402
+ if (claimed) {
403
+ currentTaskId = claimed.id;
404
+ isStreaming = true;
405
+ pi.sendUserMessage(buildTaskPrompt(style, agentName, claimed, planMode && !planApproved));
406
+ return;
407
+ }
408
+ }
409
+ } finally {
410
+ isDeciding = false;
411
+ }
412
+ };
413
+
414
+ const sendIdleNotification = async (
415
+ completedTaskId?: string,
416
+ completedStatus?: "completed" | "failed",
417
+ failureReason?: string,
418
+ ) => {
419
+ type IdleNotificationPayload = {
420
+ type: "idle_notification";
421
+ from: string;
422
+ timestamp: string;
423
+ completedTaskId?: string;
424
+ completedStatus?: "completed" | "failed";
425
+ failureReason?: string;
426
+ };
427
+
428
+ const payload: IdleNotificationPayload = {
429
+ type: "idle_notification",
430
+ from: agentName,
431
+ timestamp: new Date().toISOString(),
432
+ };
433
+ if (completedTaskId) payload.completedTaskId = completedTaskId;
434
+ if (completedStatus) payload.completedStatus = completedStatus;
435
+ if (failureReason) payload.failureReason = failureReason;
436
+
437
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
438
+ from: agentName,
439
+ text: JSON.stringify(payload),
440
+ timestamp: new Date().toISOString(),
441
+ });
442
+ };
443
+
444
+ const cleanup = async (reason: string) => {
445
+ try {
446
+ await unassignTasksForAgent(teamDir, taskListId, agentName, reason);
447
+ } catch {
448
+ // ignore
449
+ }
450
+ };
451
+
452
+ pi.on("session_start", async (_event, ctx) => {
453
+ ctxRef = ctx;
454
+
455
+ // Restrict tools in plan-required mode (read-only until plan is approved)
456
+ if (planMode) {
457
+ prePlanTools = pi.getActiveTools?.() ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
458
+ pi.setActiveTools(["read", "grep", "find", "ls"]);
459
+ }
460
+
461
+ // Register ourselves in the shared team config so manual tmux workers are discoverable.
462
+ try {
463
+ const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName, style: styleId });
464
+ style = cfg.style ?? styleId;
465
+ const now = new Date().toISOString();
466
+ if (!cfg.members.some((m) => m.name === agentName)) {
467
+ await upsertMember(teamDir, {
468
+ name: agentName,
469
+ role: "worker",
470
+ status: "online",
471
+ lastSeenAt: now,
472
+ cwd: ctx.cwd,
473
+ sessionFile: ctx.sessionManager.getSessionFile(),
474
+ });
475
+ } else {
476
+ await setMemberStatus(teamDir, agentName, "online", { lastSeenAt: now });
477
+ }
478
+ } catch {
479
+ // ignore config errors
480
+ }
481
+
482
+ void poll();
483
+ await maybeStartNextWork();
484
+ // Claude-style: let the leader know we're idle even if no task was completed yet.
485
+ if (!isStreaming && !currentTaskId) {
486
+ await sendIdleNotification();
487
+ }
488
+ });
489
+
490
+ pi.on("session_shutdown", async () => {
491
+ pollAbort = true;
492
+ await cleanup("worker shutdown");
493
+ try {
494
+ await setMemberStatus(teamDir, agentName, "offline", { meta: { offlineReason: "worker shutdown" } });
495
+ } catch {
496
+ // ignore
497
+ }
498
+ await sendIdleNotification(undefined, undefined, "worker shutdown");
499
+ });
500
+
501
+ pi.on("agent_start", async () => {
502
+ isStreaming = true;
503
+ });
504
+
505
+ pi.on("agent_end", async (event) => {
506
+ isStreaming = false;
507
+
508
+ // Plan submission: if in plan mode and not yet approved, send plan to leader for review
509
+ // Only do this when we're working on a task and haven't already requested approval.
510
+ if (planMode && !planApproved && currentTaskId && !planRequestId) {
511
+ const lastAssistantText = extractLastAssistantText(event.messages);
512
+ const reqId = randomUUID();
513
+ planRequestId = reqId;
514
+ const timestamp = new Date().toISOString();
515
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, leadName, {
516
+ from: agentName,
517
+ text: JSON.stringify({
518
+ type: "plan_approval_request",
519
+ requestId: reqId,
520
+ from: agentName,
521
+ plan: lastAssistantText,
522
+ taskId: currentTaskId ?? undefined,
523
+ timestamp,
524
+ }),
525
+ timestamp,
526
+ });
527
+ // Do NOT clear currentTaskId, do NOT complete the task, do NOT send idle notification
528
+ return;
529
+ }
530
+
531
+ const taskId = currentTaskId;
532
+ currentTaskId = null;
533
+
534
+ let completedTaskId: string | undefined;
535
+ let completedStatus: "completed" | "failed" | undefined;
536
+ let failureReason: string | undefined;
537
+
538
+ try {
539
+ if (taskId) {
540
+ const rawResult = extractLastAssistantText(event.messages);
541
+ const trimmed = rawResult.trim();
542
+ const abortedByRequest = abortTaskId === taskId;
543
+ const aborted = abortedByRequest || trimmed.length === 0;
544
+
545
+ if (aborted) {
546
+ const ts = new Date().toISOString();
547
+ const extra: Record<string, unknown> = {
548
+ abortedAt: ts,
549
+ abortedBy: agentName,
550
+ };
551
+
552
+ if (abortedByRequest) {
553
+ if (abortRequestId) extra.abortRequestId = abortRequestId;
554
+ extra.abortReason = abortReason ?? "abort requested";
555
+ if (trimmed.length > 0) extra.partialResult = rawResult;
556
+ } else {
557
+ extra.abortReason = "no assistant result";
558
+ }
559
+
560
+ await updateTask(teamDir, taskListId, taskId, (cur) => {
561
+ if (cur.owner !== agentName) return cur;
562
+ if (cur.status === "completed") return cur;
563
+
564
+ const metadata = { ...(cur.metadata ?? {}) };
565
+ Object.assign(metadata, extra);
566
+
567
+ // Reset to pending, but keep owner. This avoids immediate re-claim loops after an abort.
568
+ return { ...cur, status: "pending", metadata };
569
+ });
570
+ completedTaskId = taskId;
571
+ completedStatus = "failed";
572
+ } else {
573
+ await completeTask(teamDir, taskListId, taskId, agentName, rawResult);
574
+ completedTaskId = taskId;
575
+ completedStatus = "completed";
576
+ }
577
+ }
578
+ } finally {
579
+ abortTaskId = null;
580
+ abortReason = undefined;
581
+ abortRequestId = null;
582
+ }
583
+
584
+ await maybeStartNextWork();
585
+
586
+ // Only tell the leader we're idle if we truly didn't start more work.
587
+ if (!isStreaming && !currentTaskId) {
588
+ await sendIdleNotification(completedTaskId, completedStatus, failureReason);
589
+ }
590
+ });
591
+
592
+ // Best-effort cleanup on SIGTERM (leader kill).
593
+ process.on("SIGTERM", () => {
594
+ pollAbort = true;
595
+ void (async () => {
596
+ await cleanup("SIGTERM");
597
+ try {
598
+ await setMemberStatus(teamDir, agentName, "offline", { meta: { offlineReason: "SIGTERM" } });
599
+ } catch {
600
+ // ignore
601
+ }
602
+ await sendIdleNotification(undefined, undefined, "SIGTERM");
603
+ })().finally(() => process.exit(0));
604
+ });
605
+ }
@@ -0,0 +1,103 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { sanitizeName } from "./names.js";
5
+
6
+ async function execGit(args: string[], opts: { cwd: string; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> {
7
+ return await new Promise((resolve, reject) => {
8
+ execFile(
9
+ "git",
10
+ args,
11
+ { cwd: opts.cwd, timeout: opts.timeoutMs ?? 30_000, maxBuffer: 10 * 1024 * 1024 },
12
+ (err, stdout, stderr) => {
13
+ if (err) {
14
+ const msg = [
15
+ `git ${args.join(" ")} failed`,
16
+ `cwd=${opts.cwd}`,
17
+ stderr ? `stderr=${String(stderr).trim()}` : "",
18
+ err instanceof Error ? `error=${err.message}` : `error=${String(err)}`,
19
+ ]
20
+ .filter(Boolean)
21
+ .join("\n");
22
+ reject(new Error(msg));
23
+ return;
24
+ }
25
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
26
+ },
27
+ );
28
+ });
29
+ }
30
+
31
+ export type WorktreeResult = {
32
+ cwd: string;
33
+ warnings: string[];
34
+ mode: "worktree" | "shared";
35
+ };
36
+
37
+ /**
38
+ * Ensure a per-teammate git worktree exists, returning the cwd to use for that teammate.
39
+ *
40
+ * Behavior:
41
+ * - If not in a git repo, falls back to shared cwd with a warning.
42
+ * - If git repo is dirty, still creates a worktree but warns that uncommitted changes are not included.
43
+ */
44
+ export async function ensureWorktreeCwd(opts: {
45
+ leaderCwd: string;
46
+ teamDir: string;
47
+ teamId: string;
48
+ agentName: string;
49
+ }): Promise<WorktreeResult> {
50
+ const warnings: string[] = [];
51
+ let repoRoot: string;
52
+ try {
53
+ repoRoot = (await execGit(["rev-parse", "--show-toplevel"], { cwd: opts.leaderCwd })).stdout.trim();
54
+ if (!repoRoot) throw new Error("empty git toplevel");
55
+ } catch {
56
+ warnings.push("Not a git repository (or git unavailable). Using shared workspace instead of worktree.");
57
+ return { cwd: opts.leaderCwd, warnings, mode: "shared" };
58
+ }
59
+
60
+ try {
61
+ const status = (await execGit(["status", "--porcelain"], { cwd: repoRoot })).stdout;
62
+ if (status.trim().length) {
63
+ warnings.push(
64
+ "Git working directory is not clean. Worktree will be created from current HEAD and will NOT include your uncommitted changes.",
65
+ );
66
+ }
67
+ } catch {
68
+ // ignore status errors
69
+ }
70
+
71
+ const safeAgent = sanitizeName(opts.agentName);
72
+ const shortTeam = sanitizeName(opts.teamId).slice(0, 12) || "team";
73
+ const branch = `pi-teams/${shortTeam}/${safeAgent}`;
74
+
75
+ const worktreesDir = path.join(opts.teamDir, "worktrees");
76
+ const worktreePath = path.join(worktreesDir, safeAgent);
77
+ await fs.promises.mkdir(worktreesDir, { recursive: true });
78
+
79
+ // Reuse if it already exists.
80
+ if (fs.existsSync(worktreePath)) {
81
+ return { cwd: worktreePath, warnings, mode: "worktree" };
82
+ }
83
+
84
+ try {
85
+ // Create worktree + new branch from HEAD
86
+ await execGit(["worktree", "add", "-b", branch, worktreePath, "HEAD"], { cwd: repoRoot, timeoutMs: 120_000 });
87
+ return { cwd: worktreePath, warnings, mode: "worktree" };
88
+ } catch (err: unknown) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ // If the branch already exists (e.g. previous run), try adding worktree using the existing branch.
91
+ if (msg.includes("already exists") || msg.includes("is already checked out")) {
92
+ try {
93
+ await execGit(["worktree", "add", worktreePath, branch], { cwd: repoRoot, timeoutMs: 120_000 });
94
+ return { cwd: worktreePath, warnings, mode: "worktree" };
95
+ } catch {
96
+ // fall through
97
+ }
98
+ }
99
+
100
+ warnings.push(`Failed to create git worktree (${branch}). Using shared workspace instead.`);
101
+ return { cwd: opts.leaderCwd, warnings, mode: "shared" };
102
+ }
103
+ }