@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.
- package/.github/workflows/ci.yml +32 -0
- package/README.md +36 -6
- package/docs/claude-parity.md +16 -14
- package/docs/field-notes-teams-setup.md +6 -4
- package/docs/smoke-test-plan.md +130 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/fs-lock.ts +21 -5
- package/extensions/teams/leader-inbox.ts +175 -0
- package/extensions/teams/leader-info-commands.ts +139 -0
- package/extensions/teams/leader-lifecycle-commands.ts +330 -0
- package/extensions/teams/leader-messaging-commands.ts +149 -0
- package/extensions/teams/leader-plan-commands.ts +96 -0
- package/extensions/teams/leader-spawn-command.ts +73 -0
- package/extensions/teams/leader-task-commands.ts +417 -0
- package/extensions/teams/leader-teams-tool.ts +238 -0
- package/extensions/teams/leader.ts +396 -1422
- package/extensions/teams/mailbox.ts +54 -29
- package/extensions/teams/names.ts +87 -0
- package/extensions/teams/protocol.ts +221 -0
- package/extensions/teams/task-store.ts +32 -21
- package/extensions/teams/team-config.ts +71 -25
- package/extensions/teams/teammate-rpc.ts +56 -22
- package/extensions/teams/teams-panel.ts +698 -0
- package/extensions/teams/teams-style.ts +62 -0
- package/extensions/teams/teams-widget.ts +235 -0
- package/extensions/teams/worker.ts +100 -138
- package/extensions/teams/worktree.ts +4 -7
- package/package.json +25 -3
- package/scripts/integration-claim-test.mts +227 -0
- package/scripts/integration-todo-test.mts +583 -0
- package/scripts/smoke-test.mjs +1 -1
- package/scripts/smoke-test.mts +424 -0
- package/skills/agent-teams/SKILL.md +136 -0
- package/tsconfig.strict.json +22 -0
- 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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
444
|
-
if (teammates.has(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
|
-
|
|
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
|
-
|
|
486
|
-
"
|
|
487
|
-
|
|
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
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
704
|
-
return { block: true, reason: "Delegate mode is active
|
|
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
|
|
414
|
+
currentCtx = ctx;
|
|
710
415
|
currentTeamId = currentCtx.sessionManager.getSessionId();
|
|
711
|
-
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
466
|
+
currentCtx = ctx;
|
|
743
467
|
currentTeamId = currentCtx.sessionManager.getSessionId();
|
|
744
|
-
|
|
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
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
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
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
|