@tmustier/pi-agent-teams 0.1.2 → 0.3.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 (39) hide show
  1. package/README.md +50 -9
  2. package/docs/claude-parity.md +22 -18
  3. package/docs/field-notes-teams-setup.md +6 -4
  4. package/docs/smoke-test-plan.md +139 -0
  5. package/eslint.config.js +74 -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 +343 -0
  11. package/extensions/teams/leader-messaging-commands.ts +148 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +57 -0
  14. package/extensions/teams/leader-task-commands.ts +421 -0
  15. package/extensions/teams/leader-team-command.ts +312 -0
  16. package/extensions/teams/leader-teams-tool.ts +227 -0
  17. package/extensions/teams/leader.ts +260 -1562
  18. package/extensions/teams/mailbox.ts +54 -29
  19. package/extensions/teams/names.ts +87 -0
  20. package/extensions/teams/protocol.ts +241 -0
  21. package/extensions/teams/spawn-types.ts +21 -0
  22. package/extensions/teams/task-store.ts +36 -21
  23. package/extensions/teams/team-config.ts +71 -25
  24. package/extensions/teams/teammate-rpc.ts +81 -23
  25. package/extensions/teams/teams-panel.ts +644 -0
  26. package/extensions/teams/teams-style.ts +62 -0
  27. package/extensions/teams/teams-ui-shared.ts +89 -0
  28. package/extensions/teams/teams-widget.ts +182 -0
  29. package/extensions/teams/worker.ts +100 -138
  30. package/extensions/teams/worktree.ts +4 -7
  31. package/package.json +32 -5
  32. package/scripts/integration-claim-test.mts +157 -0
  33. package/scripts/integration-todo-test.mts +532 -0
  34. package/scripts/lib/pi-workers.ts +105 -0
  35. package/scripts/smoke-test.mts +424 -0
  36. package/skills/agent-teams/SKILL.md +139 -0
  37. package/tsconfig.strict.json +22 -0
  38. package/extensions/teams/tasks.ts +0 -95
  39. package/scripts/smoke-test.mjs +0 -199
@@ -1,75 +1,24 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
- import type { AgentToolResult } from "@mariozechner/pi-agent-core";
6
- import { StringEnum } from "@mariozechner/pi-ai";
7
- import { Type } from "@sinclair/typebox";
8
4
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
5
  import { SessionManager } from "@mariozechner/pi-coding-agent";
10
- import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
11
- import { cleanupTeamDir } from "./cleanup.js";
12
- import {
13
- addTaskDependency,
14
- clearTasks,
15
- createTask,
16
- formatTaskLine,
17
- getTask,
18
- isTaskBlocked,
19
- listTasks,
20
- removeTaskDependency,
21
- unassignTasksForAgent,
22
- updateTask,
23
- type TaskStatus,
24
- type TeamTask,
25
- } from "./task-store.js";
6
+ import { writeToMailbox } from "./mailbox.js";
7
+ import { sanitizeName } from "./names.js";
8
+ import { TEAM_MAILBOX_NS } from "./protocol.js";
9
+ import { listTasks, unassignTasksForAgent, type TeamTask } from "./task-store.js";
26
10
  import { TeammateRpc } from "./teammate-rpc.js";
27
- import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig, type TeamMember } from "./team-config.js";
28
- import { getTeamDir, getTeamsRootDir } from "./paths.js";
11
+ import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
12
+ import { getTeamDir } from "./paths.js";
29
13
  import { ensureWorktreeCwd } from "./worktree.js";
30
-
31
- const TEAM_MAILBOX_NS = "team";
32
-
33
- type ContextMode = "fresh" | "branch";
34
- type WorkspaceMode = "shared" | "worktree";
35
-
36
- const TeamsActionSchema = StringEnum(["delegate"] as const, {
37
- description: "Teams tool action. Currently only 'delegate' is supported.",
38
- default: "delegate",
39
- });
40
-
41
- const TeamsContextModeSchema = StringEnum(["fresh", "branch"] as const, {
42
- description: "How to initialize teammate session context. 'branch' clones the leader session branch.",
43
- default: "fresh",
44
- });
45
-
46
- const TeamsWorkspaceModeSchema = StringEnum(["shared", "worktree"] as const, {
47
- description: "Workspace isolation mode. 'shared' matches Claude Teams; 'worktree' creates a git worktree per teammate.",
48
- default: "shared",
49
- });
50
-
51
- const TeamsDelegateTaskSchema = Type.Object({
52
- text: Type.String({ description: "Task / TODO text." }),
53
- assignee: Type.Optional(Type.String({ description: "Optional teammate name. If omitted, assigned round-robin." })),
54
- });
55
-
56
- const TeamsToolParams = Type.Object({
57
- action: Type.Optional(TeamsActionSchema),
58
- tasks: Type.Optional(Type.Array(TeamsDelegateTaskSchema, { description: "Tasks to delegate (action=delegate)." })),
59
- teammates: Type.Optional(
60
- Type.Array(Type.String(), { description: "Explicit teammate names to use/spawn. If omitted, uses existing or auto-generates." }),
61
- ),
62
- maxTeammates: Type.Optional(
63
- Type.Integer({
64
- description: "If teammates is omitted and none exist, spawn up to this many.",
65
- default: 4,
66
- minimum: 1,
67
- maximum: 16,
68
- }),
69
- ),
70
- contextMode: Type.Optional(TeamsContextModeSchema),
71
- workspaceMode: Type.Optional(TeamsWorkspaceModeSchema),
72
- });
14
+ import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
15
+ import { openInteractiveWidget } from "./teams-panel.js";
16
+ import { createTeamsWidget } from "./teams-widget.js";
17
+ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
18
+ import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
19
+ import { handleTeamCommand } from "./leader-team-command.js";
20
+ import { registerTeamsTool } from "./leader-teams-tool.js";
21
+ import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";
73
22
 
74
23
  function getTeamsExtensionEntryPath(): string | null {
75
24
  // In dev, teammates won't automatically have this extension unless it is installed or discoverable.
@@ -86,20 +35,10 @@ function getTeamsExtensionEntryPath(): string | null {
86
35
  }
87
36
  }
88
37
 
89
- function sanitizeName(name: string): string {
90
- return name.replace(/[^a-zA-Z0-9_-]/g, "-");
91
- }
92
-
93
38
  function shellQuote(v: string): string {
94
39
  return "'" + v.replace(/'/g, `"'"'"'`) + "'";
95
40
  }
96
41
 
97
- function parseAssigneePrefix(text: string): { assignee?: string; text: string } {
98
- const m = text.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
99
- if (!m) return { text };
100
- return { assignee: m[1], text: m[2] };
101
- }
102
-
103
42
  function getTeamSessionsDir(teamDir: string): string {
104
43
  return path.join(teamDir, "sessions");
105
44
  }
@@ -153,165 +92,29 @@ async function createSessionForTeammate(
153
92
  }
154
93
  }
155
94
 
156
- function countTasks(tasks: TeamTask[]): Record<TaskStatus, number> {
157
- return tasks.reduce(
158
- (acc, t) => {
159
- acc[t.status] = (acc[t.status] ?? 0) + 1;
160
- return acc;
161
- },
162
- { pending: 0, in_progress: 0, completed: 0 } as Record<TaskStatus, number>,
163
- );
164
- }
165
-
166
- function taskAssignmentPayload(task: TeamTask, assignedBy: string) {
167
- return {
168
- type: "task_assignment",
169
- taskId: task.id,
170
- subject: task.subject,
171
- description: task.description,
172
- assignedBy,
173
- timestamp: new Date().toISOString(),
174
- };
175
- }
176
-
177
- function isIdleNotification(
178
- text: string,
179
- ): {
180
- from: string;
181
- timestamp?: string;
182
- completedTaskId?: string;
183
- completedStatus?: string;
184
- failureReason?: string;
185
- } | null {
186
- try {
187
- const obj = JSON.parse(text);
188
- if (!obj || typeof obj !== "object") return null;
189
- if (obj.type !== "idle_notification") return null;
190
- return {
191
- from: typeof obj.from === "string" ? obj.from : "unknown",
192
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
193
- completedTaskId: typeof obj.completedTaskId === "string" ? obj.completedTaskId : undefined,
194
- completedStatus: typeof obj.completedStatus === "string" ? obj.completedStatus : undefined,
195
- failureReason: typeof obj.failureReason === "string" ? obj.failureReason : undefined,
196
- };
197
- } catch {
198
- return null;
199
- }
200
- }
201
-
202
- function isShutdownApproved(
203
- text: string,
204
- ): {
205
- from: string;
206
- requestId: string;
207
- timestamp?: string;
208
- } | null {
209
- try {
210
- const obj = JSON.parse(text);
211
- if (!obj || typeof obj !== "object") return null;
212
- if (obj.type !== "shutdown_approved") return null;
213
- if (typeof obj.requestId !== "string") return null;
214
- return {
215
- from: typeof obj.from === "string" ? obj.from : "unknown",
216
- requestId: obj.requestId,
217
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
218
- };
219
- } catch {
220
- return null;
221
- }
222
- }
223
-
224
- function isShutdownRejected(
225
- text: string,
226
- ): {
227
- from: string;
228
- requestId: string;
229
- reason: string;
230
- timestamp?: string;
231
- } | null {
232
- try {
233
- const obj = JSON.parse(text);
234
- if (!obj || typeof obj !== "object") return null;
235
- if (obj.type !== "shutdown_rejected") return null;
236
- if (typeof obj.requestId !== "string") return null;
237
- return {
238
- from: typeof obj.from === "string" ? obj.from : "unknown",
239
- requestId: obj.requestId,
240
- reason: typeof obj.reason === "string" ? obj.reason : "",
241
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
242
- };
243
- } catch {
244
- return null;
245
- }
246
- }
247
-
248
- function isPlanApprovalRequest(
249
- text: string,
250
- ): {
251
- requestId: string;
252
- from: string;
253
- plan: string;
254
- taskId?: string;
255
- timestamp?: string;
256
- } | null {
257
- try {
258
- const obj = JSON.parse(text);
259
- if (!obj || typeof obj !== "object") return null;
260
- if (obj.type !== "plan_approval_request") return null;
261
- if (typeof obj.requestId !== "string") return null;
262
- if (typeof obj.from !== "string") return null;
263
- if (typeof obj.plan !== "string") return null;
264
- return {
265
- requestId: obj.requestId,
266
- from: obj.from,
267
- plan: obj.plan,
268
- taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
269
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
270
- };
271
- } catch {
272
- return null;
273
- }
274
- }
275
-
276
- function isPeerDmSent(
277
- text: string,
278
- ): {
279
- from: string;
280
- to: string;
281
- summary: string;
282
- timestamp?: string;
283
- } | null {
284
- try {
285
- const obj = JSON.parse(text);
286
- if (!obj || typeof obj !== "object") return null;
287
- if (obj.type !== "peer_dm_sent") return null;
288
- if (typeof obj.from !== "string") return null;
289
- if (typeof obj.to !== "string") return null;
290
- if (typeof obj.summary !== "string") return null;
291
- return {
292
- from: obj.from,
293
- to: obj.to,
294
- summary: obj.summary,
295
- timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
296
- };
297
- } catch {
298
- return null;
299
- }
300
- }
301
-
95
+ // Message parsers are shared with the worker implementation.
302
96
  export function runLeader(pi: ExtensionAPI): void {
303
97
  const teammates = new Map<string, TeammateRpc>();
304
- let currentCtx: ExtensionCommandContext | null = null;
98
+ const tracker = new ActivityTracker();
99
+ const transcriptTracker = new TranscriptTracker();
100
+ const teammateEventUnsubs = new Map<string, () => void>();
101
+ let currentCtx: ExtensionContext | null = null;
305
102
  let currentTeamId: string | null = null;
306
103
  let tasks: TeamTask[] = [];
307
104
  let teamConfig: TeamConfig | null = null;
308
105
  const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
309
- let taskListId: string | null = process.env.PI_TEAMS_TASK_LIST_ID ?? null;
106
+ // Task list namespace. By default we keep it aligned with the current session id.
107
+ // (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
108
+ // and can easily be set globally, which makes the leader "lose" its tasks.)
109
+ let taskListId: string | null = null;
310
110
 
311
111
  let refreshTimer: NodeJS.Timeout | null = null;
312
112
  let inboxTimer: NodeJS.Timeout | null = null;
113
+ let refreshInFlight = false;
114
+ let inboxInFlight = false;
313
115
  let isStopping = false;
314
116
  let delegateMode = process.env.PI_TEAMS_DELEGATE_MODE === "1";
117
+ let style: TeamsStyle = getTeamsStyleFromEnv();
315
118
 
316
119
  const stopLoops = () => {
317
120
  if (refreshTimer) clearInterval(refreshTimer);
@@ -320,11 +123,20 @@ export function runLeader(pi: ExtensionAPI): void {
320
123
  inboxTimer = null;
321
124
  };
322
125
 
323
- const stopAllTeammates = async (ctx: ExtensionCommandContext, reason: string) => {
126
+ const stopAllTeammates = async (ctx: ExtensionContext, reason: string) => {
324
127
  if (teammates.size === 0) return;
325
128
  isStopping = true;
326
129
  try {
327
130
  for (const [name, t] of teammates.entries()) {
131
+ try {
132
+ teammateEventUnsubs.get(name)?.();
133
+ } catch {
134
+ // ignore
135
+ }
136
+ teammateEventUnsubs.delete(name);
137
+ tracker.reset(name);
138
+ transcriptTracker.reset(name);
139
+
328
140
  await t.stop();
329
141
  // Claude-style: unassign non-completed tasks on exit.
330
142
  const teamId = ctx.sessionManager.getSessionId();
@@ -339,6 +151,15 @@ export function runLeader(pi: ExtensionAPI): void {
339
151
  }
340
152
  };
341
153
 
154
+ const widgetFactory = createTeamsWidget({
155
+ getTeammates: () => teammates,
156
+ getTracker: () => tracker,
157
+ getTasks: () => tasks,
158
+ getTeamConfig: () => teamConfig,
159
+ getStyle: () => style,
160
+ isDelegateMode: () => delegateMode,
161
+ });
162
+
342
163
  const refreshTasks = async () => {
343
164
  if (!currentCtx || !currentTeamId) return;
344
165
  const teamDir = getTeamDir(currentTeamId);
@@ -352,96 +173,30 @@ export function runLeader(pi: ExtensionAPI): void {
352
173
  teamId: currentTeamId,
353
174
  taskListId: effectiveTaskListId,
354
175
  leadName: "team-lead",
176
+ style,
355
177
  }));
178
+ style = teamConfig.style ?? style;
356
179
  };
357
180
 
358
- const renderWidget = () => {
359
- if (!currentCtx || !currentTeamId) return;
360
-
361
- // Hide the widget entirely when there is no active team state.
362
- const hasOnlineMembers = (teamConfig?.members ?? []).some(
363
- (m) => m.role === "worker" && m.status === "online",
364
- );
365
- if (teammates.size === 0 && tasks.length === 0 && !hasOnlineMembers) {
366
- currentCtx.ui.setWidget("pi-teams", []);
367
- return;
368
- }
369
-
370
- const lines: string[] = [];
371
- lines.push(delegateMode ? "Teams [delegate]" : "Teams");
372
-
373
- const c = countTasks(tasks);
374
- lines.push(` Tasks: pending ${c.pending} • in_progress ${c.in_progress} • completed ${c.completed}`);
375
-
376
- const cfgWorkers = (teamConfig?.members ?? []).filter((m) => m.role === "worker");
377
- const cfgByName = new Map<string, TeamMember>();
378
- for (const m of cfgWorkers) cfgByName.set(m.name, m);
181
+ let widgetSuppressed = false;
379
182
 
380
- const visibleNames = new Set<string>();
381
- for (const name of teammates.keys()) visibleNames.add(name);
382
- for (const m of cfgWorkers) {
383
- if (m.status === "online") visibleNames.add(m.name);
384
- }
385
- // Fallback: show active task owners even if they haven't been persisted in config yet.
386
- for (const t of tasks) {
387
- if (t.owner && t.status === "in_progress") visibleNames.add(t.owner);
388
- }
389
-
390
- if (visibleNames.size === 0) {
391
- lines.push(" (no teammates) • /team spawn <name> [fresh|branch] [shared|worktree]");
392
- lines.push(" /team task add <text...> • /team task list");
393
- } else {
394
- for (const name of Array.from(visibleNames).sort()) {
395
- const rpc = teammates.get(name);
396
- const cfg = cfgByName.get(name);
397
-
398
- const active = tasks.find((x) => x.owner === name && x.status === "in_progress");
399
- const taskTag = active ? `task:${active.id}` : "";
400
-
401
- if (rpc) {
402
- const status = rpc.status.padEnd(9);
403
- const tail = rpc.lastAssistantText.trim().split("\n").slice(-1)[0];
404
- lines.push(
405
- ` ${name}: ${status} ${taskTag ? "• " + taskTag + " " : ""}${tail ? "• " + tail.slice(0, 60) : ""}`,
406
- );
407
- } else {
408
- const status = (cfg?.status ?? "offline").padEnd(9);
409
- const seen = cfg?.lastSeenAt ? `• seen ${cfg.lastSeenAt.slice(11, 19)}` : "";
410
- lines.push(` ${name}: ${status} ${taskTag ? "• " + taskTag : ""} ${seen}`.trimEnd());
411
- }
412
- }
413
-
414
- lines.push(" /team dm <name> <msg...> • /team broadcast <msg...>");
415
- if (teammates.size > 0) lines.push(" /team send <name> <msg...> • /team kill <name>");
416
- lines.push(" /team task add <text...> • /team task list");
417
- }
418
-
419
- currentCtx.ui.setWidget("pi-teams", lines);
183
+ const renderWidget = () => {
184
+ if (!currentCtx || widgetSuppressed) return;
185
+ // Component widget (more informative + styled). Re-setting it is also our "refresh" trigger.
186
+ currentCtx.ui.setWidget("pi-teams", widgetFactory);
420
187
  };
421
188
 
422
- type SpawnTeammateResult =
423
- | {
424
- ok: true;
425
- name: string;
426
- mode: ContextMode;
427
- workspaceMode: WorkspaceMode;
428
- childCwd: string;
429
- note?: string;
430
- warnings: string[];
431
- }
432
- | { ok: false; error: string };
433
-
434
- const spawnTeammate = async (
435
- ctx: ExtensionContext,
436
- opts: { name: string; mode?: ContextMode; workspaceMode?: WorkspaceMode; planRequired?: boolean },
437
- ): Promise<SpawnTeammateResult> => {
189
+ const spawnTeammate: SpawnTeammateFn = async (ctx, opts): Promise<SpawnTeammateResult> => {
438
190
  const warnings: string[] = [];
439
191
  const mode: ContextMode = opts.mode ?? "fresh";
440
192
  let workspaceMode: WorkspaceMode = opts.workspaceMode ?? "shared";
441
193
 
442
194
  const name = sanitizeName(opts.name);
443
- if (!name) return { ok: false, error: "Missing teammate name" };
444
- if (teammates.has(name)) return { ok: false, error: `Teammate already exists: ${name}` };
195
+ if (!name) return { ok: false, error: "Missing comrade name" };
196
+ if (teammates.has(name)) {
197
+ const strings = getTeamsStrings(style);
198
+ return { ok: false, error: `${formatMemberDisplayName(style, name)} already exists (${strings.teamNoun})` };
199
+ }
445
200
 
446
201
  const teamId = ctx.sessionManager.getSessionId();
447
202
  const teamDir = getTeamDir(teamId);
@@ -452,13 +207,34 @@ export function runLeader(pi: ExtensionAPI): void {
452
207
 
453
208
  const t = new TeammateRpc(name, sessionFile);
454
209
  teammates.set(name, t);
210
+ // Track teammate activity for the widget/panel.
211
+ const unsub = t.onEvent((ev) => {
212
+ tracker.handleEvent(name, ev);
213
+ transcriptTracker.handleEvent(name, ev);
214
+ });
215
+ teammateEventUnsubs.set(name, unsub);
455
216
  renderWidget();
456
217
 
457
218
  // On crash/close, unassign tasks like Claude.
458
219
  const leaderSessionId = teamId;
459
220
  t.onClose((code) => {
221
+ try {
222
+ teammateEventUnsubs.get(name)?.();
223
+ } catch {
224
+ // ignore
225
+ }
226
+ teammateEventUnsubs.delete(name);
227
+ tracker.reset(name);
228
+ transcriptTracker.reset(name);
229
+
460
230
  if (currentCtx?.sessionManager.getSessionId() !== leaderSessionId) return;
461
- void unassignTasksForAgent(teamDir, leaderSessionId, name, `Teammate '${name}' exited`).finally(() => {
231
+ const effectiveTlId = taskListId ?? leaderSessionId;
232
+ void unassignTasksForAgent(
233
+ teamDir,
234
+ effectiveTlId,
235
+ name,
236
+ `${formatMemberDisplayName(style, name)} ${getTeamsStrings(style).leftVerb}`,
237
+ ).finally(() => {
462
238
  void refreshTasks().finally(renderWidget);
463
239
  });
464
240
  void setMemberStatus(teamDir, name, "offline", { meta: { exitCode: code ?? undefined } });
@@ -482,10 +258,11 @@ export function runLeader(pi: ExtensionAPI): void {
482
258
  argsForChild.push("--no-extensions", "-e", teamsEntry);
483
259
  }
484
260
 
485
- argsForChild.push(
486
- "--append-system-prompt",
487
- `You are teammate '${name}'. You collaborate with a team lead. Prefer working from the shared task list.\n`,
488
- );
261
+ const systemAppend =
262
+ style === "soviet"
263
+ ? `You are comrade '${name}'. You collaborate with the chairman. Prefer working from the shared task list.\n`
264
+ : `You are teammate '${name}'. You collaborate with the team leader. Prefer working from the shared task list.\n`;
265
+ argsForChild.push("--append-system-prompt", systemAppend);
489
266
 
490
267
  const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1";
491
268
 
@@ -506,6 +283,7 @@ export function runLeader(pi: ExtensionAPI): void {
506
283
  PI_TEAMS_TASK_LIST_ID: taskListId ?? teamId,
507
284
  PI_TEAMS_AGENT_NAME: name,
508
285
  PI_TEAMS_LEAD_NAME: "team-lead",
286
+ PI_TEAMS_STYLE: style,
509
287
  PI_TEAMS_AUTO_CLAIM: autoClaim ? "1" : "0",
510
288
  ...(opts.planRequired ? { PI_TEAMS_PLAN_REQUIRED: "1" } : {}),
511
289
  },
@@ -516,7 +294,8 @@ export function runLeader(pi: ExtensionAPI): void {
516
294
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
517
295
  }
518
296
 
519
- const sessionName = `pi agent teams - comrade ${name}`;
297
+ const strings = getTeamsStrings(style);
298
+ const sessionName = `pi agent teams - ${strings.memberTitle.toLowerCase()} ${name}`;
520
299
 
521
300
  // Leader-driven session naming (so teammates are easy to spot in /resume).
522
301
  try {
@@ -537,7 +316,7 @@ export function runLeader(pi: ExtensionAPI): void {
537
316
  // ignore
538
317
  }
539
318
 
540
- await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead" });
319
+ await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead", style });
541
320
  await upsertMember(teamDir, {
542
321
  name,
543
322
  role: "worker",
@@ -556,165 +335,39 @@ export function runLeader(pi: ExtensionAPI): void {
556
335
  const pollLeaderInbox = async () => {
557
336
  if (!currentCtx || !currentTeamId) return;
558
337
  const teamDir = getTeamDir(currentTeamId);
559
- const msgs = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "team-lead");
560
- if (!msgs.length) return;
561
-
562
- for (const m of msgs) {
563
- const approved = isShutdownApproved(m.text);
564
- if (approved) {
565
- const name = sanitizeName(approved.from);
566
- const cfg = await ensureTeamConfig(teamDir, {
567
- teamId: currentTeamId,
568
- taskListId: taskListId ?? currentTeamId,
569
- leadName: "team-lead",
570
- });
571
- if (!cfg.members.some((mm) => mm.name === name)) {
572
- await upsertMember(teamDir, { name, role: "worker", status: "offline" });
573
- }
574
- await setMemberStatus(teamDir, name, "offline", {
575
- lastSeenAt: approved.timestamp,
576
- meta: {
577
- shutdownApprovedRequestId: approved.requestId,
578
- shutdownApprovedAt: approved.timestamp ?? new Date().toISOString(),
579
- },
580
- });
581
- currentCtx.ui.notify(`${name} approved shutdown`, "info");
582
- continue;
583
- }
584
-
585
- const rejected = isShutdownRejected(m.text);
586
- if (rejected) {
587
- const name = sanitizeName(rejected.from);
588
- await setMemberStatus(teamDir, name, "online", {
589
- lastSeenAt: rejected.timestamp,
590
- meta: {
591
- shutdownRejectedAt: rejected.timestamp ?? new Date().toISOString(),
592
- shutdownRejectedReason: rejected.reason,
593
- },
594
- });
595
- currentCtx.ui.notify(`${name} rejected shutdown: ${rejected.reason}`, "warning");
596
- continue;
597
- }
598
-
599
- const planReq = isPlanApprovalRequest(m.text);
600
- if (planReq) {
601
- const name = sanitizeName(planReq.from);
602
- const preview = planReq.plan.length > 500 ? planReq.plan.slice(0, 500) + "..." : planReq.plan;
603
- currentCtx.ui.notify(`${name} requests plan approval:\n${preview}`, "info");
604
- pendingPlanApprovals.set(name, {
605
- requestId: planReq.requestId,
606
- name,
607
- taskId: planReq.taskId,
608
- });
609
- continue;
610
- }
611
-
612
- const peerDm = isPeerDmSent(m.text);
613
- if (peerDm) {
614
- currentCtx.ui.notify(`${peerDm.from} → ${peerDm.to}: ${peerDm.summary}`, "info");
615
- continue;
616
- }
617
-
618
- const idle = isIdleNotification(m.text);
619
- if (idle) {
620
- const name = sanitizeName(idle.from);
621
- if (idle.failureReason) {
622
- const cfg = await ensureTeamConfig(teamDir, {
623
- teamId: currentTeamId,
624
- taskListId: taskListId ?? currentTeamId,
625
- leadName: "team-lead",
626
- });
627
- if (!cfg.members.some((mm) => mm.name === name)) {
628
- await upsertMember(teamDir, { name, role: "worker", status: "offline" });
629
- }
630
- await setMemberStatus(teamDir, name, "offline", {
631
- lastSeenAt: idle.timestamp,
632
- meta: { offlineReason: idle.failureReason },
633
- });
634
- currentCtx.ui.notify(`${name} went offline (${idle.failureReason})`, "warning");
635
- } else {
636
- const desiredSessionName = `pi agent teams - comrade ${name}`;
637
-
638
- const cfg = await ensureTeamConfig(teamDir, {
639
- teamId: currentTeamId,
640
- taskListId: taskListId ?? currentTeamId,
641
- leadName: "team-lead",
642
- });
643
-
644
- const member = cfg.members.find((mm) => mm.name === name);
645
- const existingSessionName =
646
- member?.meta && typeof (member.meta as any).sessionName === "string"
647
- ? String((member.meta as any).sessionName)
648
- : undefined;
649
- const shouldSendName = existingSessionName !== desiredSessionName;
650
-
651
- if (!member) {
652
- // Manual tmux worker: learn from idle notifications.
653
- await upsertMember(teamDir, {
654
- name,
655
- role: "worker",
656
- status: "online",
657
- lastSeenAt: idle.timestamp,
658
- meta: { sessionName: desiredSessionName },
659
- });
660
- } else {
661
- await setMemberStatus(teamDir, name, "online", {
662
- lastSeenAt: idle.timestamp,
663
- meta: { sessionName: desiredSessionName },
664
- });
665
- }
666
-
667
- if (shouldSendName) {
668
- try {
669
- const ts = new Date().toISOString();
670
- await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
671
- from: "team-lead",
672
- text: JSON.stringify({
673
- type: "set_session_name",
674
- name: desiredSessionName,
675
- from: "team-lead",
676
- timestamp: ts,
677
- }),
678
- timestamp: ts,
679
- });
680
- } catch {
681
- // ignore
682
- }
683
- }
684
-
685
- if (idle.completedTaskId && idle.completedStatus === "failed") {
686
- currentCtx.ui.notify(`${name} aborted task #${idle.completedTaskId}`, "warning");
687
- } else if (idle.completedTaskId) {
688
- currentCtx.ui.notify(`${name} is idle task #${idle.completedTaskId}`, "info");
689
- } else {
690
- currentCtx.ui.notify(`${name} is idle`, "info");
691
- }
692
- }
693
- continue;
694
- }
695
-
696
- currentCtx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
697
- }
338
+ const effectiveTaskListId = taskListId ?? currentTeamId;
339
+ await pollLeaderInboxImpl({
340
+ ctx: currentCtx,
341
+ teamId: currentTeamId,
342
+ teamDir,
343
+ taskListId: effectiveTaskListId,
344
+ leadName: teamConfig?.leadName ?? "team-lead",
345
+ style,
346
+ pendingPlanApprovals,
347
+ });
698
348
  };
699
349
 
700
350
  pi.on("tool_call", (event, _ctx) => {
701
351
  if (!delegateMode) return;
702
352
  const blockedTools = new Set(["bash", "edit", "write"]);
703
- if (blockedTools.has(event.name)) {
704
- return { block: true, reason: "Delegate mode is active use teammates for implementation." };
353
+ if (blockedTools.has(event.toolName)) {
354
+ return { block: true, reason: "Delegate mode is active - use comrades for implementation." };
705
355
  }
706
356
  });
707
357
 
708
358
  pi.on("session_start", async (_event, ctx) => {
709
- currentCtx = ctx as ExtensionCommandContext;
359
+ currentCtx = ctx;
710
360
  currentTeamId = currentCtx.sessionManager.getSessionId();
711
- if (!taskListId) taskListId = currentTeamId;
361
+ // Keep the task list aligned with the active session. If you want a shared namespace,
362
+ // use `/team task use <taskListId>` after switching.
363
+ taskListId = currentTeamId;
712
364
 
713
365
  // Claude-style: a persisted team config file.
714
366
  await ensureTeamConfig(getTeamDir(currentTeamId), {
715
367
  teamId: currentTeamId,
716
368
  taskListId: taskListId,
717
369
  leadName: "team-lead",
370
+ style,
718
371
  });
719
372
 
720
373
  await refreshTasks();
@@ -723,30 +376,49 @@ export function runLeader(pi: ExtensionAPI): void {
723
376
  stopLoops();
724
377
  refreshTimer = setInterval(async () => {
725
378
  if (isStopping) return;
726
- await refreshTasks();
727
- renderWidget();
379
+ if (refreshInFlight) return;
380
+ refreshInFlight = true;
381
+ try {
382
+ await refreshTasks();
383
+ renderWidget();
384
+ } finally {
385
+ refreshInFlight = false;
386
+ }
728
387
  }, 1000);
729
388
 
730
389
  inboxTimer = setInterval(async () => {
731
390
  if (isStopping) return;
732
- await pollLeaderInbox();
391
+ if (inboxInFlight) return;
392
+ inboxInFlight = true;
393
+ try {
394
+ await pollLeaderInbox();
395
+ } finally {
396
+ inboxInFlight = false;
397
+ }
733
398
  }, 700);
734
399
  });
735
400
 
736
401
  pi.on("session_switch", async (_event, ctx) => {
737
402
  if (currentCtx) {
738
- await stopAllTeammates(currentCtx, "Stopped due to leader session switch");
403
+ const strings = getTeamsStrings(style);
404
+ await stopAllTeammates(
405
+ currentCtx,
406
+ style === "soviet" ? `The ${strings.teamNoun} is dissolved — leader moved on` : "Stopped due to session switch",
407
+ );
739
408
  }
740
409
  stopLoops();
741
410
 
742
- currentCtx = ctx as ExtensionCommandContext;
411
+ currentCtx = ctx;
743
412
  currentTeamId = currentCtx.sessionManager.getSessionId();
744
- if (!taskListId) taskListId = currentTeamId;
413
+ // Keep the task list aligned with the active session. If you want a shared namespace,
414
+ // use `/team task use <taskListId>` after switching.
415
+ taskListId = currentTeamId;
745
416
 
746
417
  await ensureTeamConfig(getTeamDir(currentTeamId), {
747
418
  teamId: currentTeamId,
748
419
  taskListId: taskListId,
749
420
  leadName: "team-lead",
421
+ style,
750
422
  });
751
423
 
752
424
  await refreshTasks();
@@ -755,1138 +427,164 @@ export function runLeader(pi: ExtensionAPI): void {
755
427
  // Restart background refresh/poll loops for the new session.
756
428
  refreshTimer = setInterval(async () => {
757
429
  if (isStopping) return;
758
- await refreshTasks();
759
- renderWidget();
430
+ if (refreshInFlight) return;
431
+ refreshInFlight = true;
432
+ try {
433
+ await refreshTasks();
434
+ renderWidget();
435
+ } finally {
436
+ refreshInFlight = false;
437
+ }
760
438
  }, 1000);
761
439
 
762
440
  inboxTimer = setInterval(async () => {
763
441
  if (isStopping) return;
764
- await pollLeaderInbox();
442
+ if (inboxInFlight) return;
443
+ inboxInFlight = true;
444
+ try {
445
+ await pollLeaderInbox();
446
+ } finally {
447
+ inboxInFlight = false;
448
+ }
765
449
  }, 700);
766
450
  });
767
451
 
768
452
  pi.on("session_shutdown", async () => {
769
453
  if (!currentCtx) return;
770
454
  stopLoops();
771
- await stopAllTeammates(currentCtx, "Stopped due to leader shutdown");
455
+ const strings = getTeamsStrings(style);
456
+ await stopAllTeammates(currentCtx, style === "soviet" ? `The ${strings.teamNoun} is over` : "Stopped due to leader shutdown");
772
457
  });
773
458
 
774
- pi.registerTool({
775
- name: "teams",
776
- label: "Teams",
777
- description: [
778
- "Spawn teammates and delegate tasks using Claude-like primitives (task list + mailbox).",
779
- "Defaults match Claude Teams: shared workspace, explicit assignment.",
780
- "Options: workspaceMode=worktree (git worktrees), contextMode=branch (clone leader session context).",
781
- ].join(" "),
782
- parameters: TeamsToolParams,
783
-
784
- async execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<any>> {
785
- const action = (params as any).action ?? "delegate";
786
- if (action !== "delegate") {
787
- return {
788
- content: [{ type: "text", text: `Unsupported action: ${String(action)}` }],
789
- details: { action },
790
- };
791
- }
792
-
793
- const inputTasks = (params as any).tasks;
794
- if (!Array.isArray(inputTasks) || inputTasks.length === 0) {
795
- return {
796
- content: [
797
- { type: "text", text: "No tasks provided. Provide tasks: [{text, assignee?}, ...]" },
798
- ],
799
- details: { action },
800
- };
801
- }
802
-
803
- const contextMode: ContextMode = (params as any).contextMode === "branch" ? "branch" : "fresh";
804
- const requestedWorkspaceMode: WorkspaceMode =
805
- (params as any).workspaceMode === "worktree" ? "worktree" : "shared";
806
-
807
- const teamId = ctx.sessionManager.getSessionId();
808
- const teamDir = getTeamDir(teamId);
809
- await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead" });
810
-
811
- let teammateNames: string[] = [];
812
- const explicit = (params as any).teammates;
813
- if (Array.isArray(explicit) && explicit.length) {
814
- teammateNames = explicit.map((n: any) => sanitizeName(String(n))).filter(Boolean);
815
- }
816
-
817
- if (teammateNames.length === 0 && teammates.size > 0) {
818
- teammateNames = Array.from(teammates.keys());
819
- }
820
-
821
- if (teammateNames.length === 0) {
822
- const maxTeammates = Math.max(1, Math.min(16, Number((params as any).maxTeammates ?? 4) || 4));
823
- const count = Math.min(maxTeammates, inputTasks.length);
824
- teammateNames = Array.from({ length: count }, (_, i) => `agent${i + 1}`);
825
- }
826
-
827
- const spawned: string[] = [];
828
- const warnings: string[] = [];
829
-
830
- for (const name of teammateNames) {
831
- if (signal?.aborted) break;
832
- if (teammates.has(name)) continue;
833
- const res = await spawnTeammate(ctx, {
834
- name,
835
- mode: contextMode,
836
- workspaceMode: requestedWorkspaceMode,
837
- });
838
- if (!res.ok) {
839
- warnings.push(`Failed to spawn '${name}': ${res.error}`);
840
- continue;
841
- }
842
- spawned.push(res.name);
843
- warnings.push(...res.warnings);
844
- }
845
-
846
- // Assign tasks (explicit assignee wins; otherwise round-robin)
847
- const assignments: Array<{ taskId: string; assignee: string; subject: string }> = [];
848
- let rr = 0;
849
- for (const t of inputTasks) {
850
- if (signal?.aborted) break;
851
-
852
- const text = typeof t?.text === "string" ? t.text.trim() : "";
853
- if (!text) {
854
- warnings.push("Skipping empty task");
855
- continue;
856
- }
857
-
858
- const explicitAssignee = typeof t?.assignee === "string" ? sanitizeName(t.assignee) : undefined;
859
- const assignee = explicitAssignee ?? teammateNames[rr++ % teammateNames.length];
860
- if (!assignee) {
861
- warnings.push(`No assignee available for task: ${text.slice(0, 60)}`);
862
- continue;
863
- }
459
+ registerTeamsTool({
460
+ pi,
461
+ teammates,
462
+ spawnTeammate,
463
+ getTaskListId: () => taskListId,
464
+ refreshTasks,
465
+ renderWidget,
466
+ });
864
467
 
865
- // Ensure assignee exists
866
- if (!teammates.has(assignee)) {
867
- const res = await spawnTeammate(ctx, {
868
- name: assignee,
869
- mode: contextMode,
870
- workspaceMode: requestedWorkspaceMode,
871
- });
872
- if (res.ok) {
873
- spawned.push(res.name);
874
- warnings.push(...res.warnings);
875
- } else {
876
- warnings.push(`Failed to spawn assignee '${assignee}': ${res.error}`);
877
- continue;
878
- }
468
+ const openWidget = async (ctx: ExtensionCommandContext) => {
469
+ const teamId = ctx.sessionManager.getSessionId();
470
+ const teamDir = getTeamDir(teamId);
471
+ const effectiveTlId = taskListId ?? teamId;
472
+ const leadName = teamConfig?.leadName ?? "team-lead";
473
+ const strings = getTeamsStrings(style);
474
+
475
+ await openInteractiveWidget(ctx, {
476
+ getTeammates: () => teammates,
477
+ getTracker: () => tracker,
478
+ getTranscript: (n: string) => transcriptTracker.get(n),
479
+ getTasks: () => tasks,
480
+ getTeamConfig: () => teamConfig,
481
+ getStyle: () => style,
482
+ isDelegateMode: () => delegateMode,
483
+ async sendMessage(name: string, message: string) {
484
+ const rpc = teammates.get(name);
485
+ if (rpc) {
486
+ if (rpc.status === "streaming") await rpc.followUp(message);
487
+ else await rpc.prompt(message);
488
+ return;
879
489
  }
880
490
 
881
- const description = text;
882
- const subject = description.split("\n")[0].slice(0, 120);
883
- const effectiveTlId = taskListId ?? teamId;
884
- const task = await createTask(teamDir, effectiveTlId, { subject, description, owner: assignee });
885
-
886
- await writeToMailbox(teamDir, effectiveTlId, assignee, {
887
- from: "team-lead",
888
- text: JSON.stringify(taskAssignmentPayload(task, "team-lead")),
491
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
492
+ from: leadName,
493
+ text: message,
889
494
  timestamp: new Date().toISOString(),
890
495
  });
496
+ },
497
+ abortMember(name: string) {
498
+ const rpc = teammates.get(name);
499
+ if (rpc) void rpc.abort();
500
+ },
501
+ killMember(name: string) {
502
+ const rpc = teammates.get(name);
503
+ if (!rpc) return;
504
+
505
+ void rpc.stop();
506
+ teammates.delete(name);
507
+
508
+ const displayName = formatMemberDisplayName(style, name);
509
+ void unassignTasksForAgent(teamDir, effectiveTlId, name, `${displayName} ${strings.killedVerb}`);
510
+ void setMemberStatus(teamDir, name, "offline", { meta: { killedAt: new Date().toISOString() } });
511
+ void refreshTasks();
512
+ },
513
+ suppressWidget() {
514
+ widgetSuppressed = true;
515
+ ctx.ui.setWidget("pi-teams", undefined);
516
+ },
517
+ restoreWidget() {
518
+ widgetSuppressed = false;
519
+ renderWidget();
520
+ },
521
+ });
522
+ };
891
523
 
892
- assignments.push({ taskId: task.id, assignee, subject });
893
- }
524
+ pi.registerCommand("tw", {
525
+ description: "Teams: open interactive widget panel",
526
+ handler: async (_args, ctx) => {
527
+ currentCtx = ctx;
528
+ currentTeamId = ctx.sessionManager.getSessionId();
529
+ await openWidget(ctx);
530
+ },
531
+ });
894
532
 
895
- // Best-effort widget refresh
896
- void refreshTasks().finally(renderWidget);
533
+ pi.registerCommand("team-widget", {
534
+ description: "Teams: open interactive widget panel (alias for /team widget)",
535
+ handler: async (_args, ctx) => {
536
+ currentCtx = ctx;
537
+ currentTeamId = ctx.sessionManager.getSessionId();
538
+ await openWidget(ctx);
539
+ },
540
+ });
897
541
 
898
- const lines: string[] = [];
899
- if (spawned.length) lines.push(`Spawned: ${spawned.join(", ")}`);
900
- lines.push(`Delegated ${assignments.length} task(s):`);
901
- for (const a of assignments) {
902
- lines.push(`- #${a.taskId} → ${a.assignee}: ${a.subject}`);
903
- }
904
- if (warnings.length) {
905
- lines.push("\nWarnings:");
906
- for (const w of warnings) lines.push(`- ${w}`);
542
+ pi.registerCommand("swarm", {
543
+ description: "Start a team of agents to work on a task",
544
+ handler: async (args, _ctx) => {
545
+ const task = args.trim();
546
+ if (!task) {
547
+ pi.sendUserMessage("Use your /team commands to spawn a team of agents and coordinate them to complete my next request. Ask me what I'd like done.");
548
+ return;
907
549
  }
908
-
909
- return {
910
- content: [{ type: "text", text: lines.join("\n") }],
911
- details: {
912
- action,
913
- teamId,
914
- contextMode,
915
- workspaceMode: requestedWorkspaceMode,
916
- spawned,
917
- assignments,
918
- warnings,
919
- },
920
- };
550
+ pi.sendUserMessage(`Use your /team commands to spawn a team of agents and coordinate them to complete this task:\n\n${task}`);
921
551
  },
922
552
  });
923
553
 
924
554
  pi.registerCommand("team", {
925
- description: "Teams: spawn teammates + coordinate via Claude-like task list",
555
+ description: "Teams: spawn comrades + coordinate via Claude-like task list",
926
556
  handler: async (args, ctx) => {
927
557
  currentCtx = ctx;
928
558
  currentTeamId = ctx.sessionManager.getSessionId();
929
559
 
930
- const [sub, ...rest] = args.trim().split(" ");
931
- if (!sub || sub === "help") {
932
- ctx.ui.notify(
933
- [
934
- "Usage:",
935
- " /team id",
936
- " /team env <name>",
937
- " /team spawn <name> [fresh|branch] [shared|worktree] [plan]",
938
- " /team send <name> <msg...>",
939
- " /team dm <name> <msg...>",
940
- " /team broadcast <msg...>",
941
- " /team steer <name> <msg...>",
942
- " /team stop <name> [reason...]",
943
- " /team kill <name>",
944
- " /team shutdown",
945
- " /team shutdown <name> [reason...]",
946
- " /team delegate [on|off]",
947
- " /team plan approve <name>",
948
- " /team plan reject <name> [feedback...]",
949
- " /team cleanup [--force]",
950
- " /team task add <text...>",
951
- " /team task assign <id> <agent>",
952
- " /team task unassign <id>",
953
- " /team task list",
954
- " /team task clear [completed|all] [--force]",
955
- " /team task show <id>",
956
- " /team task dep add <id> <depId>",
957
- " /team task dep rm <id> <depId>",
958
- " /team task dep ls <id>",
959
- " /team task use <taskListId>",
960
- ].join("\n"),
961
- "info",
962
- );
963
- return;
964
- }
965
-
966
- switch (sub) {
967
- case "list": {
968
- await refreshTasks();
969
-
970
- const cfgWorkers = (teamConfig?.members ?? []).filter((m) => m.role === "worker");
971
- const cfgByName = new Map<string, TeamMember>();
972
- for (const m of cfgWorkers) cfgByName.set(m.name, m);
973
-
974
- const names = new Set<string>();
975
- for (const name of teammates.keys()) names.add(name);
976
- for (const name of cfgByName.keys()) names.add(name);
977
-
978
- if (names.size === 0) {
979
- ctx.ui.notify("No teammates", "info");
980
- renderWidget();
981
- return;
982
- }
983
-
984
- const lines: string[] = [];
985
- for (const name of Array.from(names).sort()) {
986
- const rpc = teammates.get(name);
987
- const cfg = cfgByName.get(name);
988
- const status = rpc ? rpc.status : cfg?.status ?? "offline";
989
- const kind = rpc ? "rpc" : cfg ? "manual" : "unknown";
990
- lines.push(`${name}: ${status} (${kind})`);
991
- }
992
-
993
- ctx.ui.notify(lines.join("\n"), "info");
994
- renderWidget();
995
- return;
996
- }
997
-
998
- case "id": {
999
- const teamId = ctx.sessionManager.getSessionId();
1000
- const effectiveTlId = taskListId ?? teamId;
1001
- const leadName = "team-lead";
1002
- const teamsRoot = getTeamsRootDir();
1003
- const teamDir = getTeamDir(teamId);
1004
-
1005
- ctx.ui.notify(
1006
- [
1007
- `teamId: ${teamId}`,
1008
- `taskListId: ${effectiveTlId}`,
1009
- `leadName: ${leadName}`,
1010
- `teamsRoot: ${teamsRoot}`,
1011
- `teamDir: ${teamDir}`,
1012
- ].join("\n"),
1013
- "info",
1014
- );
1015
- return;
1016
- }
1017
-
1018
- case "env": {
1019
- const nameRaw = rest[0];
1020
- if (!nameRaw) {
1021
- ctx.ui.notify("Usage: /team env <name>", "error");
1022
- return;
1023
- }
1024
-
1025
- const name = sanitizeName(nameRaw);
1026
- const teamId = ctx.sessionManager.getSessionId();
1027
- const effectiveTlId = taskListId ?? teamId;
1028
- const leadName = "team-lead";
1029
- const teamsRoot = getTeamsRootDir();
1030
- const teamDir = getTeamDir(teamId);
1031
- const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1" ? "1" : "0";
1032
-
1033
- const teamsEntry = getTeamsExtensionEntryPath();
1034
- const piCmd = teamsEntry ? `pi --no-extensions -e ${shellQuote(teamsEntry)}` : "pi";
1035
-
1036
- const env: Record<string, string> = {
1037
- PI_TEAMS_ROOT_DIR: teamsRoot,
1038
- PI_TEAMS_WORKER: "1",
1039
- PI_TEAMS_TEAM_ID: teamId,
1040
- PI_TEAMS_TASK_LIST_ID: effectiveTlId,
1041
- PI_TEAMS_AGENT_NAME: name,
1042
- PI_TEAMS_LEAD_NAME: leadName,
1043
- PI_TEAMS_AUTO_CLAIM: autoClaim,
1044
- };
1045
-
1046
- const exportLines = Object.entries(env)
1047
- .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
1048
- .join("\n");
1049
-
1050
- const oneLiner = Object.entries(env)
1051
- .map(([k, v]) => `${k}=${shellQuote(v)}`)
1052
- .join(" ")
1053
- .concat(` ${piCmd}`);
1054
-
1055
- ctx.ui.notify(
1056
- [
1057
- `teamId: ${teamId}`,
1058
- `taskListId: ${effectiveTlId}`,
1059
- `leadName: ${leadName}`,
1060
- `teamsRoot: ${teamsRoot}`,
1061
- `teamDir: ${teamDir}`,
1062
- "",
1063
- "Env (copy/paste):",
1064
- exportLines,
1065
- "",
1066
- "Run:",
1067
- oneLiner,
1068
- ].join("\n"),
1069
- "info",
1070
- );
1071
- return;
1072
- }
1073
-
1074
- case "cleanup": {
1075
- const flags = rest.filter((a) => a.startsWith("--"));
1076
- const argsOnly = rest.filter((a) => !a.startsWith("--"));
1077
- const force = flags.includes("--force");
1078
-
1079
- const unknownFlags = flags.filter((f) => f !== "--force");
1080
- if (unknownFlags.length) {
1081
- ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
1082
- return;
1083
- }
1084
- if (argsOnly.length) {
1085
- ctx.ui.notify("Usage: /team cleanup [--force]", "error");
1086
- return;
1087
- }
1088
-
1089
- const teamId = ctx.sessionManager.getSessionId();
1090
- const teamsRoot = getTeamsRootDir();
1091
- const teamDir = getTeamDir(teamId);
1092
-
1093
- if (!force && teammates.size > 0) {
1094
- ctx.ui.notify(
1095
- `Refusing to cleanup while ${teammates.size} RPC teammate(s) are running. Stop them first or use --force.`,
1096
- "error",
1097
- );
1098
- return;
1099
- }
1100
-
1101
- await refreshTasks();
1102
- const inProgress = tasks.filter((t) => t.status === "in_progress");
1103
- if (!force && inProgress.length > 0) {
1104
- ctx.ui.notify(
1105
- `Refusing to cleanup with ${inProgress.length} in_progress task(s). Complete/unassign them first or use --force.`,
1106
- "error",
1107
- );
1108
- return;
1109
- }
1110
-
1111
- if (!force) {
1112
- // Only prompt in interactive TTY mode. In RPC mode, confirm() would require
1113
- // the host to send extension_ui_response messages.
1114
- if (process.stdout.isTTY && process.stdin.isTTY) {
1115
- const ok = await ctx.ui.confirm(
1116
- "Cleanup team",
1117
- [
1118
- "Delete ALL team artifacts for this session?",
1119
- "",
1120
- `teamId: ${teamId}`,
1121
- `teamDir: ${teamDir}`,
1122
- `tasks: ${tasks.length} (in_progress: ${inProgress.length})`,
1123
- ].join("\n"),
1124
- );
1125
- if (!ok) return;
1126
- } else {
1127
- ctx.ui.notify("Refusing to cleanup in non-interactive mode without --force", "error");
1128
- return;
1129
- }
1130
- }
1131
-
1132
- try {
1133
- await cleanupTeamDir(teamsRoot, teamDir);
1134
- } catch (err) {
1135
- ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
1136
- return;
1137
- }
1138
-
1139
- ctx.ui.notify(`Cleaned up team directory: ${teamDir}`, "warning");
1140
- await refreshTasks();
1141
- renderWidget();
1142
- return;
1143
- }
1144
-
1145
- case "delegate": {
1146
- const arg = rest[0];
1147
- if (arg === "on") delegateMode = true;
1148
- else if (arg === "off") delegateMode = false;
1149
- else delegateMode = !delegateMode;
1150
- ctx.ui.notify(`Delegate mode ${delegateMode ? "ON" : "OFF"}`, "info");
1151
- renderWidget();
1152
- return;
1153
- }
1154
-
1155
- case "shutdown": {
1156
- const nameRaw = rest[0];
1157
-
1158
- // /team shutdown <name> [reason...] = request graceful worker shutdown via mailbox
1159
- if (nameRaw) {
1160
- const name = sanitizeName(nameRaw);
1161
- const reason = rest.slice(1).join(" ").trim() || undefined;
1162
-
1163
- const teamId = ctx.sessionManager.getSessionId();
1164
- const teamDir = getTeamDir(teamId);
1165
-
1166
- const requestId = randomUUID();
1167
- const ts = new Date().toISOString();
1168
- const payload: any = {
1169
- type: "shutdown_request",
1170
- requestId,
1171
- from: "team-lead",
1172
- timestamp: ts,
1173
- };
1174
- if (reason) payload.reason = reason;
1175
-
1176
- await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
1177
- from: "team-lead",
1178
- text: JSON.stringify(payload),
1179
- timestamp: ts,
1180
- });
1181
-
1182
- // Best-effort: record in member metadata (if present).
1183
- void setMemberStatus(teamDir, name, "online", {
1184
- meta: {
1185
- shutdownRequestedAt: ts,
1186
- shutdownRequestId: requestId,
1187
- ...(reason ? { shutdownReason: reason } : {}),
1188
- },
1189
- });
1190
-
1191
- ctx.ui.notify(`Shutdown requested for ${name}`, "info");
1192
-
1193
- // Optional fallback for RPC teammates: force stop if it doesn't exit.
1194
- const t = teammates.get(name);
1195
- if (t) {
1196
- setTimeout(() => {
1197
- if (currentCtx?.sessionManager.getSessionId() !== teamId) return;
1198
- if (t.status === "stopped" || t.status === "error") return;
1199
- void (async () => {
1200
- try {
1201
- await t.stop();
1202
- await setMemberStatus(teamDir, name, "offline", {
1203
- meta: { shutdownFallback: true, shutdownRequestId: requestId },
1204
- });
1205
- currentCtx?.ui.notify(`Shutdown timeout; killed ${name}`, "warning");
1206
- } catch {
1207
- // ignore
1208
- }
1209
- })();
1210
- }, 10_000);
1211
- }
1212
-
1213
- return;
1214
- }
1215
-
1216
- // /team shutdown (no args) = shutdown leader + all teammates
1217
- // Only prompt in interactive TTY mode. In RPC mode, confirm() would require
1218
- // the host to send extension_ui_response messages.
1219
- if (process.stdout.isTTY && process.stdin.isTTY) {
1220
- const ok = await ctx.ui.confirm("Shutdown", "Exit pi and stop all teammates?");
1221
- if (!ok) return;
1222
- }
1223
- // In RPC mode, shutdown is deferred until the next input line is handled.
1224
- // Teammates are stopped in the session_shutdown handler.
1225
- ctx.ui.notify("Shutdown requested", "info");
1226
- ctx.shutdown();
1227
- return;
1228
- }
1229
-
1230
- case "spawn": {
1231
- const nameRaw = rest[0];
1232
- const spawnArgs = rest.slice(1);
1233
-
1234
- let mode: ContextMode = "fresh";
1235
- let workspaceMode: WorkspaceMode = "shared";
1236
- let planRequired = false;
1237
- for (const a of spawnArgs) {
1238
- if (a === "fresh" || a === "branch") mode = a;
1239
- if (a === "shared" || a === "worktree") workspaceMode = a;
1240
- if (a === "plan") planRequired = true;
1241
- }
1242
-
1243
- if (!nameRaw) {
1244
- ctx.ui.notify("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan]", "error");
1245
- return;
1246
- }
1247
-
1248
- const res = await spawnTeammate(ctx, { name: nameRaw, mode, workspaceMode, planRequired });
1249
- if (!res.ok) {
1250
- ctx.ui.notify(res.error, "error");
1251
- return;
1252
- }
1253
-
1254
- for (const w of res.warnings) ctx.ui.notify(w, "warning");
1255
- ctx.ui.notify(
1256
- `Spawned teammate '${res.name}' (${res.mode}${res.note ? ", " + res.note : ""} • ${res.workspaceMode})`,
1257
- "info",
1258
- );
1259
- return;
1260
- }
1261
-
1262
- case "send": {
1263
- const nameRaw = rest[0];
1264
- const msg = rest.slice(1).join(" ").trim();
1265
- if (!nameRaw || !msg) {
1266
- ctx.ui.notify("Usage: /team send <name> <msg...>", "error");
1267
- return;
1268
- }
1269
- const name = sanitizeName(nameRaw);
1270
- const t = teammates.get(name);
1271
- if (!t) {
1272
- ctx.ui.notify(`Unknown teammate: ${name}`, "error");
1273
- return;
1274
- }
1275
- if (t.status === "streaming") await t.followUp(msg);
1276
- else await t.prompt(msg);
1277
- ctx.ui.notify(`Sent to ${name}`, "info");
1278
- renderWidget();
1279
- return;
1280
- }
1281
-
1282
- case "steer": {
1283
- const nameRaw = rest[0];
1284
- const msg = rest.slice(1).join(" ").trim();
1285
- if (!nameRaw || !msg) {
1286
- ctx.ui.notify("Usage: /team steer <name> <msg...>", "error");
1287
- return;
1288
- }
1289
- const name = sanitizeName(nameRaw);
1290
- const t = teammates.get(name);
1291
- if (!t) {
1292
- ctx.ui.notify(`Unknown teammate: ${name}`, "error");
1293
- return;
1294
- }
1295
- await t.steer(msg);
1296
- ctx.ui.notify(`Steering sent to ${name}`, "info");
1297
- renderWidget();
1298
- return;
1299
- }
1300
-
1301
- case "stop": {
1302
- const nameRaw = rest[0];
1303
- const reason = rest.slice(1).join(" ").trim();
1304
- if (!nameRaw) {
1305
- ctx.ui.notify("Usage: /team stop <name> [reason...]", "error");
1306
- return;
1307
- }
1308
- const name = sanitizeName(nameRaw);
1309
-
1310
- const teamId = ctx.sessionManager.getSessionId();
1311
- const teamDir = getTeamDir(teamId);
1312
-
1313
- // Best-effort: include current in-progress task id (if any).
1314
- await refreshTasks();
1315
- const active = tasks.find((x) => x.owner === name && x.status === "in_progress");
1316
- const taskId = active?.id;
1317
-
1318
- const ts = new Date().toISOString();
1319
- await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
1320
- from: "team-lead",
1321
- text: JSON.stringify({
1322
- type: "abort_request",
1323
- requestId: randomUUID(),
1324
- from: "team-lead",
1325
- taskId,
1326
- reason: reason || undefined,
1327
- timestamp: ts,
1328
- }),
1329
- timestamp: ts,
1330
- });
1331
-
1332
- const t = teammates.get(name);
1333
- if (t) {
1334
- // Fast-path for RPC teammates.
1335
- await t.abort();
1336
- }
1337
-
1338
- ctx.ui.notify(
1339
- `Abort requested for ${name}${taskId ? ` (task #${taskId})` : ""}${t ? "" : " (mailbox only)"}`,
1340
- "warning",
1341
- );
1342
- renderWidget();
1343
- return;
1344
- }
1345
-
1346
- case "kill": {
1347
- const nameRaw = rest[0];
1348
- if (!nameRaw) {
1349
- ctx.ui.notify("Usage: /team kill <name>", "error");
1350
- return;
1351
- }
1352
- const name = sanitizeName(nameRaw);
1353
- const t = teammates.get(name);
1354
- if (!t) {
1355
- ctx.ui.notify(`Unknown teammate: ${name}`, "error");
1356
- return;
1357
- }
1358
-
1359
- await t.stop();
1360
- teammates.delete(name);
1361
-
1362
- const teamId = ctx.sessionManager.getSessionId();
1363
- const teamDir = getTeamDir(teamId);
1364
- const effectiveTlId = taskListId ?? teamId;
1365
- await unassignTasksForAgent(teamDir, effectiveTlId, name, `Killed teammate '${name}'`);
1366
- await setMemberStatus(teamDir, name, "offline", { meta: { killedAt: new Date().toISOString() } });
1367
-
1368
- ctx.ui.notify(`Killed teammate ${name}`, "warning");
1369
- await refreshTasks();
1370
- renderWidget();
1371
- return;
1372
- }
1373
-
1374
- case "dm": {
1375
- const nameRaw = rest[0];
1376
- const msg = rest.slice(1).join(" ").trim();
1377
- if (!nameRaw || !msg) {
1378
- ctx.ui.notify("Usage: /team dm <name> <msg...>", "error");
1379
- return;
1380
- }
1381
- const name = sanitizeName(nameRaw);
1382
- const teamId = ctx.sessionManager.getSessionId();
1383
- await writeToMailbox(getTeamDir(teamId), TEAM_MAILBOX_NS, name, {
1384
- from: "team-lead",
1385
- text: msg,
1386
- timestamp: new Date().toISOString(),
1387
- });
1388
- ctx.ui.notify(`DM queued for ${name}`, "info");
1389
- return;
1390
- }
1391
-
1392
- case "broadcast": {
1393
- const msg = rest.join(" ").trim();
1394
- if (!msg) {
1395
- ctx.ui.notify("Usage: /team broadcast <msg...>", "error");
1396
- return;
1397
- }
1398
-
1399
- const teamId = ctx.sessionManager.getSessionId();
1400
- const teamDir = getTeamDir(teamId);
1401
- const leadName = "team-lead";
1402
- const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName });
1403
-
1404
- const recipients = new Set<string>();
1405
- for (const m of cfg.members) {
1406
- if (m.role === "worker") recipients.add(m.name);
1407
- }
1408
- for (const name of teammates.keys()) recipients.add(name);
1409
-
1410
- // Include task owners (helps reach manual tmux workers not tracked as RPC teammates).
1411
- await refreshTasks();
1412
- for (const t of tasks) {
1413
- if (t.owner && t.owner !== leadName) recipients.add(t.owner);
1414
- }
1415
-
1416
- const names = Array.from(recipients).sort();
1417
- if (names.length === 0) {
1418
- ctx.ui.notify("No teammates to broadcast to", "warning");
1419
- return;
1420
- }
1421
-
1422
- const ts = new Date().toISOString();
1423
- await Promise.all(
1424
- names.map((name) =>
1425
- writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
1426
- from: "team-lead",
1427
- text: msg,
1428
- timestamp: ts,
1429
- }),
1430
- ),
1431
- );
1432
-
1433
- ctx.ui.notify(`Broadcast queued for ${names.length} teammate(s): ${names.join(", ")}`, "info");
1434
- return;
1435
- }
1436
-
1437
- case "task": {
1438
- const [taskSub, ...taskRest] = rest;
1439
- const teamId = ctx.sessionManager.getSessionId();
1440
- const teamDir = getTeamDir(teamId);
1441
- const effectiveTlId = taskListId ?? teamId;
1442
-
1443
- if (!taskSub || taskSub === "help") {
1444
- ctx.ui.notify(
1445
- [
1446
- "Usage:",
1447
- " /team task add <text...>",
1448
- " /team task assign <id> <agent>",
1449
- " /team task unassign <id>",
1450
- " /team task list",
1451
- " /team task clear [completed|all] [--force]",
1452
- " /team task show <id>",
1453
- " /team task dep add <id> <depId>",
1454
- " /team task dep rm <id> <depId>",
1455
- " /team task dep ls <id>",
1456
- " /team task use <taskListId>",
1457
- "Tip: prefix with assignee, e.g. 'alice: review the API surface'",
1458
- ].join("\n"),
1459
- "info",
1460
- );
1461
- return;
1462
- }
1463
-
1464
- switch (taskSub) {
1465
- case "add": {
1466
- const raw = taskRest.join(" ").trim();
1467
- if (!raw) {
1468
- ctx.ui.notify("Usage: /team task add <text...>", "error");
1469
- return;
1470
- }
1471
-
1472
- const parsed = parseAssigneePrefix(raw);
1473
- const owner = parsed.assignee ? sanitizeName(parsed.assignee) : undefined;
1474
- const description = parsed.text.trim();
1475
- const subject = description.split("\n")[0].slice(0, 120);
1476
-
1477
- const task = await createTask(teamDir, effectiveTlId, { subject, description, owner });
1478
-
1479
- if (owner) {
1480
- const payload = taskAssignmentPayload(task, "team-lead");
1481
- await writeToMailbox(teamDir, effectiveTlId, owner, {
1482
- from: "team-lead",
1483
- text: JSON.stringify(payload),
1484
- timestamp: new Date().toISOString(),
1485
- });
1486
- }
1487
-
1488
- ctx.ui.notify(`Created task #${task.id}${owner ? ` (assigned to ${owner})` : ""}`, "info");
1489
- await refreshTasks();
1490
- renderWidget();
1491
- return;
1492
- }
1493
-
1494
- case "assign": {
1495
- const taskId = taskRest[0];
1496
- const agent = taskRest[1];
1497
- if (!taskId || !agent) {
1498
- ctx.ui.notify("Usage: /team task assign <id> <agent>", "error");
1499
- return;
1500
- }
1501
-
1502
- const owner = sanitizeName(agent);
1503
- const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
1504
- if (cur.status !== "completed") {
1505
- return { ...cur, owner, status: "pending" };
1506
- }
1507
- return { ...cur, owner };
1508
- });
1509
- if (!updated) {
1510
- ctx.ui.notify(`Task not found: ${taskId}`, "error");
1511
- return;
1512
- }
1513
-
1514
- await writeToMailbox(teamDir, effectiveTlId, owner, {
1515
- from: "team-lead",
1516
- text: JSON.stringify(taskAssignmentPayload(updated, "team-lead")),
1517
- timestamp: new Date().toISOString(),
1518
- });
1519
-
1520
- ctx.ui.notify(`Assigned task #${updated.id} to ${owner}`, "info");
1521
- await refreshTasks();
1522
- renderWidget();
1523
- return;
1524
- }
1525
-
1526
- case "unassign": {
1527
- const taskId = taskRest[0];
1528
- if (!taskId) {
1529
- ctx.ui.notify("Usage: /team task unassign <id>", "error");
1530
- return;
1531
- }
1532
-
1533
- const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
1534
- if (cur.status !== "completed") {
1535
- return { ...cur, owner: undefined, status: "pending" };
1536
- }
1537
- return { ...cur, owner: undefined };
1538
- });
1539
- if (!updated) {
1540
- ctx.ui.notify(`Task not found: ${taskId}`, "error");
1541
- return;
1542
- }
1543
-
1544
- ctx.ui.notify(`Unassigned task #${updated.id}`, "info");
1545
- await refreshTasks();
1546
- renderWidget();
1547
- return;
1548
- }
1549
-
1550
- case "show": {
1551
- const taskId = taskRest[0];
1552
- if (!taskId) {
1553
- ctx.ui.notify("Usage: /team task show <id>", "error");
1554
- return;
1555
- }
1556
-
1557
- const task = await getTask(teamDir, effectiveTlId, taskId);
1558
- if (!task) {
1559
- ctx.ui.notify(`Task not found: ${taskId}`, "error");
1560
- return;
1561
- }
1562
-
1563
- const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
1564
-
1565
- const lines: string[] = [];
1566
- lines.push(`#${task.id} ${task.subject}`);
1567
- lines.push(
1568
- `status: ${task.status}${blocked ? " (blocked)" : ""}${task.owner ? ` • owner: ${task.owner}` : ""}`,
1569
- );
1570
- if (task.blockedBy.length) lines.push(`deps: ${task.blockedBy.join(", ")}`);
1571
- if (task.blocks.length) lines.push(`blocks: ${task.blocks.join(", ")}`);
1572
- lines.push("");
1573
- lines.push(task.description);
1574
-
1575
- const result = typeof task.metadata?.result === "string" ? (task.metadata.result as string) : undefined;
1576
- if (result) {
1577
- lines.push("");
1578
- lines.push("result:");
1579
- lines.push(result);
1580
- }
1581
-
1582
- ctx.ui.notify(lines.join("\n"), "info");
1583
- return;
1584
- }
1585
-
1586
- case "dep": {
1587
- const [depSub, ...depRest] = taskRest;
1588
- if (!depSub || depSub === "help") {
1589
- ctx.ui.notify(
1590
- [
1591
- "Usage:",
1592
- " /team task dep add <id> <depId>",
1593
- " /team task dep rm <id> <depId>",
1594
- " /team task dep ls <id>",
1595
- ].join("\n"),
1596
- "info",
1597
- );
1598
- return;
1599
- }
1600
-
1601
- switch (depSub) {
1602
- case "add": {
1603
- const taskId = depRest[0];
1604
- const depId = depRest[1];
1605
- if (!taskId || !depId) {
1606
- ctx.ui.notify("Usage: /team task dep add <id> <depId>", "error");
1607
- return;
1608
- }
1609
-
1610
- const res = await addTaskDependency(teamDir, effectiveTlId, taskId, depId);
1611
- if (!res.ok) {
1612
- ctx.ui.notify(res.error, "error");
1613
- return;
1614
- }
1615
-
1616
- ctx.ui.notify(`Added dependency: #${taskId} depends on #${depId}`, "info");
1617
- await refreshTasks();
1618
- renderWidget();
1619
- return;
1620
- }
1621
-
1622
- case "rm": {
1623
- const taskId = depRest[0];
1624
- const depId = depRest[1];
1625
- if (!taskId || !depId) {
1626
- ctx.ui.notify("Usage: /team task dep rm <id> <depId>", "error");
1627
- return;
1628
- }
1629
-
1630
- const res = await removeTaskDependency(teamDir, effectiveTlId, taskId, depId);
1631
- if (!res.ok) {
1632
- ctx.ui.notify(res.error, "error");
1633
- return;
1634
- }
1635
-
1636
- ctx.ui.notify(`Removed dependency: #${taskId} no longer depends on #${depId}`, "info");
1637
- await refreshTasks();
1638
- renderWidget();
1639
- return;
1640
- }
1641
-
1642
- case "ls": {
1643
- const taskId = depRest[0];
1644
- if (!taskId) {
1645
- ctx.ui.notify("Usage: /team task dep ls <id>", "error");
1646
- return;
1647
- }
1648
-
1649
- await refreshTasks();
1650
- const task = tasks.find((t) => t.id === taskId) ?? (await getTask(teamDir, effectiveTlId, taskId));
1651
- if (!task) {
1652
- ctx.ui.notify(`Task not found: ${taskId}`, "error");
1653
- return;
1654
- }
1655
-
1656
- const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
1657
-
1658
- const lines: string[] = [];
1659
- lines.push(`#${task.id} ${task.subject}`);
1660
- lines.push(`${blocked ? "blocked" : "unblocked"} • deps:${task.blockedBy.length} • blocks:${task.blocks.length}`);
1661
-
1662
- lines.push("");
1663
- lines.push("blockedBy:");
1664
- if (!task.blockedBy.length) {
1665
- lines.push(" (none)");
1666
- } else {
1667
- for (const id of task.blockedBy) {
1668
- const dep = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
1669
- lines.push(dep ? ` - #${id} ${dep.status} ${dep.subject}` : ` - #${id} (missing)`);
1670
- }
1671
- }
1672
-
1673
- lines.push("");
1674
- lines.push("blocks:");
1675
- if (!task.blocks.length) {
1676
- lines.push(" (none)");
1677
- } else {
1678
- for (const id of task.blocks) {
1679
- const child = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
1680
- lines.push(child ? ` - #${id} ${child.status} ${child.subject}` : ` - #${id} (missing)`);
1681
- }
1682
- }
1683
-
1684
- ctx.ui.notify(lines.join("\n"), "info");
1685
- return;
1686
- }
1687
-
1688
- default: {
1689
- ctx.ui.notify(`Unknown dep subcommand: ${depSub}`, "error");
1690
- return;
1691
- }
1692
- }
1693
- }
1694
-
1695
- case "clear": {
1696
- const flags = taskRest.filter((a) => a.startsWith("--"));
1697
- const argsOnly = taskRest.filter((a) => !a.startsWith("--"));
1698
- const force = flags.includes("--force");
1699
-
1700
- const unknownFlags = flags.filter((f) => f !== "--force");
1701
- if (unknownFlags.length) {
1702
- ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
1703
- return;
1704
- }
1705
-
1706
- if (argsOnly.length > 1) {
1707
- ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
1708
- return;
1709
- }
1710
-
1711
- const modeArg = argsOnly[0];
1712
- if (modeArg && modeArg !== "completed" && modeArg !== "all") {
1713
- ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
1714
- return;
1715
- }
1716
- const mode = modeArg === "all" ? "all" : "completed";
1717
-
1718
- await refreshTasks();
1719
- const toDelete =
1720
- mode === "all" ? tasks.length : tasks.filter((t) => t.status === "completed").length;
1721
-
1722
- if (!force) {
1723
- // Only prompt in interactive TTY mode. In RPC mode, confirm() would require
1724
- // the host to send extension_ui_response messages.
1725
- if (process.stdout.isTTY && process.stdin.isTTY) {
1726
- const title = mode === "all" ? "Clear task list" : "Clear completed tasks";
1727
- const body =
1728
- mode === "all"
1729
- ? `Delete ALL ${toDelete} task(s) from the task list? This cannot be undone.`
1730
- : `Delete ${toDelete} completed task(s) from the task list? This cannot be undone.`;
1731
- const ok = await ctx.ui.confirm(title, body);
1732
- if (!ok) return;
1733
- } else {
1734
- ctx.ui.notify("Refusing to clear tasks in non-interactive mode without --force", "error");
1735
- return;
1736
- }
1737
- }
1738
-
1739
- const res = await clearTasks(teamDir, effectiveTlId, mode);
1740
- const deleted = res.deletedTaskIds.length;
1741
-
1742
- if (res.errors.length) {
1743
- ctx.ui.notify(
1744
- `Cleared ${deleted} task(s) (${mode}) with ${res.errors.length} error(s)`,
1745
- "warning",
1746
- );
1747
- const preview = res.errors
1748
- .slice(0, 8)
1749
- .map((e) => `- ${path.basename(e.file)}: ${e.error}`)
1750
- .join("\n");
1751
- ctx.ui.notify(`Errors:\n${preview}${res.errors.length > 8 ? `\n... +${res.errors.length - 8} more` : ""}`,
1752
- "warning",
1753
- );
1754
- } else if (deleted === 0) {
1755
- ctx.ui.notify(`No task(s) cleared (${mode})`, "info");
1756
- } else {
1757
- ctx.ui.notify(`Cleared ${deleted} task(s) (${mode})`, "warning");
1758
- }
1759
-
1760
- await refreshTasks();
1761
- renderWidget();
1762
- return;
1763
- }
1764
-
1765
- case "list": {
1766
- await refreshTasks();
1767
- if (!tasks.length) {
1768
- ctx.ui.notify("No tasks", "info");
1769
- return;
1770
- }
1771
-
1772
- const slice = tasks.slice(-30);
1773
- const blocked = await Promise.all(
1774
- slice.map(async (t) => (t.status === "completed" ? false : await isTaskBlocked(teamDir, effectiveTlId, t))),
1775
- );
1776
-
1777
- const preview = slice.map((t, i) => formatTaskLine(t, { blocked: blocked[i] })).join("\n");
1778
- ctx.ui.notify(preview, "info");
1779
- return;
1780
- }
1781
-
1782
- case "use": {
1783
- const newId = taskRest[0];
1784
- if (!newId) {
1785
- ctx.ui.notify("Usage: /team task use <taskListId>", "error");
1786
- return;
1787
- }
1788
- taskListId = newId;
1789
- await ensureTeamConfig(teamDir, { teamId, taskListId: newId, leadName: "team-lead" });
1790
- ctx.ui.notify(`Task list ID set to: ${taskListId}`, "info");
1791
- await refreshTasks();
1792
- renderWidget();
1793
- return;
1794
- }
1795
-
1796
- default: {
1797
- ctx.ui.notify(`Unknown task subcommand: ${taskSub}`, "error");
1798
- return;
1799
- }
1800
- }
1801
- }
1802
-
1803
- case "plan": {
1804
- const [planSub, ...planRest] = rest;
1805
- if (!planSub || planSub === "help") {
1806
- ctx.ui.notify(
1807
- [
1808
- "Usage:",
1809
- " /team plan approve <name>",
1810
- " /team plan reject <name> [feedback...]",
1811
- ].join("\n"),
1812
- "info",
1813
- );
1814
- return;
1815
- }
1816
-
1817
- if (planSub === "approve") {
1818
- const nameRaw = planRest[0];
1819
- if (!nameRaw) {
1820
- ctx.ui.notify("Usage: /team plan approve <name>", "error");
1821
- return;
1822
- }
1823
- const name = sanitizeName(nameRaw);
1824
- const pending = pendingPlanApprovals.get(name);
1825
- if (!pending) {
1826
- ctx.ui.notify(`No pending plan approval for ${name}`, "error");
1827
- return;
1828
- }
1829
-
1830
- const teamId = ctx.sessionManager.getSessionId();
1831
- const teamDir = getTeamDir(teamId);
1832
- const ts = new Date().toISOString();
1833
- await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
1834
- from: "team-lead",
1835
- text: JSON.stringify({
1836
- type: "plan_approved",
1837
- requestId: pending.requestId,
1838
- from: "team-lead",
1839
- timestamp: ts,
1840
- }),
1841
- timestamp: ts,
1842
- });
1843
- pendingPlanApprovals.delete(name);
1844
- ctx.ui.notify(`Approved plan for ${name}`, "info");
1845
- return;
1846
- }
1847
-
1848
- if (planSub === "reject") {
1849
- const nameRaw = planRest[0];
1850
- if (!nameRaw) {
1851
- ctx.ui.notify("Usage: /team plan reject <name> [feedback...]", "error");
1852
- return;
1853
- }
1854
- const name = sanitizeName(nameRaw);
1855
- const pending = pendingPlanApprovals.get(name);
1856
- if (!pending) {
1857
- ctx.ui.notify(`No pending plan approval for ${name}`, "error");
1858
- return;
1859
- }
1860
-
1861
- const feedback = planRest.slice(1).join(" ").trim() || "Plan rejected";
1862
- const teamId = ctx.sessionManager.getSessionId();
1863
- const teamDir = getTeamDir(teamId);
1864
- const ts = new Date().toISOString();
1865
- await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
1866
- from: "team-lead",
1867
- text: JSON.stringify({
1868
- type: "plan_rejected",
1869
- requestId: pending.requestId,
1870
- from: "team-lead",
1871
- feedback,
1872
- timestamp: ts,
1873
- }),
1874
- timestamp: ts,
1875
- });
1876
- pendingPlanApprovals.delete(name);
1877
- ctx.ui.notify(`Rejected plan for ${name}: ${feedback}`, "info");
1878
- return;
1879
- }
1880
-
1881
- ctx.ui.notify(`Unknown plan subcommand: ${planSub}`, "error");
1882
- return;
1883
- }
1884
-
1885
- default: {
1886
- ctx.ui.notify(`Unknown subcommand: ${sub}`, "error");
1887
- return;
1888
- }
1889
- }
560
+ await handleTeamCommand({
561
+ args,
562
+ ctx,
563
+ teammates,
564
+ getTeamConfig: () => teamConfig,
565
+ getTasks: () => tasks,
566
+ refreshTasks,
567
+ renderWidget,
568
+ getTaskListId: () => taskListId,
569
+ setTaskListId: (id) => {
570
+ taskListId = id;
571
+ },
572
+ pendingPlanApprovals,
573
+ getDelegateMode: () => delegateMode,
574
+ setDelegateMode: (next) => {
575
+ delegateMode = next;
576
+ },
577
+ getStyle: () => style,
578
+ setStyle: (next) => {
579
+ style = next;
580
+ },
581
+ spawnTeammate,
582
+ openWidget,
583
+ getTeamsExtensionEntryPath,
584
+ shellQuote,
585
+ getCurrentCtx: () => currentCtx,
586
+ stopAllTeammates,
587
+ });
1890
588
  },
1891
589
  });
1892
590
  }