@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.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/CHANGELOG.md +26 -0
- package/README.md +72 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +18 -13
- package/docs/hook-contract.md +183 -0
- package/docs/smoke-test-plan.md +26 -7
- package/extensions/teams/activity-tracker.ts +296 -8
- package/extensions/teams/cleanup.ts +216 -3
- package/extensions/teams/hooks.ts +57 -5
- package/extensions/teams/leader-attach-commands.ts +8 -4
- package/extensions/teams/leader-inbox.ts +162 -4
- package/extensions/teams/leader-info-commands.ts +105 -3
- package/extensions/teams/leader-lifecycle-commands.ts +205 -3
- package/extensions/teams/leader-messaging-commands.ts +19 -7
- package/extensions/teams/leader-spawn-command.ts +5 -1
- package/extensions/teams/leader-team-command.ts +51 -2
- package/extensions/teams/leader-teams-tool.ts +387 -11
- package/extensions/teams/leader.ts +126 -52
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/model-policy.ts +117 -0
- package/extensions/teams/spawn-types.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +14 -0
- package/extensions/teams/teams-panel.ts +117 -19
- package/extensions/teams/teams-ui-shared.ts +205 -2
- package/extensions/teams/teams-widget.ts +67 -14
- package/extensions/teams/worker.ts +18 -6
- package/extensions/teams/worktree.ts +143 -0
- package/package.json +4 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +10 -0
- package/scripts/smoke-test.mts +701 -3
- package/skills/agent-teams/SKILL.md +28 -7
|
@@ -30,6 +30,7 @@ export async function handleTeamAttachCommand(opts: {
|
|
|
30
30
|
setTaskListId: (id: string) => void;
|
|
31
31
|
refreshTasks: () => Promise<void>;
|
|
32
32
|
renderWidget: () => void;
|
|
33
|
+
restoreWidget: () => void;
|
|
33
34
|
}): Promise<void> {
|
|
34
35
|
const {
|
|
35
36
|
ctx,
|
|
@@ -41,7 +42,7 @@ export async function handleTeamAttachCommand(opts: {
|
|
|
41
42
|
setStyle,
|
|
42
43
|
setTaskListId,
|
|
43
44
|
refreshTasks,
|
|
44
|
-
|
|
45
|
+
restoreWidget,
|
|
45
46
|
} = opts;
|
|
46
47
|
|
|
47
48
|
const tokens = rest.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
@@ -163,7 +164,8 @@ export async function handleTeamAttachCommand(opts: {
|
|
|
163
164
|
setTaskListId(cfg.taskListId);
|
|
164
165
|
setStyle(cfg.style ?? "normal");
|
|
165
166
|
await refreshTasks();
|
|
166
|
-
|
|
167
|
+
// Clear any /team done suppression — attaching to a team is explicit intent to work.
|
|
168
|
+
restoreWidget();
|
|
167
169
|
|
|
168
170
|
const lines: string[] = [
|
|
169
171
|
`Attached to team: ${cfg.teamId}`,
|
|
@@ -185,8 +187,9 @@ export async function handleTeamDetachCommand(opts: {
|
|
|
185
187
|
setTaskListId: (id: string) => void;
|
|
186
188
|
refreshTasks: () => Promise<void>;
|
|
187
189
|
renderWidget: () => void;
|
|
190
|
+
restoreWidget: () => void;
|
|
188
191
|
}): Promise<void> {
|
|
189
|
-
const { ctx, defaultTeamId, teammates, getActiveTeamId, setActiveTeamId, setTaskListId, refreshTasks,
|
|
192
|
+
const { ctx, defaultTeamId, teammates, getActiveTeamId, setActiveTeamId, setTaskListId, refreshTasks, restoreWidget } = opts;
|
|
190
193
|
|
|
191
194
|
const activeTeamId = getActiveTeamId();
|
|
192
195
|
if (activeTeamId === defaultTeamId) {
|
|
@@ -207,7 +210,8 @@ export async function handleTeamDetachCommand(opts: {
|
|
|
207
210
|
setActiveTeamId(defaultTeamId);
|
|
208
211
|
setTaskListId(defaultTeamId);
|
|
209
212
|
await refreshTasks();
|
|
210
|
-
|
|
213
|
+
// Clear any /team done suppression — returning to own team.
|
|
214
|
+
restoreWidget();
|
|
211
215
|
|
|
212
216
|
if (releaseResult === "not_owner") {
|
|
213
217
|
ctx.ui.notify(
|
|
@@ -10,12 +10,78 @@ import {
|
|
|
10
10
|
isShutdownRejected,
|
|
11
11
|
} from "./protocol.js";
|
|
12
12
|
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
13
|
-
import { getTask } from "./task-store.js";
|
|
13
|
+
import { getTask, listTasks } from "./task-store.js";
|
|
14
14
|
|
|
15
15
|
import type { TeamsHookInvocation } from "./hooks.js";
|
|
16
16
|
import type { TeamsStyle } from "./teams-style.js";
|
|
17
17
|
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
18
18
|
|
|
19
|
+
/** Callback to inject a message into the leader LLM conversation. */
|
|
20
|
+
export type SendLeaderLlmMessage = (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Event-driven tracker for delegation batches.
|
|
24
|
+
*
|
|
25
|
+
* Tracks task IDs from delegate() calls. Tasks are only marked done
|
|
26
|
+
* when an idle_notification with completedTaskId is received — NOT
|
|
27
|
+
* by polling task file status. This avoids race conditions where
|
|
28
|
+
* listTasks() returns stale or premature data.
|
|
29
|
+
*/
|
|
30
|
+
export class DelegationTracker {
|
|
31
|
+
private batches: Array<{
|
|
32
|
+
taskIds: Set<string>;
|
|
33
|
+
completedIds: Set<string>;
|
|
34
|
+
notified: boolean;
|
|
35
|
+
}> = [];
|
|
36
|
+
|
|
37
|
+
/** Register a new batch of delegated task IDs. */
|
|
38
|
+
addBatch(taskIds: string[]): void {
|
|
39
|
+
if (taskIds.length === 0) return;
|
|
40
|
+
this.batches.push({
|
|
41
|
+
taskIds: new Set(taskIds),
|
|
42
|
+
completedIds: new Set(),
|
|
43
|
+
notified: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mark a task as completed (called when idle_notification with
|
|
49
|
+
* completedTaskId is received). Returns any batches that became
|
|
50
|
+
* fully complete as a result.
|
|
51
|
+
*/
|
|
52
|
+
markCompleted(taskId: string): Array<{ taskIds: string[] }> {
|
|
53
|
+
const newlyComplete: Array<{ taskIds: string[] }> = [];
|
|
54
|
+
|
|
55
|
+
for (const batch of this.batches) {
|
|
56
|
+
if (batch.notified) continue;
|
|
57
|
+
if (!batch.taskIds.has(taskId)) continue;
|
|
58
|
+
|
|
59
|
+
batch.completedIds.add(taskId);
|
|
60
|
+
|
|
61
|
+
const allDone = [...batch.taskIds].every((id) => batch.completedIds.has(id));
|
|
62
|
+
if (allDone) {
|
|
63
|
+
batch.notified = true;
|
|
64
|
+
newlyComplete.push({ taskIds: [...batch.taskIds] });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Prune notified batches
|
|
69
|
+
this.batches = this.batches.filter((b) => !b.notified);
|
|
70
|
+
return newlyComplete;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear all tracked batches (e.g. on session switch). */
|
|
74
|
+
clear(): void {
|
|
75
|
+
this.batches = [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Truncate a result string to stay within token budget. */
|
|
80
|
+
function truncateResult(text: string, maxLen: number): string {
|
|
81
|
+
if (text.length <= maxLen) return text;
|
|
82
|
+
return text.slice(0, maxLen) + "…";
|
|
83
|
+
}
|
|
84
|
+
|
|
19
85
|
export async function pollLeaderInbox(opts: {
|
|
20
86
|
ctx: ExtensionContext;
|
|
21
87
|
teamId: string;
|
|
@@ -25,8 +91,11 @@ export async function pollLeaderInbox(opts: {
|
|
|
25
91
|
style: TeamsStyle;
|
|
26
92
|
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
27
93
|
enqueueHook?: (invocation: TeamsHookInvocation) => void;
|
|
94
|
+
sendLeaderLlmMessage?: SendLeaderLlmMessage;
|
|
95
|
+
/** Batch delegation tracker for all-tasks-complete auto-notify. */
|
|
96
|
+
delegationTracker?: DelegationTracker;
|
|
28
97
|
}): Promise<void> {
|
|
29
|
-
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook } = opts;
|
|
98
|
+
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, sendLeaderLlmMessage, delegationTracker } = opts;
|
|
30
99
|
const strings = getTeamsStrings(style);
|
|
31
100
|
|
|
32
101
|
let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
|
|
@@ -38,6 +107,10 @@ export async function pollLeaderInbox(opts: {
|
|
|
38
107
|
}
|
|
39
108
|
if (!msgs.length) return;
|
|
40
109
|
|
|
110
|
+
// Collect batch completions across all messages in this poll cycle,
|
|
111
|
+
// then fire notifications once at the end (avoids duplicate triggers).
|
|
112
|
+
const batchCompletions: Array<{ taskIds: string[] }> = [];
|
|
113
|
+
|
|
41
114
|
for (const m of msgs) {
|
|
42
115
|
const approved = isShutdownApproved(m.text);
|
|
43
116
|
if (approved) {
|
|
@@ -132,6 +205,13 @@ export async function pollLeaderInbox(opts: {
|
|
|
132
205
|
} catch {
|
|
133
206
|
// ignore hook enqueue errors
|
|
134
207
|
}
|
|
208
|
+
|
|
209
|
+
// Event-driven batch tracking: mark this task done and
|
|
210
|
+
// collect any batches that became fully complete.
|
|
211
|
+
if (delegationTracker && idle.completedStatus !== "failed") {
|
|
212
|
+
const completed = delegationTracker.markCompleted(idle.completedTaskId);
|
|
213
|
+
batchCompletions.push(...completed);
|
|
214
|
+
}
|
|
135
215
|
}
|
|
136
216
|
|
|
137
217
|
if (idle.failureReason) {
|
|
@@ -200,8 +280,59 @@ export async function pollLeaderInbox(opts: {
|
|
|
200
280
|
|
|
201
281
|
if (idle.completedTaskId && idle.completedStatus === "failed") {
|
|
202
282
|
ctx.ui.notify(`${name} aborted task #${idle.completedTaskId}`, "warning");
|
|
283
|
+
|
|
284
|
+
// Inject failure notification into leader LLM conversation
|
|
285
|
+
if (sendLeaderLlmMessage) {
|
|
286
|
+
const task = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
287
|
+
const subject = task?.subject ? `: ${task.subject}` : "";
|
|
288
|
+
// Failed tasks store abort details, not the success-only `result` field.
|
|
289
|
+
const abortReasonRaw = task?.metadata?.["abortReason"];
|
|
290
|
+
const partialResultRaw = task?.metadata?.["partialResult"];
|
|
291
|
+
const abortReason = typeof abortReasonRaw === "string" ? truncateResult(abortReasonRaw, 300) : undefined;
|
|
292
|
+
const partialResult = typeof partialResultRaw === "string" ? truncateResult(partialResultRaw, 300) : undefined;
|
|
293
|
+
const lines = [
|
|
294
|
+
`[Team] ${formatMemberDisplayName(style, name)} failed task #${idle.completedTaskId}${subject}`,
|
|
295
|
+
];
|
|
296
|
+
if (abortReason) lines.push(`Reason: ${abortReason}`);
|
|
297
|
+
if (partialResult) lines.push(`Partial result: ${partialResult}`);
|
|
298
|
+
sendLeaderLlmMessage(lines.join("\n"), { deliverAs: "followUp" });
|
|
299
|
+
}
|
|
203
300
|
} else if (idle.completedTaskId) {
|
|
204
|
-
ctx.ui.notify(`${name}
|
|
301
|
+
ctx.ui.notify(`${name} completed task #${idle.completedTaskId}`, "info");
|
|
302
|
+
|
|
303
|
+
// Inject completion notification into leader LLM conversation
|
|
304
|
+
if (sendLeaderLlmMessage) {
|
|
305
|
+
const task = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
306
|
+
const subject = task?.subject ? `: ${task.subject}` : "";
|
|
307
|
+
const resultRaw = task?.metadata?.["result"];
|
|
308
|
+
const result = typeof resultRaw === "string" ? truncateResult(resultRaw, 500) : undefined;
|
|
309
|
+
const lines = [
|
|
310
|
+
`[Team] ${formatMemberDisplayName(style, name)} completed task #${idle.completedTaskId}${subject}`,
|
|
311
|
+
];
|
|
312
|
+
if (result) lines.push(`Result: ${result}`);
|
|
313
|
+
|
|
314
|
+
// Check if all tasks are now completed
|
|
315
|
+
const allTasks = await listTasks(teamDir, taskListId);
|
|
316
|
+
const totalTasks = allTasks.length;
|
|
317
|
+
const completedTasks = allTasks.filter((t) => t.status === "completed");
|
|
318
|
+
const allDone = totalTasks > 0 && completedTasks.length === totalTasks;
|
|
319
|
+
|
|
320
|
+
if (allDone) {
|
|
321
|
+
lines.push("");
|
|
322
|
+
if (enqueueHook) {
|
|
323
|
+
// Hooks run asynchronously and may reopen tasks or create follow-ups.
|
|
324
|
+
lines.push(`All ${totalTasks} task(s) show completed — quality gates are still running and may change task states.`);
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(`All ${totalTasks} task(s) are now completed. Review results and determine next steps.`);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
const pending = allTasks.filter((t) => t.status === "pending").length;
|
|
330
|
+
const inProgress = allTasks.filter((t) => t.status === "in_progress").length;
|
|
331
|
+
lines.push(`Progress: ${completedTasks.length}/${totalTasks} done (${pending} pending, ${inProgress} in progress)`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
sendLeaderLlmMessage(lines.join("\n"), { deliverAs: "followUp" });
|
|
335
|
+
}
|
|
205
336
|
} else {
|
|
206
337
|
ctx.ui.notify(`${name} is idle`, "info");
|
|
207
338
|
}
|
|
@@ -209,6 +340,33 @@ export async function pollLeaderInbox(opts: {
|
|
|
209
340
|
continue;
|
|
210
341
|
}
|
|
211
342
|
|
|
212
|
-
|
|
343
|
+
// Unrecognized message = teammate DM → route to leader LLM context
|
|
344
|
+
if (sendLeaderLlmMessage) {
|
|
345
|
+
sendLeaderLlmMessage(`[Team DM] ${m.from}: ${m.text}`, { deliverAs: "followUp" });
|
|
346
|
+
} else {
|
|
347
|
+
ctx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Fire batch-complete notifications (deduplicated across this poll cycle).
|
|
352
|
+
// Uses sendLeaderLlmMessage directly (without deliverAs) when idle so it
|
|
353
|
+
// triggers a new LLM turn, waking the leader to review and continue.
|
|
354
|
+
if (sendLeaderLlmMessage) {
|
|
355
|
+
for (const batch of batchCompletions) {
|
|
356
|
+
const taskRefs = batch.taskIds.map((id) => `#${id}`).join(", ");
|
|
357
|
+
const suffix = enqueueHook
|
|
358
|
+
? "Quality gates are still running and may change task states."
|
|
359
|
+
: "Review the results and continue.";
|
|
360
|
+
const msg = `[Team] All delegated tasks completed (${taskRefs}). ${suffix}`;
|
|
361
|
+
try {
|
|
362
|
+
if (ctx.isIdle()) {
|
|
363
|
+
sendLeaderLlmMessage(msg);
|
|
364
|
+
} else {
|
|
365
|
+
sendLeaderLlmMessage(msg, { deliverAs: "followUp" });
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
ctx.ui.notify(`✅ ${msg}`, "info");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
213
371
|
}
|
|
214
372
|
}
|
|
@@ -5,16 +5,20 @@ import type { TeammateRpc } from "./teammate-rpc.js";
|
|
|
5
5
|
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
6
6
|
import type { TeamsStyle } from "./teams-style.js";
|
|
7
7
|
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
8
|
+
import { resolveDisplayStatus, formatElapsed, formatTokens, lastMessageSummary, toolActivity } from "./teams-ui-shared.js";
|
|
9
|
+
import type { ActivityTracker } from "./activity-tracker.js";
|
|
10
|
+
import { listTasks } from "./task-store.js";
|
|
8
11
|
|
|
9
12
|
export async function handleTeamListCommand(opts: {
|
|
10
13
|
ctx: ExtensionCommandContext;
|
|
11
14
|
teammates: Map<string, TeammateRpc>;
|
|
12
15
|
getTeamConfig: () => TeamConfig | null;
|
|
16
|
+
getTracker: () => ActivityTracker;
|
|
13
17
|
style: TeamsStyle;
|
|
14
18
|
refreshTasks: () => Promise<void>;
|
|
15
19
|
renderWidget: () => void;
|
|
16
20
|
}): Promise<void> {
|
|
17
|
-
const { ctx, teammates, getTeamConfig, style, refreshTasks, renderWidget } = opts;
|
|
21
|
+
const { ctx, teammates, getTeamConfig, getTracker, style, refreshTasks, renderWidget } = opts;
|
|
18
22
|
const strings = getTeamsStrings(style);
|
|
19
23
|
|
|
20
24
|
await refreshTasks();
|
|
@@ -34,13 +38,19 @@ export async function handleTeamListCommand(opts: {
|
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
const tracker = getTracker();
|
|
37
42
|
const lines: string[] = [];
|
|
38
43
|
for (const name of Array.from(names).sort()) {
|
|
39
44
|
const rpc = teammates.get(name);
|
|
40
45
|
const cfg = cfgByName.get(name);
|
|
41
|
-
const
|
|
46
|
+
const displayStatus = resolveDisplayStatus(rpc, cfg);
|
|
42
47
|
const kind = rpc ? "rpc" : cfg ? "manual" : "unknown";
|
|
43
|
-
|
|
48
|
+
const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
|
|
49
|
+
const activity = tracker.get(name);
|
|
50
|
+
const tool = toolActivity(activity.currentToolName);
|
|
51
|
+
const elapsedTag = elapsed ? ` ${elapsed}` : "";
|
|
52
|
+
const toolTag = tool ? ` (${tool})` : "";
|
|
53
|
+
lines.push(`${formatMemberDisplayName(style, name)}: ${displayStatus}${elapsedTag}${toolTag} [${kind}]`);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
@@ -138,3 +148,95 @@ export async function handleTeamEnvCommand(opts: {
|
|
|
138
148
|
"info",
|
|
139
149
|
);
|
|
140
150
|
}
|
|
151
|
+
|
|
152
|
+
export async function handleTeamStatusCommand(opts: {
|
|
153
|
+
ctx: ExtensionCommandContext;
|
|
154
|
+
rest: string[];
|
|
155
|
+
teammates: Map<string, TeammateRpc>;
|
|
156
|
+
getTeamConfig: () => TeamConfig | null;
|
|
157
|
+
getTracker: () => ActivityTracker;
|
|
158
|
+
teamId: string;
|
|
159
|
+
taskListId: string | null;
|
|
160
|
+
style: TeamsStyle;
|
|
161
|
+
}): Promise<void> {
|
|
162
|
+
const { ctx, rest, teammates, getTeamConfig, getTracker, teamId, taskListId, style } = opts;
|
|
163
|
+
const strings = getTeamsStrings(style);
|
|
164
|
+
const tracker = getTracker();
|
|
165
|
+
const teamConfig = getTeamConfig();
|
|
166
|
+
const teamDir = getTeamDir(teamId);
|
|
167
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
168
|
+
|
|
169
|
+
const nameRaw = rest[0];
|
|
170
|
+
|
|
171
|
+
// If no name, show summary of all workers (same as member_status with no name).
|
|
172
|
+
if (!nameRaw) {
|
|
173
|
+
const cfgMembers = teamConfig?.members ?? [];
|
|
174
|
+
const cfgByName = new Map<string, TeamMember>();
|
|
175
|
+
for (const m of cfgMembers) cfgByName.set(m.name, m);
|
|
176
|
+
|
|
177
|
+
const workerNames = new Set<string>();
|
|
178
|
+
for (const n of teammates.keys()) workerNames.add(n);
|
|
179
|
+
for (const m of cfgMembers) {
|
|
180
|
+
if (m.role === "worker" && m.status === "online") workerNames.add(m.name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (workerNames.size === 0) {
|
|
184
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s`, "info");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const lines: string[] = [];
|
|
189
|
+
for (const n of Array.from(workerNames).sort()) {
|
|
190
|
+
const rpc = teammates.get(n);
|
|
191
|
+
const cfg = cfgByName.get(n);
|
|
192
|
+
const displayStatus = resolveDisplayStatus(rpc, cfg);
|
|
193
|
+
const activity = tracker.get(n);
|
|
194
|
+
const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
|
|
195
|
+
const tool = toolActivity(activity.currentToolName);
|
|
196
|
+
const toolTag = tool ? ` (${tool})` : "";
|
|
197
|
+
const stalledTag = displayStatus === "stalled" ? " ⚠ STALLED" : "";
|
|
198
|
+
lines.push(`${formatMemberDisplayName(style, n)}: ${displayStatus} ${elapsed}${toolTag} · ${formatTokens(activity.totalTokens)} tokens${stalledTag}`);
|
|
199
|
+
}
|
|
200
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Single worker detail view.
|
|
205
|
+
const name = sanitizeName(nameRaw);
|
|
206
|
+
const rpc = teammates.get(name);
|
|
207
|
+
const memberCfg = (teamConfig?.members ?? []).find((m) => m.name === name);
|
|
208
|
+
if (!rpc && !memberCfg) {
|
|
209
|
+
ctx.ui.notify(`Unknown ${strings.memberTitle.toLowerCase()}: ${name}`, "error");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const displayStatus = resolveDisplayStatus(rpc, memberCfg);
|
|
214
|
+
const activity = tracker.get(name);
|
|
215
|
+
const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
|
|
216
|
+
const noEventFor = rpc ? formatElapsed(Date.now() - rpc.lastEventAt) : "";
|
|
217
|
+
const currentTool = toolActivity(activity.currentToolName);
|
|
218
|
+
const msgPreview = lastMessageSummary(rpc, 120);
|
|
219
|
+
const allTasks = await listTasks(teamDir, effectiveTlId);
|
|
220
|
+
const owned = allTasks.filter((t) => t.owner === name);
|
|
221
|
+
const activeTask = owned.find((t) => t.status === "in_progress");
|
|
222
|
+
const model = memberCfg?.meta?.["model"];
|
|
223
|
+
const cwd = memberCfg?.cwd;
|
|
224
|
+
|
|
225
|
+
const lines: string[] = [
|
|
226
|
+
`${formatMemberDisplayName(style, name)}: ${displayStatus}`,
|
|
227
|
+
`time in state: ${elapsed || "(unknown)"}`,
|
|
228
|
+
`last event: ${noEventFor || "(unknown)"} ago`,
|
|
229
|
+
`current activity: ${currentTool || "(none)"}`,
|
|
230
|
+
`tool calls: ${activity.toolUseCount} · turns: ${activity.turnCount} · tokens: ${formatTokens(activity.totalTokens)}`,
|
|
231
|
+
];
|
|
232
|
+
if (typeof model === "string" && model) lines.push(`model: ${model}`);
|
|
233
|
+
if (cwd) lines.push(`cwd: ${cwd}`);
|
|
234
|
+
if (activeTask) lines.push(`active task: #${activeTask.id} ${activeTask.subject}`);
|
|
235
|
+
lines.push(`tasks: ${owned.filter((t) => t.status === "pending").length} pending · ${owned.filter((t) => t.status === "in_progress").length} in-progress · ${owned.filter((t) => t.status === "completed").length} completed`);
|
|
236
|
+
if (msgPreview) lines.push(`last message: ${msgPreview}`);
|
|
237
|
+
if (displayStatus === "stalled") {
|
|
238
|
+
lines.push(`⚠ WARNING: no agent events for ${noEventFor} — worker may be stalled`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
242
|
+
}
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
|
-
import { cleanupTeamDir } from "./cleanup.js";
|
|
5
|
+
import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js";
|
|
6
6
|
import { writeToMailbox } from "./mailbox.js";
|
|
7
7
|
import { sanitizeName } from "./names.js";
|
|
8
8
|
import { getTeamDir, getTeamsRootDir, getTeamsStylesDir } from "./paths.js";
|
|
@@ -220,13 +220,23 @@ export async function handleTeamCleanupCommand(opts: {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
try {
|
|
223
|
-
await cleanupTeamDir(teamsRoot, teamDir);
|
|
223
|
+
const result = await cleanupTeamDir(teamsRoot, teamDir, { teamId, repoCwd: ctx.cwd });
|
|
224
|
+
const parts: string[] = [`Cleaned up team directory: ${teamDir}`];
|
|
225
|
+
if (result.worktreeResult.removedWorktrees.length > 0) {
|
|
226
|
+
parts.push(`Removed ${result.worktreeResult.removedWorktrees.length} worktree(s)`);
|
|
227
|
+
}
|
|
228
|
+
if (result.worktreeResult.removedBranches.length > 0) {
|
|
229
|
+
parts.push(`Removed ${result.worktreeResult.removedBranches.length} branch(es)`);
|
|
230
|
+
}
|
|
231
|
+
for (const w of result.warnings) {
|
|
232
|
+
parts.push(`⚠ ${w}`);
|
|
233
|
+
}
|
|
234
|
+
ctx.ui.notify(parts.join("\n"), "warning");
|
|
224
235
|
} catch (err) {
|
|
225
236
|
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
226
237
|
return;
|
|
227
238
|
}
|
|
228
239
|
|
|
229
|
-
ctx.ui.notify(`Cleaned up team directory: ${teamDir}`, "warning");
|
|
230
240
|
await refreshTasks();
|
|
231
241
|
renderWidget();
|
|
232
242
|
}
|
|
@@ -519,6 +529,116 @@ export async function handleTeamStopCommand(opts: {
|
|
|
519
529
|
renderWidget();
|
|
520
530
|
}
|
|
521
531
|
|
|
532
|
+
/**
|
|
533
|
+
* `/team done` — end-of-run cleanup.
|
|
534
|
+
*
|
|
535
|
+
* Stops all teammates, hides the widget, and optionally cleans up team artifacts.
|
|
536
|
+
* This is the "team is finished" ergonomic counterpart to `/team cleanup`.
|
|
537
|
+
*
|
|
538
|
+
* Behavior:
|
|
539
|
+
* 1. Stops all RPC teammates (graceful, no confirmation prompt).
|
|
540
|
+
* 2. Marks all config-only workers offline.
|
|
541
|
+
* 3. Hides the Teams widget.
|
|
542
|
+
* 4. Notifies the user with a summary.
|
|
543
|
+
*
|
|
544
|
+
* Use `/team cleanup [--force]` afterward if you also want to delete task/mailbox files.
|
|
545
|
+
*/
|
|
546
|
+
export async function handleTeamDoneCommand(opts: {
|
|
547
|
+
ctx: ExtensionCommandContext;
|
|
548
|
+
rest: string[];
|
|
549
|
+
teamId: string;
|
|
550
|
+
teammates: Map<string, TeammateRpc>;
|
|
551
|
+
getTeamConfig: () => TeamConfig | null;
|
|
552
|
+
leadName: string;
|
|
553
|
+
style: TeamsStyle;
|
|
554
|
+
stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
|
|
555
|
+
refreshTasks: () => Promise<void>;
|
|
556
|
+
getTasks: () => TeamTask[];
|
|
557
|
+
hideWidget: () => void;
|
|
558
|
+
}): Promise<void> {
|
|
559
|
+
const { ctx, rest, teamId, teammates, getTeamConfig, leadName, style, stopAllTeammates, refreshTasks, getTasks, hideWidget } = opts;
|
|
560
|
+
const strings = getTeamsStrings(style);
|
|
561
|
+
|
|
562
|
+
const flags = rest.filter((a) => a.startsWith("--"));
|
|
563
|
+
const unknownFlags = flags.filter((f) => f !== "--force");
|
|
564
|
+
if (unknownFlags.length) {
|
|
565
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
await refreshTasks();
|
|
570
|
+
const tasks = getTasks();
|
|
571
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
572
|
+
|
|
573
|
+
if (inProgress.length > 0 && !flags.includes("--force")) {
|
|
574
|
+
ctx.ui.notify(
|
|
575
|
+
`${inProgress.length} task(s) still in progress. Use /team done --force to end anyway.`,
|
|
576
|
+
"error",
|
|
577
|
+
);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Stop all RPC teammates
|
|
582
|
+
const reason = formatTeamsTemplate(strings.teamEndedAllStopped, {
|
|
583
|
+
members: `${strings.memberTitle.toLowerCase()}s`,
|
|
584
|
+
count: String(teammates.size),
|
|
585
|
+
});
|
|
586
|
+
await stopAllTeammates(ctx, reason);
|
|
587
|
+
|
|
588
|
+
// Mark manual/config workers offline
|
|
589
|
+
const cfg = getTeamConfig();
|
|
590
|
+
const teamDir = getTeamDir(teamId);
|
|
591
|
+
const manualWorkers = (cfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
592
|
+
for (const m of manualWorkers) {
|
|
593
|
+
if (teammates.has(m.name)) continue;
|
|
594
|
+
const ts = new Date().toISOString();
|
|
595
|
+
try {
|
|
596
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
|
|
597
|
+
from: leadName,
|
|
598
|
+
text: JSON.stringify({
|
|
599
|
+
type: "shutdown_request",
|
|
600
|
+
requestId: randomUUID(),
|
|
601
|
+
from: leadName,
|
|
602
|
+
timestamp: ts,
|
|
603
|
+
reason: "Team done",
|
|
604
|
+
}),
|
|
605
|
+
timestamp: ts,
|
|
606
|
+
});
|
|
607
|
+
} catch {
|
|
608
|
+
// ignore
|
|
609
|
+
}
|
|
610
|
+
await setMemberStatus(teamDir, m.name, "offline", {
|
|
611
|
+
meta: { stoppedReason: "team-done", stoppedAt: ts },
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Unassign any in-progress tasks (force mode)
|
|
616
|
+
if (inProgress.length > 0) {
|
|
617
|
+
for (const task of inProgress) {
|
|
618
|
+
if (task.owner) {
|
|
619
|
+
await unassignTasksForAgent(teamDir, cfg?.taskListId ?? teamId, task.owner, "team done");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
await refreshTasks();
|
|
625
|
+
|
|
626
|
+
// Hide the widget
|
|
627
|
+
hideWidget();
|
|
628
|
+
|
|
629
|
+
// Summary
|
|
630
|
+
const completed = tasks.filter((t) => t.status === "completed").length;
|
|
631
|
+
const pending = tasks.filter((t) => t.status === "pending").length;
|
|
632
|
+
const total = tasks.length;
|
|
633
|
+
|
|
634
|
+
const summaryParts = [`Team done. ${total} task(s): ${completed} completed`];
|
|
635
|
+
if (pending > 0) summaryParts.push(`${pending} pending`);
|
|
636
|
+
if (inProgress.length > 0) summaryParts.push(`${inProgress.length} were in-progress (unassigned)`);
|
|
637
|
+
summaryParts.push("Widget hidden. Use /team cleanup to remove artifacts.");
|
|
638
|
+
|
|
639
|
+
ctx.ui.notify(summaryParts.join(", ") + ".", "info");
|
|
640
|
+
}
|
|
641
|
+
|
|
522
642
|
export async function handleTeamKillCommand(opts: {
|
|
523
643
|
ctx: ExtensionCommandContext;
|
|
524
644
|
rest: string[];
|
|
@@ -557,3 +677,85 @@ export async function handleTeamKillCommand(opts: {
|
|
|
557
677
|
await refreshTasks();
|
|
558
678
|
renderWidget();
|
|
559
679
|
}
|
|
680
|
+
|
|
681
|
+
const DEFAULT_GC_MAX_AGE_HOURS = 24;
|
|
682
|
+
|
|
683
|
+
export async function handleTeamGcCommand(opts: {
|
|
684
|
+
ctx: ExtensionCommandContext;
|
|
685
|
+
rest: string[];
|
|
686
|
+
}): Promise<void> {
|
|
687
|
+
const { ctx, rest } = opts;
|
|
688
|
+
|
|
689
|
+
const flags = rest.filter((a) => a.startsWith("--"));
|
|
690
|
+
const argsOnly = rest.filter((a) => !a.startsWith("--"));
|
|
691
|
+
const dryRun = flags.includes("--dry-run");
|
|
692
|
+
const force = flags.includes("--force");
|
|
693
|
+
|
|
694
|
+
const unknownFlags = flags.filter((f) => f !== "--dry-run" && f !== "--force" && !f.startsWith("--max-age-hours="));
|
|
695
|
+
if (unknownFlags.length) {
|
|
696
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (argsOnly.length) {
|
|
700
|
+
ctx.ui.notify("Usage: /team gc [--dry-run] [--force] [--max-age-hours=N]", "error");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Parse --max-age-hours=N
|
|
705
|
+
let maxAgeHours = DEFAULT_GC_MAX_AGE_HOURS;
|
|
706
|
+
const maxAgeFlag = flags.find((f) => f.startsWith("--max-age-hours="));
|
|
707
|
+
if (maxAgeFlag) {
|
|
708
|
+
const val = Number(maxAgeFlag.split("=")[1]);
|
|
709
|
+
if (!Number.isFinite(val) || val < 0) {
|
|
710
|
+
ctx.ui.notify("--max-age-hours must be a non-negative number", "error");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
maxAgeHours = val;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const teamsRoot = getTeamsRootDir();
|
|
717
|
+
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
718
|
+
|
|
719
|
+
if (!force && !dryRun) {
|
|
720
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
721
|
+
const ok = await ctx.ui.confirm(
|
|
722
|
+
"Garbage collect teams",
|
|
723
|
+
`Remove all stale team directories older than ${maxAgeHours}h?\nTeams root: ${teamsRoot}`,
|
|
724
|
+
);
|
|
725
|
+
if (!ok) return;
|
|
726
|
+
} else {
|
|
727
|
+
ctx.ui.notify("Use --force or --dry-run in non-interactive mode", "error");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const result = await gcStaleTeamDirs({
|
|
733
|
+
teamsRootDir: teamsRoot,
|
|
734
|
+
maxAgeMs,
|
|
735
|
+
repoCwd: ctx.cwd,
|
|
736
|
+
dryRun,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const lines: string[] = [];
|
|
740
|
+
if (dryRun) {
|
|
741
|
+
lines.push(`[DRY RUN] Would remove ${result.removed.length} of ${result.scanned} team dirs`);
|
|
742
|
+
} else {
|
|
743
|
+
lines.push(`Removed ${result.removed.length} of ${result.scanned} team dirs`);
|
|
744
|
+
}
|
|
745
|
+
if (result.skipped.length > 0) {
|
|
746
|
+
const byReason = new Map<string, number>();
|
|
747
|
+
for (const s of result.skipped) {
|
|
748
|
+
byReason.set(s.reason, (byReason.get(s.reason) ?? 0) + 1);
|
|
749
|
+
}
|
|
750
|
+
const parts: string[] = [];
|
|
751
|
+
for (const [reason, count] of byReason) {
|
|
752
|
+
parts.push(`${count} ${reason}`);
|
|
753
|
+
}
|
|
754
|
+
lines.push(`Skipped: ${parts.join(", ")}`);
|
|
755
|
+
}
|
|
756
|
+
for (const w of result.warnings) {
|
|
757
|
+
lines.push(`⚠ ${w}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
ctx.ui.notify(lines.join("\n"), dryRun ? "info" : "warning");
|
|
761
|
+
}
|