@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
@@ -1,76 +1,45 @@
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";
11
+ import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
28
12
  import { getTeamDir, getTeamsRootDir } from "./paths.js";
29
13
  import { ensureWorktreeCwd } from "./worktree.js";
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 { handleTeamTaskCommand } from "./leader-task-commands.js";
20
+ import { handleTeamPlanCommand } from "./leader-plan-commands.js";
21
+ import { handleTeamSpawnCommand } from "./leader-spawn-command.js";
22
+ import { registerTeamsTool } from "./leader-teams-tool.js";
23
+ import {
24
+ handleTeamBroadcastCommand,
25
+ handleTeamDmCommand,
26
+ handleTeamSendCommand,
27
+ handleTeamSteerCommand,
28
+ } from "./leader-messaging-commands.js";
29
+ import { handleTeamEnvCommand, handleTeamIdCommand, handleTeamListCommand } from "./leader-info-commands.js";
30
+ import {
31
+ handleTeamCleanupCommand,
32
+ handleTeamDelegateCommand,
33
+ handleTeamKillCommand,
34
+ handleTeamShutdownCommand,
35
+ handleTeamStopCommand,
36
+ handleTeamStyleCommand,
37
+ } from "./leader-lifecycle-commands.js";
30
38
 
31
- const TEAM_MAILBOX_NS = "team";
32
39
 
33
40
  type ContextMode = "fresh" | "branch";
34
41
  type WorkspaceMode = "shared" | "worktree";
35
42
 
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
- });
73
-
74
43
  function getTeamsExtensionEntryPath(): string | null {
75
44
  // In dev, teammates won't automatically have this extension unless it is installed or discoverable.
76
45
  // We try to load the same extension entry explicitly (and disable extension discovery to avoid duplicates).
@@ -86,10 +55,6 @@ function getTeamsExtensionEntryPath(): string | null {
86
55
  }
87
56
  }
88
57
 
89
- function sanitizeName(name: string): string {
90
- return name.replace(/[^a-zA-Z0-9_-]/g, "-");
91
- }
92
-
93
58
  function shellQuote(v: string): string {
94
59
  return "'" + v.replace(/'/g, `"'"'"'`) + "'";
95
60
  }
@@ -97,7 +62,10 @@ function shellQuote(v: string): string {
97
62
  function parseAssigneePrefix(text: string): { assignee?: string; text: string } {
98
63
  const m = text.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
99
64
  if (!m) return { text };
100
- return { assignee: m[1], text: m[2] };
65
+ const assignee = m[1];
66
+ const rest = m[2];
67
+ if (!assignee || !rest) return { text };
68
+ return { assignee, text: rest };
101
69
  }
102
70
 
103
71
  function getTeamSessionsDir(teamDir: string): string {
@@ -153,16 +121,6 @@ async function createSessionForTeammate(
153
121
  }
154
122
  }
155
123
 
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
124
  function taskAssignmentPayload(task: TeamTask, assignedBy: string) {
167
125
  return {
168
126
  type: "task_assignment",
@@ -174,144 +132,29 @@ function taskAssignmentPayload(task: TeamTask, assignedBy: string) {
174
132
  };
175
133
  }
176
134
 
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
-
135
+ // Message parsers are shared with the worker implementation.
302
136
  export function runLeader(pi: ExtensionAPI): void {
303
137
  const teammates = new Map<string, TeammateRpc>();
304
- let currentCtx: ExtensionCommandContext | null = null;
138
+ const tracker = new ActivityTracker();
139
+ const transcriptTracker = new TranscriptTracker();
140
+ const teammateEventUnsubs = new Map<string, () => void>();
141
+ let currentCtx: ExtensionContext | null = null;
305
142
  let currentTeamId: string | null = null;
306
143
  let tasks: TeamTask[] = [];
307
144
  let teamConfig: TeamConfig | null = null;
308
145
  const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
309
- let taskListId: string | null = process.env.PI_TEAMS_TASK_LIST_ID ?? null;
146
+ // Task list namespace. By default we keep it aligned with the current session id.
147
+ // (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
148
+ // and can easily be set globally, which makes the leader "lose" its tasks.)
149
+ let taskListId: string | null = null;
310
150
 
311
151
  let refreshTimer: NodeJS.Timeout | null = null;
312
152
  let inboxTimer: NodeJS.Timeout | null = null;
153
+ let refreshInFlight = false;
154
+ let inboxInFlight = false;
313
155
  let isStopping = false;
314
156
  let delegateMode = process.env.PI_TEAMS_DELEGATE_MODE === "1";
157
+ let style: TeamsStyle = getTeamsStyleFromEnv();
315
158
 
316
159
  const stopLoops = () => {
317
160
  if (refreshTimer) clearInterval(refreshTimer);
@@ -320,11 +163,20 @@ export function runLeader(pi: ExtensionAPI): void {
320
163
  inboxTimer = null;
321
164
  };
322
165
 
323
- const stopAllTeammates = async (ctx: ExtensionCommandContext, reason: string) => {
166
+ const stopAllTeammates = async (ctx: ExtensionContext, reason: string) => {
324
167
  if (teammates.size === 0) return;
325
168
  isStopping = true;
326
169
  try {
327
170
  for (const [name, t] of teammates.entries()) {
171
+ try {
172
+ teammateEventUnsubs.get(name)?.();
173
+ } catch {
174
+ // ignore
175
+ }
176
+ teammateEventUnsubs.delete(name);
177
+ tracker.reset(name);
178
+ transcriptTracker.reset(name);
179
+
328
180
  await t.stop();
329
181
  // Claude-style: unassign non-completed tasks on exit.
330
182
  const teamId = ctx.sessionManager.getSessionId();
@@ -339,6 +191,15 @@ export function runLeader(pi: ExtensionAPI): void {
339
191
  }
340
192
  };
341
193
 
194
+ const widgetFactory = createTeamsWidget({
195
+ getTeammates: () => teammates,
196
+ getTracker: () => tracker,
197
+ getTasks: () => tasks,
198
+ getTeamConfig: () => teamConfig,
199
+ getStyle: () => style,
200
+ isDelegateMode: () => delegateMode,
201
+ });
202
+
342
203
  const refreshTasks = async () => {
343
204
  if (!currentCtx || !currentTeamId) return;
344
205
  const teamDir = getTeamDir(currentTeamId);
@@ -352,71 +213,17 @@ export function runLeader(pi: ExtensionAPI): void {
352
213
  teamId: currentTeamId,
353
214
  taskListId: effectiveTaskListId,
354
215
  leadName: "team-lead",
216
+ style,
355
217
  }));
218
+ style = teamConfig.style ?? style;
356
219
  };
357
220
 
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");
221
+ let widgetSuppressed = false;
372
222
 
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);
379
-
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);
223
+ const renderWidget = () => {
224
+ if (!currentCtx || widgetSuppressed) return;
225
+ // Component widget (more informative + styled). Re-setting it is also our "refresh" trigger.
226
+ currentCtx.ui.setWidget("pi-teams", widgetFactory);
420
227
  };
421
228
 
422
229
  type SpawnTeammateResult =
@@ -440,8 +247,11 @@ export function runLeader(pi: ExtensionAPI): void {
440
247
  let workspaceMode: WorkspaceMode = opts.workspaceMode ?? "shared";
441
248
 
442
249
  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}` };
250
+ if (!name) return { ok: false, error: "Missing comrade name" };
251
+ if (teammates.has(name)) {
252
+ const strings = getTeamsStrings(style);
253
+ return { ok: false, error: `${formatMemberDisplayName(style, name)} already exists (${strings.teamNoun})` };
254
+ }
445
255
 
446
256
  const teamId = ctx.sessionManager.getSessionId();
447
257
  const teamDir = getTeamDir(teamId);
@@ -452,13 +262,34 @@ export function runLeader(pi: ExtensionAPI): void {
452
262
 
453
263
  const t = new TeammateRpc(name, sessionFile);
454
264
  teammates.set(name, t);
265
+ // Track teammate activity for the widget/panel.
266
+ const unsub = t.onEvent((ev) => {
267
+ tracker.handleEvent(name, ev);
268
+ transcriptTracker.handleEvent(name, ev);
269
+ });
270
+ teammateEventUnsubs.set(name, unsub);
455
271
  renderWidget();
456
272
 
457
273
  // On crash/close, unassign tasks like Claude.
458
274
  const leaderSessionId = teamId;
459
275
  t.onClose((code) => {
276
+ try {
277
+ teammateEventUnsubs.get(name)?.();
278
+ } catch {
279
+ // ignore
280
+ }
281
+ teammateEventUnsubs.delete(name);
282
+ tracker.reset(name);
283
+ transcriptTracker.reset(name);
284
+
460
285
  if (currentCtx?.sessionManager.getSessionId() !== leaderSessionId) return;
461
- void unassignTasksForAgent(teamDir, leaderSessionId, name, `Teammate '${name}' exited`).finally(() => {
286
+ const effectiveTlId = taskListId ?? leaderSessionId;
287
+ void unassignTasksForAgent(
288
+ teamDir,
289
+ effectiveTlId,
290
+ name,
291
+ `${formatMemberDisplayName(style, name)} ${getTeamsStrings(style).leftVerb}`,
292
+ ).finally(() => {
462
293
  void refreshTasks().finally(renderWidget);
463
294
  });
464
295
  void setMemberStatus(teamDir, name, "offline", { meta: { exitCode: code ?? undefined } });
@@ -482,10 +313,11 @@ export function runLeader(pi: ExtensionAPI): void {
482
313
  argsForChild.push("--no-extensions", "-e", teamsEntry);
483
314
  }
484
315
 
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
- );
316
+ const systemAppend =
317
+ style === "soviet"
318
+ ? `You are comrade '${name}'. You collaborate with the chairman. Prefer working from the shared task list.\n`
319
+ : `You are teammate '${name}'. You collaborate with the team leader. Prefer working from the shared task list.\n`;
320
+ argsForChild.push("--append-system-prompt", systemAppend);
489
321
 
490
322
  const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1";
491
323
 
@@ -506,6 +338,7 @@ export function runLeader(pi: ExtensionAPI): void {
506
338
  PI_TEAMS_TASK_LIST_ID: taskListId ?? teamId,
507
339
  PI_TEAMS_AGENT_NAME: name,
508
340
  PI_TEAMS_LEAD_NAME: "team-lead",
341
+ PI_TEAMS_STYLE: style,
509
342
  PI_TEAMS_AUTO_CLAIM: autoClaim ? "1" : "0",
510
343
  ...(opts.planRequired ? { PI_TEAMS_PLAN_REQUIRED: "1" } : {}),
511
344
  },
@@ -516,7 +349,8 @@ export function runLeader(pi: ExtensionAPI): void {
516
349
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
517
350
  }
518
351
 
519
- const sessionName = `pi agent teams - comrade ${name}`;
352
+ const strings = getTeamsStrings(style);
353
+ const sessionName = `pi agent teams - ${strings.memberTitle.toLowerCase()} ${name}`;
520
354
 
521
355
  // Leader-driven session naming (so teammates are easy to spot in /resume).
522
356
  try {
@@ -537,7 +371,7 @@ export function runLeader(pi: ExtensionAPI): void {
537
371
  // ignore
538
372
  }
539
373
 
540
- await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead" });
374
+ await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead", style });
541
375
  await upsertMember(teamDir, {
542
376
  name,
543
377
  role: "worker",
@@ -556,165 +390,39 @@ export function runLeader(pi: ExtensionAPI): void {
556
390
  const pollLeaderInbox = async () => {
557
391
  if (!currentCtx || !currentTeamId) return;
558
392
  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
- }
393
+ const effectiveTaskListId = taskListId ?? currentTeamId;
394
+ await pollLeaderInboxImpl({
395
+ ctx: currentCtx,
396
+ teamId: currentTeamId,
397
+ teamDir,
398
+ taskListId: effectiveTaskListId,
399
+ leadName: teamConfig?.leadName ?? "team-lead",
400
+ style,
401
+ pendingPlanApprovals,
402
+ });
698
403
  };
699
404
 
700
405
  pi.on("tool_call", (event, _ctx) => {
701
406
  if (!delegateMode) return;
702
407
  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." };
408
+ if (blockedTools.has(event.toolName)) {
409
+ return { block: true, reason: "Delegate mode is active - use comrades for implementation." };
705
410
  }
706
411
  });
707
412
 
708
413
  pi.on("session_start", async (_event, ctx) => {
709
- currentCtx = ctx as ExtensionCommandContext;
414
+ currentCtx = ctx;
710
415
  currentTeamId = currentCtx.sessionManager.getSessionId();
711
- if (!taskListId) taskListId = currentTeamId;
416
+ // Keep the task list aligned with the active session. If you want a shared namespace,
417
+ // use `/team task use <taskListId>` after switching.
418
+ taskListId = currentTeamId;
712
419
 
713
420
  // Claude-style: a persisted team config file.
714
421
  await ensureTeamConfig(getTeamDir(currentTeamId), {
715
422
  teamId: currentTeamId,
716
423
  taskListId: taskListId,
717
424
  leadName: "team-lead",
425
+ style,
718
426
  });
719
427
 
720
428
  await refreshTasks();
@@ -723,30 +431,49 @@ export function runLeader(pi: ExtensionAPI): void {
723
431
  stopLoops();
724
432
  refreshTimer = setInterval(async () => {
725
433
  if (isStopping) return;
726
- await refreshTasks();
727
- renderWidget();
434
+ if (refreshInFlight) return;
435
+ refreshInFlight = true;
436
+ try {
437
+ await refreshTasks();
438
+ renderWidget();
439
+ } finally {
440
+ refreshInFlight = false;
441
+ }
728
442
  }, 1000);
729
443
 
730
444
  inboxTimer = setInterval(async () => {
731
445
  if (isStopping) return;
732
- await pollLeaderInbox();
446
+ if (inboxInFlight) return;
447
+ inboxInFlight = true;
448
+ try {
449
+ await pollLeaderInbox();
450
+ } finally {
451
+ inboxInFlight = false;
452
+ }
733
453
  }, 700);
734
454
  });
735
455
 
736
456
  pi.on("session_switch", async (_event, ctx) => {
737
457
  if (currentCtx) {
738
- await stopAllTeammates(currentCtx, "Stopped due to leader session switch");
458
+ const strings = getTeamsStrings(style);
459
+ await stopAllTeammates(
460
+ currentCtx,
461
+ style === "soviet" ? `The ${strings.teamNoun} is dissolved — leader moved on` : "Stopped due to session switch",
462
+ );
739
463
  }
740
464
  stopLoops();
741
465
 
742
- currentCtx = ctx as ExtensionCommandContext;
466
+ currentCtx = ctx;
743
467
  currentTeamId = currentCtx.sessionManager.getSessionId();
744
- if (!taskListId) taskListId = currentTeamId;
468
+ // Keep the task list aligned with the active session. If you want a shared namespace,
469
+ // use `/team task use <taskListId>` after switching.
470
+ taskListId = currentTeamId;
745
471
 
746
472
  await ensureTeamConfig(getTeamDir(currentTeamId), {
747
473
  teamId: currentTeamId,
748
474
  taskListId: taskListId,
749
475
  leadName: "team-lead",
476
+ style,
750
477
  });
751
478
 
752
479
  await refreshTasks();
@@ -755,174 +482,133 @@ export function runLeader(pi: ExtensionAPI): void {
755
482
  // Restart background refresh/poll loops for the new session.
756
483
  refreshTimer = setInterval(async () => {
757
484
  if (isStopping) return;
758
- await refreshTasks();
759
- renderWidget();
485
+ if (refreshInFlight) return;
486
+ refreshInFlight = true;
487
+ try {
488
+ await refreshTasks();
489
+ renderWidget();
490
+ } finally {
491
+ refreshInFlight = false;
492
+ }
760
493
  }, 1000);
761
494
 
762
495
  inboxTimer = setInterval(async () => {
763
496
  if (isStopping) return;
764
- await pollLeaderInbox();
497
+ if (inboxInFlight) return;
498
+ inboxInFlight = true;
499
+ try {
500
+ await pollLeaderInbox();
501
+ } finally {
502
+ inboxInFlight = false;
503
+ }
765
504
  }, 700);
766
505
  });
767
506
 
768
507
  pi.on("session_shutdown", async () => {
769
508
  if (!currentCtx) return;
770
509
  stopLoops();
771
- await stopAllTeammates(currentCtx, "Stopped due to leader shutdown");
510
+ const strings = getTeamsStrings(style);
511
+ await stopAllTeammates(currentCtx, style === "soviet" ? `The ${strings.teamNoun} is over` : "Stopped due to leader shutdown");
772
512
  });
773
513
 
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
- }
514
+ registerTeamsTool({
515
+ pi,
516
+ teammates,
517
+ spawnTeammate,
518
+ getTaskListId: () => taskListId,
519
+ taskAssignmentPayload,
520
+ refreshTasks,
521
+ renderWidget,
522
+ });
864
523
 
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
- }
524
+ const openWidget = async (ctx: ExtensionCommandContext) => {
525
+ const teamId = ctx.sessionManager.getSessionId();
526
+ const teamDir = getTeamDir(teamId);
527
+ const effectiveTlId = taskListId ?? teamId;
528
+ const leadName = teamConfig?.leadName ?? "team-lead";
529
+ const strings = getTeamsStrings(style);
530
+
531
+ await openInteractiveWidget(ctx, {
532
+ getTeammates: () => teammates,
533
+ getTracker: () => tracker,
534
+ getTranscript: (n: string) => transcriptTracker.get(n),
535
+ getTasks: () => tasks,
536
+ getTeamConfig: () => teamConfig,
537
+ getStyle: () => style,
538
+ isDelegateMode: () => delegateMode,
539
+ async sendMessage(name: string, message: string) {
540
+ const rpc = teammates.get(name);
541
+ if (rpc) {
542
+ if (rpc.status === "streaming") await rpc.followUp(message);
543
+ else await rpc.prompt(message);
544
+ return;
879
545
  }
880
546
 
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")),
547
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
548
+ from: leadName,
549
+ text: message,
889
550
  timestamp: new Date().toISOString(),
890
551
  });
552
+ },
553
+ abortComrade(name: string) {
554
+ const rpc = teammates.get(name);
555
+ if (rpc) void rpc.abort();
556
+ },
557
+ killComrade(name: string) {
558
+ const rpc = teammates.get(name);
559
+ if (!rpc) return;
560
+
561
+ void rpc.stop();
562
+ teammates.delete(name);
563
+
564
+ const displayName = formatMemberDisplayName(style, name);
565
+ void unassignTasksForAgent(teamDir, effectiveTlId, name, `${displayName} ${strings.killedVerb}`);
566
+ void setMemberStatus(teamDir, name, "offline", { meta: { killedAt: new Date().toISOString() } });
567
+ void refreshTasks();
568
+ },
569
+ suppressWidget() {
570
+ widgetSuppressed = true;
571
+ ctx.ui.setWidget("pi-teams", undefined);
572
+ },
573
+ restoreWidget() {
574
+ widgetSuppressed = false;
575
+ renderWidget();
576
+ },
577
+ });
578
+ };
891
579
 
892
- assignments.push({ taskId: task.id, assignee, subject });
893
- }
580
+ pi.registerCommand("tw", {
581
+ description: "Teams: open interactive widget panel",
582
+ handler: async (_args, ctx) => {
583
+ currentCtx = ctx;
584
+ currentTeamId = ctx.sessionManager.getSessionId();
585
+ await openWidget(ctx);
586
+ },
587
+ });
894
588
 
895
- // Best-effort widget refresh
896
- void refreshTasks().finally(renderWidget);
589
+ pi.registerCommand("team-widget", {
590
+ description: "Teams: open interactive widget panel (alias for /team widget)",
591
+ handler: async (_args, ctx) => {
592
+ currentCtx = ctx;
593
+ currentTeamId = ctx.sessionManager.getSessionId();
594
+ await openWidget(ctx);
595
+ },
596
+ });
897
597
 
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}`);
598
+ pi.registerCommand("swarm", {
599
+ description: "Start a team of agents to work on a task",
600
+ handler: async (args, _ctx) => {
601
+ const task = args.trim();
602
+ if (!task) {
603
+ 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.");
604
+ return;
907
605
  }
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
- };
606
+ pi.sendUserMessage(`Use your /team commands to spawn a team of agents and coordinate them to complete this task:\n\n${task}`);
921
607
  },
922
608
  });
923
609
 
924
610
  pi.registerCommand("team", {
925
- description: "Teams: spawn teammates + coordinate via Claude-like task list",
611
+ description: "Teams: spawn comrades + coordinate via Claude-like task list",
926
612
  handler: async (args, ctx) => {
927
613
  currentCtx = ctx;
928
614
  currentTeamId = ctx.sessionManager.getSessionId();
@@ -935,6 +621,7 @@ export function runLeader(pi: ExtensionAPI): void {
935
621
  " /team id",
936
622
  " /team env <name>",
937
623
  " /team spawn <name> [fresh|branch] [shared|worktree] [plan]",
624
+ " /team panel",
938
625
  " /team send <name> <msg...>",
939
626
  " /team dm <name> <msg...>",
940
627
  " /team broadcast <msg...>",
@@ -965,920 +652,207 @@ export function runLeader(pi: ExtensionAPI): void {
965
652
 
966
653
  switch (sub) {
967
654
  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();
655
+ await handleTeamListCommand({
656
+ ctx,
657
+ teammates,
658
+ getTeamConfig: () => teamConfig,
659
+ style,
660
+ refreshTasks,
661
+ renderWidget,
662
+ });
995
663
  return;
996
664
  }
997
665
 
998
666
  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
- );
667
+ await handleTeamIdCommand({
668
+ ctx,
669
+ taskListId,
670
+ leadName: teamConfig?.leadName ?? "team-lead",
671
+ style,
672
+ });
1015
673
  return;
1016
674
  }
1017
675
 
1018
676
  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
- );
677
+ await handleTeamEnvCommand({
678
+ ctx,
679
+ rest,
680
+ taskListId,
681
+ leadName: teamConfig?.leadName ?? "team-lead",
682
+ style,
683
+ getTeamsExtensionEntryPath,
684
+ shellQuote,
685
+ });
1071
686
  return;
1072
687
  }
1073
688
 
1074
689
  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();
690
+ await handleTeamCleanupCommand({
691
+ ctx,
692
+ rest,
693
+ teammates,
694
+ refreshTasks,
695
+ getTasks: () => tasks,
696
+ renderWidget,
697
+ style,
698
+ });
1142
699
  return;
1143
700
  }
1144
701
 
1145
702
  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();
703
+ await handleTeamDelegateCommand({
704
+ ctx,
705
+ rest,
706
+ getDelegateMode: () => delegateMode,
707
+ setDelegateMode: (next) => {
708
+ delegateMode = next;
709
+ },
710
+ renderWidget,
711
+ });
1152
712
  return;
1153
713
  }
1154
714
 
1155
715
  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();
716
+ await handleTeamShutdownCommand({
717
+ ctx,
718
+ rest,
719
+ teammates,
720
+ leadName: teamConfig?.leadName ?? "team-lead",
721
+ style,
722
+ getCurrentCtx: () => currentCtx,
723
+ });
1227
724
  return;
1228
725
  }
1229
726
 
1230
727
  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
- );
728
+ await handleTeamSpawnCommand({ ctx, rest, teammates, style, spawnTeammate });
729
+ return;
730
+ }
731
+
732
+ case "style": {
733
+ const teamId = ctx.sessionManager.getSessionId();
734
+ const teamDir = getTeamDir(teamId);
735
+ await handleTeamStyleCommand({
736
+ ctx,
737
+ rest,
738
+ teamDir,
739
+ getStyle: () => style,
740
+ setStyle: (next) => {
741
+ style = next;
742
+ },
743
+ refreshTasks,
744
+ renderWidget,
745
+ });
746
+ return;
747
+ }
748
+
749
+ case "panel":
750
+ case "widget": {
751
+ await openWidget(ctx);
1259
752
  return;
1260
753
  }
1261
754
 
1262
755
  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();
756
+ await handleTeamSendCommand({
757
+ ctx,
758
+ rest,
759
+ teammates,
760
+ style,
761
+ renderWidget,
762
+ });
1279
763
  return;
1280
764
  }
1281
765
 
1282
766
  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();
767
+ await handleTeamSteerCommand({
768
+ ctx,
769
+ rest,
770
+ teammates,
771
+ style,
772
+ renderWidget,
773
+ });
1298
774
  return;
1299
775
  }
1300
776
 
1301
777
  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,
778
+ await handleTeamStopCommand({
779
+ ctx,
780
+ rest,
781
+ teammates,
782
+ leadName: teamConfig?.leadName ?? "team-lead",
783
+ style,
784
+ refreshTasks,
785
+ getTasks: () => tasks,
786
+ renderWidget,
1330
787
  });
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
788
  return;
1344
789
  }
1345
790
 
1346
791
  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();
792
+ await handleTeamKillCommand({
793
+ ctx,
794
+ rest,
795
+ teammates,
796
+ leadName: teamConfig?.leadName ?? "team-lead",
797
+ style,
798
+ taskListId,
799
+ refreshTasks,
800
+ renderWidget,
801
+ });
1371
802
  return;
1372
803
  }
1373
804
 
1374
805
  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(),
806
+ await handleTeamDmCommand({
807
+ ctx,
808
+ rest,
809
+ leadName: teamConfig?.leadName ?? "team-lead",
810
+ style,
1387
811
  });
1388
- ctx.ui.notify(`DM queued for ${name}`, "info");
1389
812
  return;
1390
813
  }
1391
814
 
1392
815
  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");
816
+ await handleTeamBroadcastCommand({
817
+ ctx,
818
+ rest,
819
+ teammates,
820
+ leadName: teamConfig?.leadName ?? "team-lead",
821
+ style,
822
+ refreshTasks,
823
+ getTasks: () => tasks,
824
+ getTaskListId: () => taskListId,
825
+ });
1434
826
  return;
1435
827
  }
1436
828
 
1437
829
  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
- }
830
+ await handleTeamTaskCommand({
831
+ ctx,
832
+ rest,
833
+ leadName: teamConfig?.leadName ?? "team-lead",
834
+ style,
835
+ getTaskListId: () => taskListId,
836
+ setTaskListId: (id) => {
837
+ taskListId = id;
838
+ },
839
+ getTasks: () => tasks,
840
+ refreshTasks,
841
+ renderWidget,
842
+ parseAssigneePrefix,
843
+ taskAssignmentPayload,
844
+ });
845
+ return;
1801
846
  }
1802
847
 
1803
848
  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");
849
+ await handleTeamPlanCommand({
850
+ ctx,
851
+ rest,
852
+ leadName: teamConfig?.leadName ?? "team-lead",
853
+ style,
854
+ pendingPlanApprovals,
855
+ });
1882
856
  return;
1883
857
  }
1884
858