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