indus-swarms 0.1.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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ExtensionCommandContext, ExtensionContext } from "indusagi-coding-agent";
|
|
5
|
+
import { cleanupTeamDir } from "./cleanup.js";
|
|
6
|
+
import { writeToMailbox } from "./mailbox.js";
|
|
7
|
+
import { sanitizeName } from "./names.js";
|
|
8
|
+
import { getTeamDir, getTeamsRootDir, getTeamsStylesDir } from "./paths.js";
|
|
9
|
+
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
10
|
+
import { unassignTasksForAgent, type TeamTask } from "./task-store.js";
|
|
11
|
+
import { setMemberStatus, setTeamStyle, type TeamConfig } from "./team-config.js";
|
|
12
|
+
import {
|
|
13
|
+
type TeamsStyle,
|
|
14
|
+
formatMemberDisplayName,
|
|
15
|
+
getTeamsStrings,
|
|
16
|
+
listAvailableTeamsStyles,
|
|
17
|
+
normalizeTeamsStyleId,
|
|
18
|
+
resolveTeamsStyleDefinition,
|
|
19
|
+
formatTeamsTemplate,
|
|
20
|
+
} from "./teams-style.js";
|
|
21
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
22
|
+
|
|
23
|
+
export async function handleTeamDelegateCommand(opts: {
|
|
24
|
+
ctx: ExtensionCommandContext;
|
|
25
|
+
rest: string[];
|
|
26
|
+
getDelegateMode: () => boolean;
|
|
27
|
+
setDelegateMode: (next: boolean) => void;
|
|
28
|
+
renderWidget: () => void;
|
|
29
|
+
}): Promise<void> {
|
|
30
|
+
const { ctx, rest, getDelegateMode, setDelegateMode, renderWidget } = opts;
|
|
31
|
+
const arg = rest[0];
|
|
32
|
+
if (arg === "on") setDelegateMode(true);
|
|
33
|
+
else if (arg === "off") setDelegateMode(false);
|
|
34
|
+
else setDelegateMode(!getDelegateMode());
|
|
35
|
+
ctx.ui.notify(`Delegate mode ${getDelegateMode() ? "ON" : "OFF"}`, "info");
|
|
36
|
+
renderWidget();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleTeamStyleCommand(opts: {
|
|
40
|
+
ctx: ExtensionCommandContext;
|
|
41
|
+
rest: string[];
|
|
42
|
+
teamDir: string;
|
|
43
|
+
getStyle: () => TeamsStyle;
|
|
44
|
+
setStyle: (next: TeamsStyle) => void;
|
|
45
|
+
refreshTasks: () => Promise<void>;
|
|
46
|
+
renderWidget: () => void;
|
|
47
|
+
}): Promise<void> {
|
|
48
|
+
const { ctx, rest, teamDir, getStyle, setStyle, refreshTasks, renderWidget } = opts;
|
|
49
|
+
const argRaw = rest[0];
|
|
50
|
+
if (!argRaw) {
|
|
51
|
+
ctx.ui.notify(
|
|
52
|
+
"Teams style:\n" +
|
|
53
|
+
` current: ${getStyle()}\n` +
|
|
54
|
+
" list: /team style list\n" +
|
|
55
|
+
" set: /team style <name>\n" +
|
|
56
|
+
" init: /team style init <name> [extends <base>]",
|
|
57
|
+
"info",
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (argRaw === "list") {
|
|
63
|
+
const { dir, all, builtins, customs } = listAvailableTeamsStyles();
|
|
64
|
+
ctx.ui.notify(
|
|
65
|
+
[
|
|
66
|
+
"Available team styles:",
|
|
67
|
+
"",
|
|
68
|
+
`built-in: ${builtins.join(", ")}`,
|
|
69
|
+
customs.length ? `custom: ${customs.join(", ")}` : "custom: (none)",
|
|
70
|
+
"",
|
|
71
|
+
"To add a custom style, create a JSON file:",
|
|
72
|
+
` ${dir}/<style>.json`,
|
|
73
|
+
"",
|
|
74
|
+
`All: ${all.join(", ")}`,
|
|
75
|
+
].join("\n"),
|
|
76
|
+
"info",
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (argRaw === "init") {
|
|
82
|
+
const nameRaw = rest[1];
|
|
83
|
+
const styleId = normalizeTeamsStyleId(nameRaw);
|
|
84
|
+
if (!styleId) {
|
|
85
|
+
ctx.ui.notify("Usage: /team style init <name> [extends <base>]", "error");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let extendsRaw: string | undefined;
|
|
90
|
+
if (rest[2] === "extends") extendsRaw = rest[3];
|
|
91
|
+
else extendsRaw = rest[2];
|
|
92
|
+
const extendsId = normalizeTeamsStyleId(extendsRaw) ?? "normal";
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
resolveTeamsStyleDefinition(extendsId, { strict: true });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dir = getTeamsStylesDir();
|
|
102
|
+
const file = path.join(dir, `${styleId}.json`);
|
|
103
|
+
try {
|
|
104
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
105
|
+
const base = resolveTeamsStyleDefinition(extendsId);
|
|
106
|
+
const template = {
|
|
107
|
+
extends: extendsId,
|
|
108
|
+
strings: base.strings,
|
|
109
|
+
naming: base.naming,
|
|
110
|
+
};
|
|
111
|
+
await fs.promises.writeFile(file, JSON.stringify(template, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
114
|
+
ctx.ui.notify(`Failed to create style file: ${file}\n${msg}`, "error");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ctx.ui.notify(
|
|
119
|
+
[
|
|
120
|
+
"Created style template:",
|
|
121
|
+
` ${file}`,
|
|
122
|
+
"",
|
|
123
|
+
"Edit it, then activate with:",
|
|
124
|
+
` /team style ${styleId}`,
|
|
125
|
+
].join("\n"),
|
|
126
|
+
"info",
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const next = normalizeTeamsStyleId(argRaw);
|
|
132
|
+
if (!next) {
|
|
133
|
+
ctx.ui.notify("Usage: /team style <name> | /team style list | /team style init <name>", "error");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Validate that the style exists (built-in or custom file). Falls back to throwing with a useful message.
|
|
139
|
+
resolveTeamsStyleDefinition(next, { strict: true });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
setStyle(next);
|
|
146
|
+
await setTeamStyle(teamDir, next);
|
|
147
|
+
await refreshTasks();
|
|
148
|
+
renderWidget();
|
|
149
|
+
ctx.ui.notify(`Teams style set to ${next}`, "info");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function handleTeamCleanupCommand(opts: {
|
|
153
|
+
ctx: ExtensionCommandContext;
|
|
154
|
+
rest: string[];
|
|
155
|
+
teamId: string;
|
|
156
|
+
teammates: Map<string, TeammateRpc>;
|
|
157
|
+
refreshTasks: () => Promise<void>;
|
|
158
|
+
getTasks: () => TeamTask[];
|
|
159
|
+
renderWidget: () => void;
|
|
160
|
+
style: TeamsStyle;
|
|
161
|
+
}): Promise<void> {
|
|
162
|
+
const { ctx, rest, teamId, teammates, refreshTasks, getTasks, renderWidget, style } = opts;
|
|
163
|
+
const strings = getTeamsStrings(style);
|
|
164
|
+
|
|
165
|
+
const flags = rest.filter((a) => a.startsWith("--"));
|
|
166
|
+
const argsOnly = rest.filter((a) => !a.startsWith("--"));
|
|
167
|
+
const force = flags.includes("--force");
|
|
168
|
+
|
|
169
|
+
const unknownFlags = flags.filter((f) => f !== "--force");
|
|
170
|
+
if (unknownFlags.length) {
|
|
171
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (argsOnly.length) {
|
|
175
|
+
ctx.ui.notify("Usage: /team cleanup [--force]", "error");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const teamsRoot = getTeamsRootDir();
|
|
180
|
+
const teamDir = getTeamDir(teamId);
|
|
181
|
+
|
|
182
|
+
if (!force && teammates.size > 0) {
|
|
183
|
+
ctx.ui.notify(
|
|
184
|
+
`Refusing to cleanup while ${teammates.size} RPC ${strings.memberTitle.toLowerCase()}(s) are running. Stop them first or use --force.`,
|
|
185
|
+
"error",
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await refreshTasks();
|
|
191
|
+
const tasks = getTasks();
|
|
192
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
193
|
+
if (!force && inProgress.length > 0) {
|
|
194
|
+
ctx.ui.notify(
|
|
195
|
+
`Refusing to cleanup with ${inProgress.length} in_progress task(s). Complete/unassign them first or use --force.`,
|
|
196
|
+
"error",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!force) {
|
|
202
|
+
// Only prompt in interactive TTY mode. In RPC mode, confirm() would require
|
|
203
|
+
// the host to send extension_ui_response messages.
|
|
204
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
205
|
+
const ok = await ctx.ui.confirm(
|
|
206
|
+
"Cleanup team",
|
|
207
|
+
[
|
|
208
|
+
"Delete ALL team artifacts for this session?",
|
|
209
|
+
"",
|
|
210
|
+
`teamId: ${teamId}`,
|
|
211
|
+
`teamDir: ${teamDir}`,
|
|
212
|
+
`tasks: ${tasks.length} (in_progress: ${inProgress.length})`,
|
|
213
|
+
].join("\n"),
|
|
214
|
+
);
|
|
215
|
+
if (!ok) return;
|
|
216
|
+
} else {
|
|
217
|
+
ctx.ui.notify("Refusing to cleanup in non-interactive mode without --force", "error");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await cleanupTeamDir(teamsRoot, teamDir);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ctx.ui.notify(`Cleaned up team directory: ${teamDir}`, "warning");
|
|
230
|
+
await refreshTasks();
|
|
231
|
+
renderWidget();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function handleTeamShutdownCommand(opts: {
|
|
235
|
+
ctx: ExtensionCommandContext;
|
|
236
|
+
rest: string[];
|
|
237
|
+
teamId: string;
|
|
238
|
+
teammates: Map<string, TeammateRpc>;
|
|
239
|
+
getTeamConfig: () => TeamConfig | null;
|
|
240
|
+
leadName: string;
|
|
241
|
+
style: TeamsStyle;
|
|
242
|
+
getCurrentCtx: () => ExtensionContext | null;
|
|
243
|
+
getActiveTeamId: () => string;
|
|
244
|
+
stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
|
|
245
|
+
refreshTasks: () => Promise<void>;
|
|
246
|
+
getTasks: () => TeamTask[];
|
|
247
|
+
renderWidget: () => void;
|
|
248
|
+
}): Promise<void> {
|
|
249
|
+
const { ctx, rest, teamId, teammates, getTeamConfig, leadName, style, getCurrentCtx, getActiveTeamId, stopAllTeammates, refreshTasks, getTasks, renderWidget } = opts;
|
|
250
|
+
const strings = getTeamsStrings(style);
|
|
251
|
+
const nameRaw = rest[0];
|
|
252
|
+
|
|
253
|
+
// /team shutdown <name> [reason...] = request graceful worker shutdown via mailbox
|
|
254
|
+
if (nameRaw) {
|
|
255
|
+
const name = sanitizeName(nameRaw);
|
|
256
|
+
const reason = rest.slice(1).join(" ").trim() || undefined;
|
|
257
|
+
|
|
258
|
+
const teamDir = getTeamDir(teamId);
|
|
259
|
+
|
|
260
|
+
const requestId = randomUUID();
|
|
261
|
+
const ts = new Date().toISOString();
|
|
262
|
+
const payload: {
|
|
263
|
+
type: "shutdown_request";
|
|
264
|
+
requestId: string;
|
|
265
|
+
from: string;
|
|
266
|
+
timestamp: string;
|
|
267
|
+
reason?: string;
|
|
268
|
+
} = {
|
|
269
|
+
type: "shutdown_request",
|
|
270
|
+
requestId,
|
|
271
|
+
from: leadName,
|
|
272
|
+
timestamp: ts,
|
|
273
|
+
...(reason ? { reason } : {}),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
277
|
+
from: leadName,
|
|
278
|
+
text: JSON.stringify(payload),
|
|
279
|
+
timestamp: ts,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Best-effort: record in member metadata (if present).
|
|
283
|
+
void setMemberStatus(teamDir, name, "online", {
|
|
284
|
+
meta: {
|
|
285
|
+
shutdownRequestedAt: ts,
|
|
286
|
+
shutdownRequestId: requestId,
|
|
287
|
+
...(reason ? { shutdownReason: reason } : {}),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownRequestedVerb}`, "info");
|
|
292
|
+
|
|
293
|
+
// Optional fallback for RPC teammates: force stop if it doesn't exit.
|
|
294
|
+
const t = teammates.get(name);
|
|
295
|
+
if (t) {
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
if (getActiveTeamId() !== teamId) return;
|
|
298
|
+
if (t.status === "stopped" || t.status === "error") return;
|
|
299
|
+
void (async () => {
|
|
300
|
+
try {
|
|
301
|
+
await t.stop();
|
|
302
|
+
await setMemberStatus(teamDir, name, "offline", {
|
|
303
|
+
meta: { shutdownFallback: true, shutdownRequestId: requestId },
|
|
304
|
+
});
|
|
305
|
+
getCurrentCtx()?.ui.notify(
|
|
306
|
+
`${formatMemberDisplayName(style, name)} did not comply; ${strings.killedVerb}`,
|
|
307
|
+
"warning",
|
|
308
|
+
);
|
|
309
|
+
} catch {
|
|
310
|
+
// ignore
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
}, 10_000);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// /team shutdown (no args) = stop all teammates but keep the leader session alive
|
|
320
|
+
await refreshTasks();
|
|
321
|
+
const cfgBefore = getTeamConfig();
|
|
322
|
+
const cfgWorkersOnline = (cfgBefore?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
323
|
+
|
|
324
|
+
const activeNames = new Set<string>();
|
|
325
|
+
for (const name of teammates.keys()) activeNames.add(name);
|
|
326
|
+
for (const m of cfgWorkersOnline) activeNames.add(m.name);
|
|
327
|
+
|
|
328
|
+
if (activeNames.size === 0) {
|
|
329
|
+
const members = `${strings.memberTitle.toLowerCase()}s`;
|
|
330
|
+
ctx.ui.notify(formatTeamsTemplate(strings.noMembersToShutdown, { members, count: "0" }), "info");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
335
|
+
const plural = activeNames.size === 1 ? "" : "s";
|
|
336
|
+
const members = `${strings.memberTitle.toLowerCase()}${plural}`;
|
|
337
|
+
const msg = formatTeamsTemplate(strings.shutdownAllPrompt, {
|
|
338
|
+
count: String(activeNames.size),
|
|
339
|
+
members,
|
|
340
|
+
});
|
|
341
|
+
const ok = await ctx.ui.confirm("Shutdown team", msg);
|
|
342
|
+
if (!ok) return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const reason = "Stopped by /team shutdown";
|
|
346
|
+
// Stop RPC teammates we own
|
|
347
|
+
await stopAllTeammates(ctx, reason);
|
|
348
|
+
|
|
349
|
+
// Best-effort: ask *manual* workers (persisted in config.json) to shut down too.
|
|
350
|
+
// Also mark them offline so they stop cluttering the UI if they were left behind from old runs.
|
|
351
|
+
await refreshTasks();
|
|
352
|
+
const cfg = getTeamConfig();
|
|
353
|
+
const teamDir = getTeamDir(teamId);
|
|
354
|
+
|
|
355
|
+
const inProgressOwners = new Set<string>();
|
|
356
|
+
for (const t of getTasks()) {
|
|
357
|
+
if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const manualWorkers = (cfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
361
|
+
for (const m of manualWorkers) {
|
|
362
|
+
// If it's an RPC teammate we already stopped above, skip mailbox request.
|
|
363
|
+
if (teammates.has(m.name)) continue;
|
|
364
|
+
// If a manual worker still owns an in-progress task, don't force it offline in the UI.
|
|
365
|
+
if (inProgressOwners.has(m.name)) continue;
|
|
366
|
+
|
|
367
|
+
const requestId = randomUUID();
|
|
368
|
+
const ts = new Date().toISOString();
|
|
369
|
+
try {
|
|
370
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
|
|
371
|
+
from: leadName,
|
|
372
|
+
text: JSON.stringify({
|
|
373
|
+
type: "shutdown_request",
|
|
374
|
+
requestId,
|
|
375
|
+
from: leadName,
|
|
376
|
+
timestamp: ts,
|
|
377
|
+
reason,
|
|
378
|
+
}),
|
|
379
|
+
timestamp: ts,
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
// ignore mailbox errors
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
void setMemberStatus(teamDir, m.name, "offline", {
|
|
386
|
+
meta: { shutdownRequestedAt: ts, shutdownRequestId: requestId, stoppedReason: reason },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
renderWidget();
|
|
391
|
+
const members = `${strings.memberTitle.toLowerCase()}s`;
|
|
392
|
+
ctx.ui.notify(formatTeamsTemplate(strings.teamEndedAllStopped, { members, count: String(activeNames.size) }), "info");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function handleTeamPruneCommand(opts: {
|
|
396
|
+
ctx: ExtensionCommandContext;
|
|
397
|
+
rest: string[];
|
|
398
|
+
teamId: string;
|
|
399
|
+
teammates: Map<string, TeammateRpc>;
|
|
400
|
+
getTeamConfig: () => TeamConfig | null;
|
|
401
|
+
refreshTasks: () => Promise<void>;
|
|
402
|
+
getTasks: () => TeamTask[];
|
|
403
|
+
style: TeamsStyle;
|
|
404
|
+
renderWidget: () => void;
|
|
405
|
+
}): Promise<void> {
|
|
406
|
+
const { ctx, rest, teamId, teammates, getTeamConfig, refreshTasks, getTasks, style, renderWidget } = opts;
|
|
407
|
+
const strings = getTeamsStrings(style);
|
|
408
|
+
|
|
409
|
+
const flags = rest.filter((a) => a.startsWith("--"));
|
|
410
|
+
const argsOnly = rest.filter((a) => !a.startsWith("--"));
|
|
411
|
+
const all = flags.includes("--all");
|
|
412
|
+
const unknownFlags = flags.filter((f) => f !== "--all");
|
|
413
|
+
if (unknownFlags.length) {
|
|
414
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (argsOnly.length) {
|
|
418
|
+
ctx.ui.notify("Usage: /team prune [--all]", "error");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await refreshTasks();
|
|
423
|
+
const cfg = getTeamConfig();
|
|
424
|
+
const members = (cfg?.members ?? []).filter((m) => m.role === "worker");
|
|
425
|
+
if (!members.length) {
|
|
426
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to prune`, "info");
|
|
427
|
+
renderWidget();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const inProgressOwners = new Set<string>();
|
|
432
|
+
for (const t of getTasks()) {
|
|
433
|
+
if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const cutoffMs = 60 * 60 * 1000; // 1h
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
|
|
439
|
+
const pruned: string[] = [];
|
|
440
|
+
for (const m of members) {
|
|
441
|
+
if (teammates.has(m.name)) continue; // still tracked as RPC
|
|
442
|
+
if (inProgressOwners.has(m.name)) continue; // still actively working
|
|
443
|
+
if (!all) {
|
|
444
|
+
const lastSeen = m.lastSeenAt ? Date.parse(m.lastSeenAt) : NaN;
|
|
445
|
+
if (!Number.isFinite(lastSeen)) continue;
|
|
446
|
+
if (now - lastSeen < cutoffMs) continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const teamDir = getTeamDir(teamId);
|
|
450
|
+
await setMemberStatus(teamDir, m.name, "offline", {
|
|
451
|
+
meta: { prunedAt: new Date().toISOString(), prunedBy: "leader" },
|
|
452
|
+
});
|
|
453
|
+
pruned.push(m.name);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await refreshTasks();
|
|
457
|
+
renderWidget();
|
|
458
|
+
ctx.ui.notify(
|
|
459
|
+
pruned.length
|
|
460
|
+
? `Pruned ${pruned.length} stale ${strings.memberTitle.toLowerCase()}(s): ${pruned.join(", ")}`
|
|
461
|
+
: `No stale ${strings.memberTitle.toLowerCase()}s to prune (use --all to force)`,
|
|
462
|
+
"info",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function handleTeamStopCommand(opts: {
|
|
467
|
+
ctx: ExtensionCommandContext;
|
|
468
|
+
rest: string[];
|
|
469
|
+
teamId: string;
|
|
470
|
+
teammates: Map<string, TeammateRpc>;
|
|
471
|
+
leadName: string;
|
|
472
|
+
style: TeamsStyle;
|
|
473
|
+
refreshTasks: () => Promise<void>;
|
|
474
|
+
getTasks: () => TeamTask[];
|
|
475
|
+
renderWidget: () => void;
|
|
476
|
+
}): Promise<void> {
|
|
477
|
+
const { ctx, rest, teamId, teammates, leadName, style, refreshTasks, getTasks, renderWidget } = opts;
|
|
478
|
+
|
|
479
|
+
const nameRaw = rest[0];
|
|
480
|
+
const reason = rest.slice(1).join(" ").trim();
|
|
481
|
+
if (!nameRaw) {
|
|
482
|
+
ctx.ui.notify("Usage: /team stop <name> [reason...]", "error");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const name = sanitizeName(nameRaw);
|
|
486
|
+
|
|
487
|
+
const teamDir = getTeamDir(teamId);
|
|
488
|
+
|
|
489
|
+
// Best-effort: include current in-progress task id (if any).
|
|
490
|
+
await refreshTasks();
|
|
491
|
+
const tasks = getTasks();
|
|
492
|
+
const active = tasks.find((x) => x.owner === name && x.status === "in_progress");
|
|
493
|
+
const taskId = active?.id;
|
|
494
|
+
|
|
495
|
+
const ts = new Date().toISOString();
|
|
496
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
497
|
+
from: leadName,
|
|
498
|
+
text: JSON.stringify({
|
|
499
|
+
type: "abort_request",
|
|
500
|
+
requestId: randomUUID(),
|
|
501
|
+
from: leadName,
|
|
502
|
+
taskId,
|
|
503
|
+
reason: reason || undefined,
|
|
504
|
+
timestamp: ts,
|
|
505
|
+
}),
|
|
506
|
+
timestamp: ts,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const t = teammates.get(name);
|
|
510
|
+
if (t) {
|
|
511
|
+
// Fast-path for RPC teammates.
|
|
512
|
+
await t.abort();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let msg = `${formatMemberDisplayName(style, name)} ${getTeamsStrings(style).abortRequestedVerb}`;
|
|
516
|
+
if (taskId) msg += ` (task #${taskId})`;
|
|
517
|
+
if (!t) msg += " (mailbox only)";
|
|
518
|
+
ctx.ui.notify(msg, "warning");
|
|
519
|
+
renderWidget();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function handleTeamKillCommand(opts: {
|
|
523
|
+
ctx: ExtensionCommandContext;
|
|
524
|
+
rest: string[];
|
|
525
|
+
teamId: string;
|
|
526
|
+
teammates: Map<string, TeammateRpc>;
|
|
527
|
+
leadName: string;
|
|
528
|
+
style: TeamsStyle;
|
|
529
|
+
taskListId: string | null;
|
|
530
|
+
refreshTasks: () => Promise<void>;
|
|
531
|
+
renderWidget: () => void;
|
|
532
|
+
}): Promise<void> {
|
|
533
|
+
const { ctx, rest, teamId, teammates, taskListId, leadName: _leadName, style, refreshTasks, renderWidget } = opts;
|
|
534
|
+
const strings = getTeamsStrings(style);
|
|
535
|
+
|
|
536
|
+
const nameRaw = rest[0];
|
|
537
|
+
if (!nameRaw) {
|
|
538
|
+
ctx.ui.notify("Usage: /team kill <name>", "error");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const name = sanitizeName(nameRaw);
|
|
542
|
+
const t = teammates.get(name);
|
|
543
|
+
if (!t) {
|
|
544
|
+
ctx.ui.notify(`Unknown ${strings.memberTitle.toLowerCase()}: ${name}`, "error");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await t.stop();
|
|
549
|
+
teammates.delete(name);
|
|
550
|
+
|
|
551
|
+
const teamDir = getTeamDir(teamId);
|
|
552
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
553
|
+
await unassignTasksForAgent(teamDir, effectiveTlId, name, `${formatMemberDisplayName(style, name)} ${strings.killedVerb}`);
|
|
554
|
+
await setMemberStatus(teamDir, name, "offline", { meta: { killedAt: new Date().toISOString() } });
|
|
555
|
+
|
|
556
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.killedVerb} (SIGTERM)`, "warning");
|
|
557
|
+
await refreshTasks();
|
|
558
|
+
renderWidget();
|
|
559
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
2
|
+
import { writeToMailbox } from "./mailbox.js";
|
|
3
|
+
import { sanitizeName } from "./names.js";
|
|
4
|
+
import { getTeamDir } from "./paths.js";
|
|
5
|
+
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
6
|
+
import { ensureTeamConfig } from "./team-config.js";
|
|
7
|
+
import type { TeamTask } from "./task-store.js";
|
|
8
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
9
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
10
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
11
|
+
|
|
12
|
+
export async function handleTeamSendCommand(opts: {
|
|
13
|
+
ctx: ExtensionCommandContext;
|
|
14
|
+
rest: string[];
|
|
15
|
+
teammates: Map<string, TeammateRpc>;
|
|
16
|
+
style: TeamsStyle;
|
|
17
|
+
renderWidget: () => void;
|
|
18
|
+
}): Promise<void> {
|
|
19
|
+
const { ctx, rest, teammates, style, renderWidget } = opts;
|
|
20
|
+
const strings = getTeamsStrings(style);
|
|
21
|
+
|
|
22
|
+
const nameRaw = rest[0];
|
|
23
|
+
const msg = rest.slice(1).join(" ").trim();
|
|
24
|
+
if (!nameRaw || !msg) {
|
|
25
|
+
ctx.ui.notify("Usage: /team send <name> <msg...>", "error");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const name = sanitizeName(nameRaw);
|
|
29
|
+
const t = teammates.get(name);
|
|
30
|
+
if (!t) {
|
|
31
|
+
ctx.ui.notify(`Unknown ${strings.memberTitle.toLowerCase()}: ${name}`, "error");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (t.status === "streaming") await t.followUp(msg);
|
|
35
|
+
else await t.prompt(msg);
|
|
36
|
+
ctx.ui.notify(`Sent to ${name}`, "info");
|
|
37
|
+
renderWidget();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function handleTeamSteerCommand(opts: {
|
|
41
|
+
ctx: ExtensionCommandContext;
|
|
42
|
+
rest: string[];
|
|
43
|
+
teammates: Map<string, TeammateRpc>;
|
|
44
|
+
style: TeamsStyle;
|
|
45
|
+
renderWidget: () => void;
|
|
46
|
+
}): Promise<void> {
|
|
47
|
+
const { ctx, rest, teammates, style, renderWidget } = opts;
|
|
48
|
+
const strings = getTeamsStrings(style);
|
|
49
|
+
|
|
50
|
+
const nameRaw = rest[0];
|
|
51
|
+
const msg = rest.slice(1).join(" ").trim();
|
|
52
|
+
if (!nameRaw || !msg) {
|
|
53
|
+
ctx.ui.notify("Usage: /team steer <name> <msg...>", "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const name = sanitizeName(nameRaw);
|
|
57
|
+
const t = teammates.get(name);
|
|
58
|
+
if (!t) {
|
|
59
|
+
ctx.ui.notify(`Unknown ${strings.memberTitle.toLowerCase()}: ${name}`, "error");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await t.steer(msg);
|
|
63
|
+
ctx.ui.notify(`Steering sent to ${name}`, "info");
|
|
64
|
+
renderWidget();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleTeamDmCommand(opts: {
|
|
68
|
+
ctx: ExtensionCommandContext;
|
|
69
|
+
rest: string[];
|
|
70
|
+
teamId: string;
|
|
71
|
+
leadName: string;
|
|
72
|
+
style: TeamsStyle;
|
|
73
|
+
}): Promise<void> {
|
|
74
|
+
const { ctx, rest, teamId, leadName, style } = opts;
|
|
75
|
+
|
|
76
|
+
const nameRaw = rest[0];
|
|
77
|
+
const msg = rest.slice(1).join(" ").trim();
|
|
78
|
+
if (!nameRaw || !msg) {
|
|
79
|
+
ctx.ui.notify("Usage: /team dm <name> <msg...>", "error");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const name = sanitizeName(nameRaw);
|
|
83
|
+
await writeToMailbox(getTeamDir(teamId), TEAM_MAILBOX_NS, name, {
|
|
84
|
+
from: leadName,
|
|
85
|
+
text: msg,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
ctx.ui.notify(`DM queued for ${formatMemberDisplayName(style, name)}`, "info");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function handleTeamBroadcastCommand(opts: {
|
|
92
|
+
ctx: ExtensionCommandContext;
|
|
93
|
+
rest: string[];
|
|
94
|
+
teamId: string;
|
|
95
|
+
teammates: Map<string, TeammateRpc>;
|
|
96
|
+
leadName: string;
|
|
97
|
+
style: TeamsStyle;
|
|
98
|
+
refreshTasks: () => Promise<void>;
|
|
99
|
+
getTasks: () => TeamTask[];
|
|
100
|
+
getTaskListId: () => string | null;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
const { ctx, rest, teamId, teammates, leadName, style, refreshTasks, getTasks, getTaskListId } = opts;
|
|
103
|
+
const strings = getTeamsStrings(style);
|
|
104
|
+
|
|
105
|
+
const msg = rest.join(" ").trim();
|
|
106
|
+
if (!msg) {
|
|
107
|
+
ctx.ui.notify("Usage: /team broadcast <msg...>", "error");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const teamDir = getTeamDir(teamId);
|
|
112
|
+
const taskListId = getTaskListId();
|
|
113
|
+
const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName, style });
|
|
114
|
+
|
|
115
|
+
const recipients = new Set<string>();
|
|
116
|
+
for (const m of cfg.members) {
|
|
117
|
+
if (m.role === "worker") recipients.add(m.name);
|
|
118
|
+
}
|
|
119
|
+
for (const name of teammates.keys()) recipients.add(name);
|
|
120
|
+
|
|
121
|
+
// Include task owners (helps reach manual tmux workers not tracked as RPC teammates).
|
|
122
|
+
await refreshTasks();
|
|
123
|
+
for (const t of getTasks()) {
|
|
124
|
+
if (t.owner && t.owner !== leadName) recipients.add(t.owner);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const names = Array.from(recipients).sort();
|
|
128
|
+
if (names.length === 0) {
|
|
129
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to broadcast to`, "warning");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ts = new Date().toISOString();
|
|
134
|
+
await Promise.all(
|
|
135
|
+
names.map((name) =>
|
|
136
|
+
writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
137
|
+
from: leadName,
|
|
138
|
+
text: msg,
|
|
139
|
+
timestamp: ts,
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
ctx.ui.notify(
|
|
145
|
+
`Broadcast queued for ${names.length} ${strings.memberTitle.toLowerCase()}(s): ${names.map((n) => formatMemberDisplayName(style, n)).join(", ")}`,
|
|
146
|
+
"info",
|
|
147
|
+
);
|
|
148
|
+
}
|