@tmustier/pi-agent-teams 0.1.2 → 0.2.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 (35) hide show
  1. package/.github/workflows/ci.yml +32 -0
  2. package/README.md +36 -6
  3. package/docs/claude-parity.md +16 -14
  4. package/docs/field-notes-teams-setup.md +6 -4
  5. package/docs/smoke-test-plan.md +130 -0
  6. package/extensions/teams/activity-tracker.ts +234 -0
  7. package/extensions/teams/fs-lock.ts +21 -5
  8. package/extensions/teams/leader-inbox.ts +175 -0
  9. package/extensions/teams/leader-info-commands.ts +139 -0
  10. package/extensions/teams/leader-lifecycle-commands.ts +330 -0
  11. package/extensions/teams/leader-messaging-commands.ts +149 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +73 -0
  14. package/extensions/teams/leader-task-commands.ts +417 -0
  15. package/extensions/teams/leader-teams-tool.ts +238 -0
  16. package/extensions/teams/leader.ts +396 -1422
  17. package/extensions/teams/mailbox.ts +54 -29
  18. package/extensions/teams/names.ts +87 -0
  19. package/extensions/teams/protocol.ts +221 -0
  20. package/extensions/teams/task-store.ts +32 -21
  21. package/extensions/teams/team-config.ts +71 -25
  22. package/extensions/teams/teammate-rpc.ts +56 -22
  23. package/extensions/teams/teams-panel.ts +698 -0
  24. package/extensions/teams/teams-style.ts +62 -0
  25. package/extensions/teams/teams-widget.ts +235 -0
  26. package/extensions/teams/worker.ts +100 -138
  27. package/extensions/teams/worktree.ts +4 -7
  28. package/package.json +25 -3
  29. package/scripts/integration-claim-test.mts +227 -0
  30. package/scripts/integration-todo-test.mts +583 -0
  31. package/scripts/smoke-test.mjs +1 -1
  32. package/scripts/smoke-test.mts +424 -0
  33. package/skills/agent-teams/SKILL.md +136 -0
  34. package/tsconfig.strict.json +22 -0
  35. package/extensions/teams/tasks.ts +0 -95
@@ -0,0 +1,417 @@
1
+ import * as path from "node:path";
2
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
3
+ import { writeToMailbox } from "./mailbox.js";
4
+ import { sanitizeName } from "./names.js";
5
+ import { getTeamDir } from "./paths.js";
6
+ import { TEAM_MAILBOX_NS } from "./protocol.js";
7
+ import {
8
+ addTaskDependency,
9
+ clearTasks,
10
+ createTask,
11
+ formatTaskLine,
12
+ getTask,
13
+ isTaskBlocked,
14
+ removeTaskDependency,
15
+ updateTask,
16
+ type TeamTask,
17
+ } from "./task-store.js";
18
+ import { ensureTeamConfig } from "./team-config.js";
19
+ import type { TeamsStyle } from "./teams-style.js";
20
+ import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
21
+
22
+ export async function handleTeamTaskCommand(opts: {
23
+ ctx: ExtensionCommandContext;
24
+ rest: string[];
25
+ leadName: string;
26
+ style: TeamsStyle;
27
+ getTaskListId: () => string | null;
28
+ setTaskListId: (id: string) => void;
29
+ getTasks: () => TeamTask[];
30
+ refreshTasks: () => Promise<void>;
31
+ renderWidget: () => void;
32
+ parseAssigneePrefix: (text: string) => { assignee?: string; text: string };
33
+ taskAssignmentPayload: (task: TeamTask, assignedBy: string) => unknown;
34
+ }): Promise<void> {
35
+ const {
36
+ ctx,
37
+ rest,
38
+ leadName,
39
+ style,
40
+ getTaskListId,
41
+ setTaskListId,
42
+ getTasks,
43
+ refreshTasks,
44
+ renderWidget,
45
+ parseAssigneePrefix,
46
+ taskAssignmentPayload,
47
+ } = opts;
48
+ const strings = getTeamsStrings(style);
49
+
50
+ const [taskSub, ...taskRest] = rest;
51
+ const teamId = ctx.sessionManager.getSessionId();
52
+ const teamDir = getTeamDir(teamId);
53
+ const effectiveTlId = getTaskListId() ?? teamId;
54
+
55
+ if (!taskSub || taskSub === "help") {
56
+ ctx.ui.notify(
57
+ [
58
+ "Usage:",
59
+ " /team task add <text...>",
60
+ " /team task assign <id> <agent>",
61
+ " /team task unassign <id>",
62
+ " /team task list",
63
+ " /team task clear [completed|all] [--force]",
64
+ " /team task show <id>",
65
+ " /team task dep add <id> <depId>",
66
+ " /team task dep rm <id> <depId>",
67
+ " /team task dep ls <id>",
68
+ " /team task use <taskListId>",
69
+ "Tip: prefix with assignee, e.g. 'alice: review the API surface'",
70
+ ].join("\n"),
71
+ "info",
72
+ );
73
+ return;
74
+ }
75
+
76
+ switch (taskSub) {
77
+ case "add": {
78
+ const raw = taskRest.join(" ").trim();
79
+ if (!raw) {
80
+ ctx.ui.notify("Usage: /team task add <text...>", "error");
81
+ return;
82
+ }
83
+
84
+ const parsed = parseAssigneePrefix(raw);
85
+ const owner = parsed.assignee ? sanitizeName(parsed.assignee) : undefined;
86
+ const description = parsed.text.trim();
87
+ const firstLine = description.split("\n").at(0) ?? "";
88
+ const subject = firstLine.slice(0, 120);
89
+
90
+ const task = await createTask(teamDir, effectiveTlId, { subject, description, owner });
91
+
92
+ if (owner) {
93
+ const payload = taskAssignmentPayload(task, leadName);
94
+ await writeToMailbox(teamDir, effectiveTlId, owner, {
95
+ from: leadName,
96
+ text: JSON.stringify(payload),
97
+ timestamp: new Date().toISOString(),
98
+ });
99
+ }
100
+
101
+ ctx.ui.notify(
102
+ `Created task #${task.id}${owner ? ` (assigned to ${formatMemberDisplayName(style, owner)})` : ""}`,
103
+ "info",
104
+ );
105
+ await refreshTasks();
106
+ renderWidget();
107
+ return;
108
+ }
109
+
110
+ case "assign": {
111
+ const taskId = taskRest[0];
112
+ const agent = taskRest[1];
113
+ if (!taskId || !agent) {
114
+ ctx.ui.notify("Usage: /team task assign <id> <agent>", "error");
115
+ return;
116
+ }
117
+
118
+ const owner = sanitizeName(agent);
119
+ const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
120
+ if (cur.status !== "completed") {
121
+ return { ...cur, owner, status: "pending" };
122
+ }
123
+ return { ...cur, owner };
124
+ });
125
+ if (!updated) {
126
+ ctx.ui.notify(`Task not found: ${taskId}`, "error");
127
+ return;
128
+ }
129
+
130
+ await writeToMailbox(teamDir, effectiveTlId, owner, {
131
+ from: leadName,
132
+ text: JSON.stringify(taskAssignmentPayload(updated, leadName)),
133
+ timestamp: new Date().toISOString(),
134
+ });
135
+
136
+ ctx.ui.notify(`Assigned task #${updated.id} to ${formatMemberDisplayName(style, owner)}`, "info");
137
+ await refreshTasks();
138
+ renderWidget();
139
+ return;
140
+ }
141
+
142
+ case "unassign": {
143
+ const taskId = taskRest[0];
144
+ if (!taskId) {
145
+ ctx.ui.notify("Usage: /team task unassign <id>", "error");
146
+ return;
147
+ }
148
+
149
+ const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
150
+ if (cur.status !== "completed") {
151
+ return { ...cur, owner: undefined, status: "pending" };
152
+ }
153
+ return { ...cur, owner: undefined };
154
+ });
155
+ if (!updated) {
156
+ ctx.ui.notify(`Task not found: ${taskId}`, "error");
157
+ return;
158
+ }
159
+
160
+ ctx.ui.notify(`Unassigned task #${updated.id}`, "info");
161
+ await refreshTasks();
162
+ renderWidget();
163
+ return;
164
+ }
165
+
166
+ case "show": {
167
+ const taskId = taskRest[0];
168
+ if (!taskId) {
169
+ ctx.ui.notify("Usage: /team task show <id>", "error");
170
+ return;
171
+ }
172
+
173
+ const task = await getTask(teamDir, effectiveTlId, taskId);
174
+ if (!task) {
175
+ ctx.ui.notify(`Task not found: ${taskId}`, "error");
176
+ return;
177
+ }
178
+
179
+ const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
180
+
181
+ const lines: string[] = [];
182
+ lines.push(`#${task.id} ${task.subject}`);
183
+ lines.push(
184
+ `status: ${task.status}${blocked ? " (blocked)" : ""}${task.owner ? ` • owner: ${task.owner}` : ""}`,
185
+ );
186
+ if (task.blockedBy.length) lines.push(`deps: ${task.blockedBy.join(", ")}`);
187
+ if (task.blocks.length) lines.push(`blocks: ${task.blocks.join(", ")}`);
188
+ lines.push("");
189
+ lines.push(task.description);
190
+
191
+ const result = typeof task.metadata?.result === "string" ? task.metadata.result : undefined;
192
+ if (result) {
193
+ lines.push("");
194
+ lines.push("result:");
195
+ lines.push(result);
196
+ }
197
+
198
+ ctx.ui.notify(lines.join("\n"), "info");
199
+ return;
200
+ }
201
+
202
+ case "dep": {
203
+ const [depSub, ...depRest] = taskRest;
204
+ if (!depSub || depSub === "help") {
205
+ ctx.ui.notify(
206
+ [
207
+ "Usage:",
208
+ " /team task dep add <id> <depId>",
209
+ " /team task dep rm <id> <depId>",
210
+ " /team task dep ls <id>",
211
+ ].join("\n"),
212
+ "info",
213
+ );
214
+ return;
215
+ }
216
+
217
+ switch (depSub) {
218
+ case "add": {
219
+ const taskId = depRest[0];
220
+ const depId = depRest[1];
221
+ if (!taskId || !depId) {
222
+ ctx.ui.notify("Usage: /team task dep add <id> <depId>", "error");
223
+ return;
224
+ }
225
+
226
+ const res = await addTaskDependency(teamDir, effectiveTlId, taskId, depId);
227
+ if (!res.ok) {
228
+ ctx.ui.notify(res.error, "error");
229
+ return;
230
+ }
231
+
232
+ ctx.ui.notify(`Added dependency: #${taskId} depends on #${depId}`, "info");
233
+ await refreshTasks();
234
+ renderWidget();
235
+ return;
236
+ }
237
+
238
+ case "rm": {
239
+ const taskId = depRest[0];
240
+ const depId = depRest[1];
241
+ if (!taskId || !depId) {
242
+ ctx.ui.notify("Usage: /team task dep rm <id> <depId>", "error");
243
+ return;
244
+ }
245
+
246
+ const res = await removeTaskDependency(teamDir, effectiveTlId, taskId, depId);
247
+ if (!res.ok) {
248
+ ctx.ui.notify(res.error, "error");
249
+ return;
250
+ }
251
+
252
+ ctx.ui.notify(`Removed dependency: #${taskId} no longer depends on #${depId}`, "info");
253
+ await refreshTasks();
254
+ renderWidget();
255
+ return;
256
+ }
257
+
258
+ case "ls": {
259
+ const taskId = depRest[0];
260
+ if (!taskId) {
261
+ ctx.ui.notify("Usage: /team task dep ls <id>", "error");
262
+ return;
263
+ }
264
+
265
+ await refreshTasks();
266
+ const tasks = getTasks();
267
+ const task = tasks.find((t) => t.id === taskId) ?? (await getTask(teamDir, effectiveTlId, taskId));
268
+ if (!task) {
269
+ ctx.ui.notify(`Task not found: ${taskId}`, "error");
270
+ return;
271
+ }
272
+
273
+ const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
274
+
275
+ const lines: string[] = [];
276
+ lines.push(`#${task.id} ${task.subject}`);
277
+ lines.push(`${blocked ? "blocked" : "unblocked"} • deps:${task.blockedBy.length} • blocks:${task.blocks.length}`);
278
+
279
+ lines.push("");
280
+ lines.push("blockedBy:");
281
+ if (!task.blockedBy.length) {
282
+ lines.push(" (none)");
283
+ } else {
284
+ for (const id of task.blockedBy) {
285
+ const dep = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
286
+ lines.push(dep ? ` - #${id} ${dep.status} ${dep.subject}` : ` - #${id} (missing)`);
287
+ }
288
+ }
289
+
290
+ lines.push("");
291
+ lines.push("blocks:");
292
+ if (!task.blocks.length) {
293
+ lines.push(" (none)");
294
+ } else {
295
+ for (const id of task.blocks) {
296
+ const child = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
297
+ lines.push(child ? ` - #${id} ${child.status} ${child.subject}` : ` - #${id} (missing)`);
298
+ }
299
+ }
300
+
301
+ ctx.ui.notify(lines.join("\n"), "info");
302
+ return;
303
+ }
304
+
305
+ default: {
306
+ ctx.ui.notify(`Unknown dep subcommand: ${depSub}`, "error");
307
+ return;
308
+ }
309
+ }
310
+ }
311
+
312
+ case "clear": {
313
+ const flags = taskRest.filter((a) => a.startsWith("--"));
314
+ const argsOnly = taskRest.filter((a) => !a.startsWith("--"));
315
+ const force = flags.includes("--force");
316
+
317
+ const unknownFlags = flags.filter((f) => f !== "--force");
318
+ if (unknownFlags.length) {
319
+ ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
320
+ return;
321
+ }
322
+
323
+ if (argsOnly.length > 1) {
324
+ ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
325
+ return;
326
+ }
327
+
328
+ const modeArg = argsOnly[0];
329
+ if (modeArg && modeArg !== "completed" && modeArg !== "all") {
330
+ ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
331
+ return;
332
+ }
333
+ const mode = modeArg === "all" ? "all" : "completed";
334
+
335
+ await refreshTasks();
336
+ const tasks = getTasks();
337
+ const toDelete = mode === "all" ? tasks.length : tasks.filter((t) => t.status === "completed").length;
338
+
339
+ if (!force) {
340
+ // Only prompt in interactive TTY mode. In RPC mode, confirm() would require
341
+ // the host to send extension_ui_response messages.
342
+ if (process.stdout.isTTY && process.stdin.isTTY) {
343
+ const title = mode === "all" ? "Clear task list" : "Clear completed tasks";
344
+ const body =
345
+ mode === "all"
346
+ ? `Delete ALL ${toDelete} task(s) from the task list? This cannot be undone.`
347
+ : `Delete ${toDelete} completed task(s) from the task list? This cannot be undone.`;
348
+ const ok = await ctx.ui.confirm(title, body);
349
+ if (!ok) return;
350
+ } else {
351
+ ctx.ui.notify("Refusing to clear tasks in non-interactive mode without --force", "error");
352
+ return;
353
+ }
354
+ }
355
+
356
+ const res = await clearTasks(teamDir, effectiveTlId, mode);
357
+ const deleted = res.deletedTaskIds.length;
358
+
359
+ if (res.errors.length) {
360
+ ctx.ui.notify(`Cleared ${deleted} task(s) (${mode}) with ${res.errors.length} error(s)`, "warning");
361
+ const preview = res.errors
362
+ .slice(0, 8)
363
+ .map((e) => `- ${path.basename(e.file)}: ${e.error}`)
364
+ .join("\n");
365
+ ctx.ui.notify(
366
+ `Errors:\n${preview}${res.errors.length > 8 ? `\n... +${res.errors.length - 8} more` : ""}`,
367
+ "warning",
368
+ );
369
+ } else if (deleted === 0) {
370
+ ctx.ui.notify(`No task(s) cleared (${mode})`, "info");
371
+ } else {
372
+ ctx.ui.notify(`Cleared ${deleted} task(s) (${mode})`, "warning");
373
+ }
374
+
375
+ await refreshTasks();
376
+ renderWidget();
377
+ return;
378
+ }
379
+
380
+ case "list": {
381
+ await refreshTasks();
382
+ const tasks = getTasks();
383
+ if (!tasks.length) {
384
+ ctx.ui.notify("No tasks", "info");
385
+ return;
386
+ }
387
+
388
+ const slice = tasks.slice(-30);
389
+ const blocked = await Promise.all(
390
+ slice.map(async (t) => (t.status === "completed" ? false : await isTaskBlocked(teamDir, effectiveTlId, t))),
391
+ );
392
+
393
+ const preview = slice.map((t, i) => formatTaskLine(t, { blocked: blocked[i] })).join("\n");
394
+ ctx.ui.notify(preview, "info");
395
+ return;
396
+ }
397
+
398
+ case "use": {
399
+ const newId = taskRest[0];
400
+ if (!newId) {
401
+ ctx.ui.notify("Usage: /team task use <taskListId>", "error");
402
+ return;
403
+ }
404
+ setTaskListId(newId);
405
+ await ensureTeamConfig(teamDir, { teamId, taskListId: newId, leadName, style });
406
+ ctx.ui.notify(`Task list ID set to: ${newId}`, "info");
407
+ await refreshTasks();
408
+ renderWidget();
409
+ return;
410
+ }
411
+
412
+ default: {
413
+ ctx.ui.notify(`Unknown task subcommand: ${taskSub}`, "error");
414
+ return;
415
+ }
416
+ }
417
+ }
@@ -0,0 +1,238 @@
1
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2
+ import { StringEnum } from "@mariozechner/pi-ai";
3
+ import { Type, type Static } from "@sinclair/typebox";
4
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import { writeToMailbox } from "./mailbox.js";
6
+ import { pickAgentNames, pickComradeNames, sanitizeName } from "./names.js";
7
+ import { getTeamDir } from "./paths.js";
8
+ import { ensureTeamConfig } from "./team-config.js";
9
+ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName } from "./teams-style.js";
10
+ import { createTask, type TeamTask } from "./task-store.js";
11
+ import type { TeammateRpc } from "./teammate-rpc.js";
12
+
13
+ export type ContextMode = "fresh" | "branch";
14
+ export type WorkspaceMode = "shared" | "worktree";
15
+
16
+ type TeamsToolDelegateTask = { text: string; assignee?: string };
17
+
18
+ type SpawnTeammateResult =
19
+ | { ok: true; name: string; warnings: string[] }
20
+ | { ok: false; error: string };
21
+
22
+ export type SpawnTeammateFn = (
23
+ ctx: ExtensionContext,
24
+ opts: { name: string; mode?: ContextMode; workspaceMode?: WorkspaceMode; planRequired?: boolean },
25
+ ) => Promise<SpawnTeammateResult>;
26
+
27
+ const TeamsActionSchema = StringEnum(["delegate"] as const, {
28
+ description: "Teams tool action. Currently only 'delegate' is supported.",
29
+ default: "delegate",
30
+ });
31
+
32
+ const TeamsContextModeSchema = StringEnum(["fresh", "branch"] as const, {
33
+ description: "How to initialize comrade session context. 'branch' clones the leader session branch.",
34
+ default: "fresh",
35
+ });
36
+
37
+ const TeamsWorkspaceModeSchema = StringEnum(["shared", "worktree"] as const, {
38
+ description: "Workspace isolation mode. 'shared' matches Claude Teams; 'worktree' creates a git worktree per comrade.",
39
+ default: "shared",
40
+ });
41
+
42
+ const TeamsDelegateTaskSchema = Type.Object({
43
+ text: Type.String({ description: "Task / TODO text." }),
44
+ assignee: Type.Optional(Type.String({ description: "Optional comrade name. If omitted, assigned round-robin." })),
45
+ });
46
+
47
+ const TeamsToolParamsSchema = Type.Object({
48
+ action: Type.Optional(TeamsActionSchema),
49
+ tasks: Type.Optional(Type.Array(TeamsDelegateTaskSchema, { description: "Tasks to delegate (action=delegate)." })),
50
+ teammates: Type.Optional(
51
+ Type.Array(Type.String(), {
52
+ description: "Explicit comrade names to use/spawn. If omitted, uses existing or auto-generates.",
53
+ }),
54
+ ),
55
+ maxTeammates: Type.Optional(
56
+ Type.Integer({
57
+ description: "If comrades list is omitted and none exist, spawn up to this many.",
58
+ default: 4,
59
+ minimum: 1,
60
+ maximum: 16,
61
+ }),
62
+ ),
63
+ contextMode: Type.Optional(TeamsContextModeSchema),
64
+ workspaceMode: Type.Optional(TeamsWorkspaceModeSchema),
65
+ });
66
+
67
+ type TeamsToolParamsType = Static<typeof TeamsToolParamsSchema>;
68
+
69
+ export function registerTeamsTool(opts: {
70
+ pi: ExtensionAPI;
71
+ teammates: Map<string, TeammateRpc>;
72
+ spawnTeammate: SpawnTeammateFn;
73
+ getTaskListId: () => string | null;
74
+ taskAssignmentPayload: (task: TeamTask, assignedBy: string) => unknown;
75
+ refreshTasks: () => Promise<void>;
76
+ renderWidget: () => void;
77
+ }): void {
78
+ const { pi, teammates, spawnTeammate, getTaskListId, taskAssignmentPayload, refreshTasks, renderWidget } = opts;
79
+
80
+ pi.registerTool({
81
+ name: "teams",
82
+ label: "Teams",
83
+ description: [
84
+ "Spawn comrade agents and delegate tasks. Each comrade is a child Pi process that executes work autonomously and reports back.",
85
+ "Provide a list of tasks with optional assignees; comrades are spawned automatically and assigned round-robin if unspecified.",
86
+ "Options: contextMode=branch (clone session context), workspaceMode=worktree (git worktree isolation).",
87
+ "For governance, the user can run /team delegate on (leader restricted to coordination) or /team spawn <name> plan (worker needs plan approval).",
88
+ ].join(" "),
89
+ parameters: TeamsToolParamsSchema,
90
+
91
+ async execute(_toolCallId, params: TeamsToolParamsType, signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
92
+ const action = params.action ?? "delegate";
93
+ if (action !== "delegate") {
94
+ return {
95
+ content: [{ type: "text", text: `Unsupported action: ${String(action)}` }],
96
+ details: { action },
97
+ };
98
+ }
99
+
100
+ const inputTasks: TeamsToolDelegateTask[] = params.tasks ?? [];
101
+ if (inputTasks.length === 0) {
102
+ return {
103
+ content: [{ type: "text", text: "No tasks provided. Provide tasks: [{text, assignee?}, ...]" }],
104
+ details: { action },
105
+ };
106
+ }
107
+
108
+ const contextMode: ContextMode = params.contextMode === "branch" ? "branch" : "fresh";
109
+ const requestedWorkspaceMode: WorkspaceMode = params.workspaceMode === "worktree" ? "worktree" : "shared";
110
+
111
+ const teamId = ctx.sessionManager.getSessionId();
112
+ const teamDir = getTeamDir(teamId);
113
+ const taskListId = getTaskListId();
114
+ const cfg = await ensureTeamConfig(teamDir, {
115
+ teamId,
116
+ taskListId: taskListId ?? teamId,
117
+ leadName: "team-lead",
118
+ style: getTeamsStyleFromEnv(),
119
+ });
120
+ const style: TeamsStyle = cfg.style ?? getTeamsStyleFromEnv();
121
+
122
+ let teammateNames: string[] = [];
123
+ const explicit = params.teammates;
124
+ if (explicit && explicit.length) {
125
+ teammateNames = explicit.map((n) => sanitizeName(n)).filter((n) => n.length > 0);
126
+ }
127
+
128
+ if (teammateNames.length === 0 && teammates.size > 0) {
129
+ teammateNames = Array.from(teammates.keys());
130
+ }
131
+
132
+ if (teammateNames.length === 0) {
133
+ const maxTeammates = Math.max(1, Math.min(16, params.maxTeammates ?? 4));
134
+ const count = Math.min(maxTeammates, inputTasks.length);
135
+ const taken = new Set(teammates.keys());
136
+ teammateNames = style === "soviet" ? pickComradeNames(count, taken) : pickAgentNames(count, taken);
137
+ }
138
+
139
+ const spawned: string[] = [];
140
+ const warnings: string[] = [];
141
+
142
+ for (const name of teammateNames) {
143
+ if (signal?.aborted) break;
144
+ if (teammates.has(name)) continue;
145
+ const res = await spawnTeammate(ctx, {
146
+ name,
147
+ mode: contextMode,
148
+ workspaceMode: requestedWorkspaceMode,
149
+ });
150
+ if (!res.ok) {
151
+ warnings.push(`Failed to spawn '${name}': ${res.error}`);
152
+ continue;
153
+ }
154
+ spawned.push(res.name);
155
+ warnings.push(...res.warnings);
156
+ }
157
+
158
+ // Assign tasks (explicit assignee wins; otherwise round-robin)
159
+ const assignments: Array<{ taskId: string; assignee: string; subject: string }> = [];
160
+ let rr = 0;
161
+ for (const t of inputTasks) {
162
+ if (signal?.aborted) break;
163
+
164
+ const text = t.text.trim();
165
+ if (!text) {
166
+ warnings.push("Skipping empty task");
167
+ continue;
168
+ }
169
+
170
+ const explicitAssignee = t.assignee ? sanitizeName(t.assignee) : undefined;
171
+ const assignee = explicitAssignee ?? teammateNames[rr++ % teammateNames.length];
172
+ if (!assignee) {
173
+ warnings.push(`No assignee available for task: ${text.slice(0, 60)}`);
174
+ continue;
175
+ }
176
+
177
+ // Ensure assignee exists
178
+ if (!teammates.has(assignee)) {
179
+ const res = await spawnTeammate(ctx, {
180
+ name: assignee,
181
+ mode: contextMode,
182
+ workspaceMode: requestedWorkspaceMode,
183
+ });
184
+ if (res.ok) {
185
+ spawned.push(res.name);
186
+ warnings.push(...res.warnings);
187
+ } else {
188
+ warnings.push(`Failed to spawn assignee '${assignee}': ${res.error}`);
189
+ continue;
190
+ }
191
+ }
192
+
193
+ const description = text;
194
+ const firstLine = description.split("\n").at(0) ?? "";
195
+ const subject = firstLine.slice(0, 120);
196
+ const effectiveTlId = taskListId ?? teamId;
197
+ const task = await createTask(teamDir, effectiveTlId, { subject, description, owner: assignee });
198
+
199
+ await writeToMailbox(teamDir, effectiveTlId, assignee, {
200
+ from: cfg.leadName,
201
+ text: JSON.stringify(taskAssignmentPayload(task, cfg.leadName)),
202
+ timestamp: new Date().toISOString(),
203
+ });
204
+
205
+ assignments.push({ taskId: task.id, assignee, subject });
206
+ }
207
+
208
+ // Best-effort widget refresh
209
+ void refreshTasks().finally(renderWidget);
210
+
211
+ const lines: string[] = [];
212
+ if (spawned.length) {
213
+ lines.push(`Spawned: ${spawned.map((n) => formatMemberDisplayName(style, n)).join(", ")}`);
214
+ }
215
+ lines.push(`Delegated ${assignments.length} task(s):`);
216
+ for (const a of assignments) {
217
+ lines.push(`- #${a.taskId} → ${formatMemberDisplayName(style, a.assignee)}: ${a.subject}`);
218
+ }
219
+ if (warnings.length) {
220
+ lines.push("\nWarnings:");
221
+ for (const w of warnings) lines.push(`- ${w}`);
222
+ }
223
+
224
+ return {
225
+ content: [{ type: "text", text: lines.join("\n") }],
226
+ details: {
227
+ action,
228
+ teamId,
229
+ contextMode,
230
+ workspaceMode: requestedWorkspaceMode,
231
+ spawned,
232
+ assignments,
233
+ warnings,
234
+ },
235
+ };
236
+ },
237
+ });
238
+ }