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.
Files changed (52) hide show
  1. package/AGENTS.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/claude-parity.md +151 -0
  5. package/docs/field-notes-teams-setup.md +107 -0
  6. package/docs/smoke-test-plan.md +146 -0
  7. package/eslint.config.js +74 -0
  8. package/extensions/teams/README.md +23 -0
  9. package/extensions/teams/activity-tracker.ts +234 -0
  10. package/extensions/teams/cleanup.ts +31 -0
  11. package/extensions/teams/fs-lock.ts +87 -0
  12. package/extensions/teams/hooks.ts +363 -0
  13. package/extensions/teams/index.ts +18 -0
  14. package/extensions/teams/leader-attach-commands.ts +221 -0
  15. package/extensions/teams/leader-inbox.ts +214 -0
  16. package/extensions/teams/leader-info-commands.ts +140 -0
  17. package/extensions/teams/leader-lifecycle-commands.ts +559 -0
  18. package/extensions/teams/leader-messaging-commands.ts +148 -0
  19. package/extensions/teams/leader-plan-commands.ts +95 -0
  20. package/extensions/teams/leader-spawn-command.ts +149 -0
  21. package/extensions/teams/leader-task-commands.ts +435 -0
  22. package/extensions/teams/leader-team-command.ts +382 -0
  23. package/extensions/teams/leader-teams-tool.ts +1075 -0
  24. package/extensions/teams/leader.ts +925 -0
  25. package/extensions/teams/mailbox.ts +131 -0
  26. package/extensions/teams/model-policy.ts +142 -0
  27. package/extensions/teams/names.ts +121 -0
  28. package/extensions/teams/paths.ts +37 -0
  29. package/extensions/teams/protocol.ts +241 -0
  30. package/extensions/teams/spawn-types.ts +36 -0
  31. package/extensions/teams/task-store.ts +544 -0
  32. package/extensions/teams/team-attach-claim.ts +205 -0
  33. package/extensions/teams/team-config.ts +335 -0
  34. package/extensions/teams/team-discovery.ts +59 -0
  35. package/extensions/teams/teammate-rpc.ts +261 -0
  36. package/extensions/teams/teams-panel.ts +1186 -0
  37. package/extensions/teams/teams-style.ts +322 -0
  38. package/extensions/teams/teams-ui-shared.ts +89 -0
  39. package/extensions/teams/teams-widget.ts +212 -0
  40. package/extensions/teams/worker.ts +605 -0
  41. package/extensions/teams/worktree.ts +103 -0
  42. package/package.json +53 -0
  43. package/scripts/e2e-rpc-test.mjs +277 -0
  44. package/scripts/integration-claim-test.mts +157 -0
  45. package/scripts/integration-hooks-remediation-test.mts +382 -0
  46. package/scripts/integration-spawn-overrides-test.mts +398 -0
  47. package/scripts/integration-todo-test.mts +533 -0
  48. package/scripts/lib/pi-workers.ts +105 -0
  49. package/scripts/smoke-test.mts +764 -0
  50. package/scripts/start-tmux-team.sh +91 -0
  51. package/skills/agent-teams/SKILL.md +180 -0
  52. package/tsconfig.strict.json +22 -0
@@ -0,0 +1,925 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "indusagi-coding-agent";
5
+ import { SessionManager } from "indusagi-coding-agent";
6
+ import { writeToMailbox } from "./mailbox.js";
7
+ import { sanitizeName } from "./names.js";
8
+ import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js";
9
+ import { createTask, listTasks, unassignTasksForAgent, updateTask, type TeamTask } from "./task-store.js";
10
+ import { TeammateRpc } from "./teammate-rpc.js";
11
+ import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
12
+ import { getTeamDir } from "./paths.js";
13
+ import { heartbeatTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
14
+ import { ensureWorktreeCwd } from "./worktree.js";
15
+ import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
16
+ import { openInteractiveWidget } from "./teams-panel.js";
17
+ import { createTeamsWidget } from "./teams-widget.js";
18
+ import { resolveTeammateModelSelection, formatProviderModel } from "./model-policy.js";
19
+ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
20
+ import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
21
+ import {
22
+ getHookBaseName,
23
+ getTeamsHookFailureAction,
24
+ getTeamsHookFollowupOwnerPolicy,
25
+ getTeamsHookMaxReopensPerTask,
26
+ resolveTeamsHookFollowupOwner,
27
+ runTeamsHook,
28
+ shouldCreateHookFollowupTask,
29
+ shouldReopenTaskOnHookFailure,
30
+ type TeamsHookInvocation,
31
+ } from "./hooks.js";
32
+ import { handleTeamCommand } from "./leader-team-command.js";
33
+ import { registerTeamsTool } from "./leader-teams-tool.js";
34
+ import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";
35
+
36
+ function getTeamsExtensionEntryPath(): string | null {
37
+ // In dev, teammates won't automatically have this extension unless it is installed or discoverable.
38
+ // We try to load the same extension entry explicitly (and disable extension discovery to avoid duplicates).
39
+ try {
40
+ const dir = path.dirname(fileURLToPath(import.meta.url));
41
+ const ts = path.join(dir, "index.ts");
42
+ if (fs.existsSync(ts)) return ts;
43
+ const js = path.join(dir, "index.js");
44
+ if (fs.existsSync(js)) return js;
45
+ return null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function shellQuote(v: string): string {
52
+ return "'" + v.replace(/'/g, `"'"'"'`) + "'";
53
+ }
54
+
55
+ function getTeamSessionsDir(teamDir: string): string {
56
+ return path.join(teamDir, "sessions");
57
+ }
58
+
59
+ async function ensureDir(p: string): Promise<void> {
60
+ await fs.promises.mkdir(p, { recursive: true });
61
+ }
62
+
63
+ async function createSessionForTeammate(
64
+ ctx: ExtensionContext,
65
+ mode: ContextMode,
66
+ teamSessionsDir: string,
67
+ ): Promise<{ sessionFile?: string; note?: string; warnings: string[] }> {
68
+ const warnings: string[] = [];
69
+ await ensureDir(teamSessionsDir);
70
+
71
+ if (mode === "fresh") {
72
+ const sm = SessionManager.create(ctx.cwd, teamSessionsDir);
73
+ return { sessionFile: sm.getSessionFile(), note: "fresh", warnings };
74
+ }
75
+
76
+ const leafId = ctx.sessionManager.getLeafId();
77
+ if (!leafId) {
78
+ const sm = SessionManager.create(ctx.cwd, teamSessionsDir);
79
+ return { sessionFile: sm.getSessionFile(), note: "branch(empty->fresh)", warnings };
80
+ }
81
+
82
+ const parentSessionFile = ctx.sessionManager.getSessionFile();
83
+ if (!parentSessionFile) {
84
+ const sm = SessionManager.create(ctx.cwd, teamSessionsDir);
85
+ return { sessionFile: sm.getSessionFile(), note: "branch(in-memory->fresh)", warnings };
86
+ }
87
+
88
+ try {
89
+ const sm = SessionManager.open(parentSessionFile, teamSessionsDir);
90
+ const branched = sm.createBranchedSession(leafId);
91
+ if (!branched) {
92
+ const fallback = SessionManager.create(ctx.cwd, teamSessionsDir);
93
+ return { sessionFile: fallback.getSessionFile(), note: "branch(failed->fresh)", warnings };
94
+ }
95
+ return { sessionFile: branched, note: "branch", warnings };
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ if (/Entry .* not found/i.test(msg)) {
99
+ warnings.push(`Branch context missing (${msg}); falling back to fresh session.`);
100
+ } else {
101
+ warnings.push(`Branch context error (${msg}); falling back to fresh session.`);
102
+ }
103
+ const fallback = SessionManager.create(ctx.cwd, teamSessionsDir);
104
+ return { sessionFile: fallback.getSessionFile(), note: "branch(error->fresh)", warnings };
105
+ }
106
+ }
107
+
108
+ // Message parsers are shared with the worker implementation.
109
+ export function runLeader(pi: ExtensionAPI): void {
110
+ const teammates = new Map<string, TeammateRpc>();
111
+ const tracker = new ActivityTracker();
112
+ const transcriptTracker = new TranscriptTracker();
113
+ const teammateEventUnsubs = new Map<string, () => void>();
114
+ let currentCtx: ExtensionContext | null = null;
115
+ let currentTeamId: string | null = null;
116
+ let tasks: TeamTask[] = [];
117
+ let teamConfig: TeamConfig | null = null;
118
+ const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
119
+ // Task list namespace. By default we keep it aligned with the current session id.
120
+ // (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
121
+ // and can easily be set globally, which makes the leader "lose" its tasks.)
122
+ let taskListId: string | null = null;
123
+
124
+ let refreshTimer: NodeJS.Timeout | null = null;
125
+ let inboxTimer: NodeJS.Timeout | null = null;
126
+ let refreshInFlight = false;
127
+ let inboxInFlight = false;
128
+ let isStopping = false;
129
+ let delegateMode = process.env.PI_TEAMS_DELEGATE_MODE === "1";
130
+ let style: TeamsStyle = getTeamsStyleFromEnv();
131
+ let lastAttachClaimHeartbeatMs = 0;
132
+
133
+ const stopLoops = () => {
134
+ if (refreshTimer) clearInterval(refreshTimer);
135
+ if (inboxTimer) clearInterval(inboxTimer);
136
+ refreshTimer = null;
137
+ inboxTimer = null;
138
+ };
139
+
140
+ const releaseActiveAttachClaim = async (ctx: ExtensionContext): Promise<void> => {
141
+ if (!currentTeamId) return;
142
+ const sessionTeamId = ctx.sessionManager.getSessionId();
143
+ if (currentTeamId === sessionTeamId) return;
144
+ await releaseTeamAttachClaim(getTeamDir(currentTeamId), sessionTeamId);
145
+ };
146
+
147
+ const heartbeatActiveAttachClaim = async (ctx: ExtensionContext): Promise<void> => {
148
+ if (!currentTeamId) return;
149
+ const sessionTeamId = ctx.sessionManager.getSessionId();
150
+ if (currentTeamId === sessionTeamId) return;
151
+ const nowMs = Date.now();
152
+ if (nowMs - lastAttachClaimHeartbeatMs < 5_000) return;
153
+ lastAttachClaimHeartbeatMs = nowMs;
154
+ const result = await heartbeatTeamAttachClaim(getTeamDir(currentTeamId), sessionTeamId);
155
+ if (result === "updated") return;
156
+
157
+ ctx.ui.notify(
158
+ `Attach claim for team ${currentTeamId} is no longer owned by this session; detaching to session team.`,
159
+ "warning",
160
+ );
161
+ currentTeamId = sessionTeamId;
162
+ taskListId = sessionTeamId;
163
+ await refreshTasks();
164
+ renderWidget();
165
+ };
166
+
167
+ const stopAllTeammates = async (ctx: ExtensionContext, reason: string) => {
168
+ if (teammates.size === 0) return;
169
+ isStopping = true;
170
+ try {
171
+ for (const [name, t] of teammates.entries()) {
172
+ try {
173
+ teammateEventUnsubs.get(name)?.();
174
+ } catch {
175
+ // ignore
176
+ }
177
+ teammateEventUnsubs.delete(name);
178
+ tracker.reset(name);
179
+ transcriptTracker.reset(name);
180
+
181
+ await t.stop();
182
+ // Claude-style: unassign non-completed tasks on exit.
183
+ const teamId = currentTeamId ?? ctx.sessionManager.getSessionId();
184
+ const teamDir = getTeamDir(teamId);
185
+ const effectiveTlId = taskListId ?? teamId;
186
+ await unassignTasksForAgent(teamDir, effectiveTlId, name, reason);
187
+ await setMemberStatus(teamDir, name, "offline", { meta: { stoppedReason: reason } });
188
+ }
189
+ teammates.clear();
190
+ } finally {
191
+ isStopping = false;
192
+ }
193
+ };
194
+
195
+ // Hooks / quality gates (serialized execution so multiple idle events don't overlap).
196
+ let hookChain: Promise<void> = Promise.resolve();
197
+ const seenHookEvents = new Set<string>();
198
+
199
+ const enqueueHook = (invocation: TeamsHookInvocation) => {
200
+ const taskId = invocation.completedTask?.id ?? "";
201
+ const ts = invocation.timestamp ?? "";
202
+ const key = `${invocation.teamId}:${invocation.event}:${taskId}:${ts}:${invocation.memberName ?? ""}`;
203
+ if (seenHookEvents.has(key)) return;
204
+ seenHookEvents.add(key);
205
+
206
+ hookChain = hookChain
207
+ .then(async () => {
208
+ // Only run hooks for the currently active team session.
209
+ if (!currentCtx) return;
210
+ if (!currentTeamId || currentTeamId !== invocation.teamId) return;
211
+
212
+ const res = await runTeamsHook({ invocation, cwd: currentCtx.cwd });
213
+ if (!res.ran) return;
214
+
215
+ // Persist a log for debugging.
216
+ try {
217
+ const logsDir = path.join(invocation.teamDir, "hook-logs");
218
+ await fs.promises.mkdir(logsDir, { recursive: true });
219
+ const name = `${new Date().toISOString().replace(/[:.]/g, "-")}_${invocation.event}.json`;
220
+ await fs.promises.writeFile(
221
+ path.join(logsDir, name),
222
+ JSON.stringify(
223
+ {
224
+ invocation,
225
+ result: res,
226
+ },
227
+ null,
228
+ 2,
229
+ ) + "\n",
230
+ "utf8",
231
+ );
232
+ } catch {
233
+ // ignore logging errors
234
+ }
235
+
236
+ const ok = res.exitCode === 0 && !res.timedOut && !res.error;
237
+ const hookName = getHookBaseName(invocation.event);
238
+ const cfgForInvocation = await loadTeamConfig(invocation.teamDir);
239
+ const hookPolicy = cfgForInvocation?.hooks;
240
+ const failureAction = getTeamsHookFailureAction(process.env, hookPolicy?.failureAction);
241
+ const shouldFollowup = shouldCreateHookFollowupTask(failureAction);
242
+ const shouldReopen = shouldReopenTaskOnHookFailure(failureAction);
243
+ const maxReopens = getTeamsHookMaxReopensPerTask(process.env, hookPolicy?.maxReopensPerTask);
244
+ const followupOwnerPolicy = getTeamsHookFollowupOwnerPolicy(process.env, hookPolicy?.followupOwner);
245
+ const task = invocation.completedTask;
246
+ const leadName = cfgForInvocation?.leadName ?? teamConfig?.leadName ?? "team-lead";
247
+
248
+ const stderrFirstLine = res.stderr
249
+ .split(/\r?\n/)
250
+ .map((line) => line.trim())
251
+ .find((line) => line.length > 0);
252
+ const failureParts: string[] = [];
253
+ if (res.error) failureParts.push(res.error);
254
+ if (res.timedOut) failureParts.push(`timeout after ${res.durationMs}ms`);
255
+ if (!res.timedOut && res.exitCode !== null && res.exitCode !== 0) failureParts.push(`exit code ${res.exitCode}`);
256
+ if (stderrFirstLine) failureParts.push(stderrFirstLine.length > 180 ? `${stderrFirstLine.slice(0, 179)}…` : stderrFirstLine);
257
+ const failureSummary = failureParts.join(" • ") || "hook failed";
258
+
259
+ // Idle hooks are intentionally quiet unless they fail.
260
+ if (invocation.event === "idle") {
261
+ if (!ok) {
262
+ currentCtx.ui.notify(`Hook ${hookName} failed: ${failureSummary}`, "warning");
263
+ }
264
+ return;
265
+ }
266
+
267
+ let taskReopened = false;
268
+ let taskReopenSuppressed = false;
269
+ if (task?.id) {
270
+ const nowIso = new Date().toISOString();
271
+ await updateTask(invocation.teamDir, invocation.taskListId, task.id, (cur) => {
272
+ const metadata = { ...(cur.metadata ?? {}) };
273
+ const prevFailureCountRaw = metadata["qualityGateFailureCount"];
274
+ const prevFailureCount =
275
+ typeof prevFailureCountRaw === "number" && Number.isFinite(prevFailureCountRaw) ? prevFailureCountRaw : 0;
276
+
277
+ metadata["qualityGateHook"] = hookName;
278
+ metadata["qualityGateAt"] = nowIso;
279
+
280
+ if (ok) {
281
+ metadata["qualityGateStatus"] = "passed";
282
+ metadata["qualityGateSummary"] = `passed in ${res.durationMs}ms`;
283
+ metadata["qualityGateLastSuccessAt"] = nowIso;
284
+ metadata["qualityGateReopenSuppressed"] = false;
285
+ return { ...cur, metadata };
286
+ }
287
+
288
+ metadata["qualityGateStatus"] = "failed";
289
+ metadata["qualityGateSummary"] = failureSummary;
290
+ metadata["qualityGateFailureCount"] = prevFailureCount + 1;
291
+ metadata["qualityGateLastFailureAt"] = nowIso;
292
+
293
+ if (shouldReopen && cur.status === "completed") {
294
+ const prevReopenCountRaw = metadata["reopenedByQualityGateCount"];
295
+ const prevReopenCount =
296
+ typeof prevReopenCountRaw === "number" && Number.isFinite(prevReopenCountRaw) ? prevReopenCountRaw : 0;
297
+ const canAutoReopen = maxReopens > 0 && prevReopenCount < maxReopens;
298
+ if (canAutoReopen) {
299
+ taskReopened = true;
300
+ metadata["reopenedByQualityGateAt"] = nowIso;
301
+ metadata["reopenedByQualityGateHook"] = hookName;
302
+ metadata["reopenedByQualityGateCount"] = prevReopenCount + 1;
303
+ metadata["qualityGateReopenSuppressed"] = false;
304
+ return { ...cur, status: "pending", metadata };
305
+ }
306
+ taskReopenSuppressed = true;
307
+ metadata["qualityGateReopenSuppressed"] = true;
308
+ metadata["qualityGateReopenSuppressedReason"] =
309
+ maxReopens <= 0
310
+ ? "PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK=0"
311
+ : `reopen limit reached (${maxReopens})`;
312
+ }
313
+ return { ...cur, metadata };
314
+ });
315
+
316
+ await refreshTasks();
317
+ renderWidget();
318
+ }
319
+
320
+ if (ok) {
321
+ const taskRef = task?.id ? ` for task #${task.id}` : "";
322
+ currentCtx.ui.notify(`Hook ${hookName} passed${taskRef} (${res.durationMs}ms)`, "info");
323
+ return;
324
+ }
325
+
326
+ const failedTaskRef = task?.id ? ` for task #${task.id}` : "";
327
+ currentCtx.ui.notify(`Hook ${hookName} failed${failedTaskRef}: ${failureSummary}`, "warning");
328
+ if (taskReopened && task?.id) {
329
+ currentCtx.ui.notify(`Reopened task #${task.id} due to quality-gate failure`, "warning");
330
+ } else if (taskReopenSuppressed && shouldReopen && task?.id) {
331
+ currentCtx.ui.notify(`Auto-reopen suppressed for task #${task.id} (limit ${maxReopens})`, "warning");
332
+ }
333
+
334
+ let followupTask: TeamTask | null = null;
335
+ if (shouldFollowup && task?.id) {
336
+ const followupOwner = resolveTeamsHookFollowupOwner({
337
+ policy: followupOwnerPolicy,
338
+ memberName: invocation.memberName,
339
+ leadName,
340
+ });
341
+ const subject = `Quality gate failed: ${hookName} (task #${task.id})`;
342
+ const descParts: string[] = [];
343
+ descParts.push(`Hook: ${hookName}`);
344
+ descParts.push(`Policy: ${failureAction}`);
345
+ descParts.push(`Failure: ${failureSummary}`);
346
+ if (res.command?.length) descParts.push(`Command: ${res.command.join(" ")}`);
347
+ descParts.push("");
348
+ if (task.subject) descParts.push(`Original task subject: ${task.subject}`);
349
+ descParts.push("");
350
+ if (res.stdout.trim()) {
351
+ descParts.push("STDOUT:");
352
+ descParts.push(res.stdout.trim());
353
+ descParts.push("");
354
+ }
355
+ if (res.stderr.trim()) {
356
+ descParts.push("STDERR:");
357
+ descParts.push(res.stderr.trim());
358
+ descParts.push("");
359
+ }
360
+
361
+ followupTask = await createTask(invocation.teamDir, invocation.taskListId, {
362
+ subject,
363
+ description: descParts.join("\n"),
364
+ owner: followupOwner,
365
+ });
366
+
367
+ if (followupOwner) {
368
+ await writeToMailbox(invocation.teamDir, invocation.taskListId, followupOwner, {
369
+ from: leadName,
370
+ text: JSON.stringify(taskAssignmentPayload(followupTask, leadName)),
371
+ timestamp: new Date().toISOString(),
372
+ });
373
+ }
374
+
375
+ await refreshTasks();
376
+ renderWidget();
377
+ }
378
+
379
+ const remediationTarget = sanitizeName(task?.owner ?? invocation.memberName ?? "");
380
+ if (remediationTarget) {
381
+ const nextSteps: string[] = [];
382
+ if (task?.id && taskReopened) nextSteps.push(`Task #${task.id} was reopened to pending.`);
383
+ if (followupTask?.id) nextSteps.push(`Follow-up task #${followupTask.id} was created.`);
384
+ if (nextSteps.length === 0 && task?.id) nextSteps.push(`Task #${task.id} still requires remediation.`);
385
+
386
+ const messageLines = [
387
+ `Quality gate failed (${hookName}${task?.id ? ` / task #${task.id}` : ""}): ${failureSummary}`,
388
+ ...nextSteps,
389
+ "Please remediate automatically and continue without waiting for user intervention.",
390
+ ];
391
+
392
+ await writeToMailbox(invocation.teamDir, invocation.taskListId, remediationTarget, {
393
+ from: leadName,
394
+ text: messageLines.join("\n"),
395
+ timestamp: new Date().toISOString(),
396
+ });
397
+ }
398
+ })
399
+ .catch((err: unknown) => {
400
+ if (!currentCtx) return;
401
+ currentCtx.ui.notify(err instanceof Error ? err.message : String(err), "warning");
402
+ });
403
+ };
404
+
405
+ const widgetFactory = createTeamsWidget({
406
+ getTeammates: () => teammates,
407
+ getTracker: () => tracker,
408
+ getTasks: () => tasks,
409
+ getTeamConfig: () => teamConfig,
410
+ getStyle: () => style,
411
+ isDelegateMode: () => delegateMode,
412
+ getActiveTeamId: () => currentTeamId,
413
+ getSessionTeamId: () => currentCtx?.sessionManager.getSessionId() ?? null,
414
+ });
415
+
416
+ const refreshTasks = async () => {
417
+ if (!currentCtx || !currentTeamId) return;
418
+ const teamDir = getTeamDir(currentTeamId);
419
+ const effectiveTaskListId = taskListId ?? currentTeamId;
420
+
421
+ const [nextTasks, cfg] = await Promise.all([listTasks(teamDir, effectiveTaskListId), loadTeamConfig(teamDir)]);
422
+ tasks = nextTasks;
423
+ teamConfig =
424
+ cfg ??
425
+ (await ensureTeamConfig(teamDir, {
426
+ teamId: currentTeamId,
427
+ taskListId: effectiveTaskListId,
428
+ leadName: "team-lead",
429
+ style,
430
+ }));
431
+ style = teamConfig.style ?? style;
432
+ };
433
+
434
+ let widgetSuppressed = false;
435
+
436
+ const renderWidget = () => {
437
+ if (!currentCtx || widgetSuppressed) return;
438
+ // Component widget (more informative + styled). Re-setting it is also our "refresh" trigger.
439
+ currentCtx.ui.setWidget("pi-teams", widgetFactory);
440
+ };
441
+
442
+ const spawnTeammate: SpawnTeammateFn = async (ctx, opts): Promise<SpawnTeammateResult> => {
443
+ const warnings: string[] = [];
444
+ const mode: ContextMode = opts.mode ?? "fresh";
445
+ let workspaceMode: WorkspaceMode = opts.workspaceMode ?? "shared";
446
+
447
+ const name = sanitizeName(opts.name);
448
+ if (!name) return { ok: false, error: "Missing comrade name" };
449
+ if (teammates.has(name)) {
450
+ const strings = getTeamsStrings(style);
451
+ return { ok: false, error: `${formatMemberDisplayName(style, name)} already exists (${strings.teamNoun})` };
452
+ }
453
+
454
+ // Spawn-time model / thinking overrides (optional).
455
+ const thinkingLevel = opts.thinking ?? pi.getThinkingLevel();
456
+
457
+ const modelResolution = resolveTeammateModelSelection({
458
+ modelOverride: opts.model,
459
+ leaderProvider: ctx.model?.provider,
460
+ leaderModelId: ctx.model?.id,
461
+ });
462
+ if (!modelResolution.ok) return { ok: false, error: modelResolution.error };
463
+ const { provider: childProvider, modelId: childModelId, warnings: modelWarnings } = modelResolution.value;
464
+ warnings.push(...modelWarnings);
465
+
466
+ const teamId = currentTeamId ?? ctx.sessionManager.getSessionId();
467
+ const teamDir = getTeamDir(teamId);
468
+ const teamSessionsDir = getTeamSessionsDir(teamDir);
469
+ const session = await createSessionForTeammate(ctx, mode, teamSessionsDir);
470
+ const { sessionFile, note } = session;
471
+ warnings.push(...session.warnings);
472
+
473
+ const t = new TeammateRpc(name, sessionFile);
474
+ teammates.set(name, t);
475
+ // Track teammate activity for the widget/panel.
476
+ const unsub = t.onEvent((ev) => {
477
+ tracker.handleEvent(name, ev);
478
+ transcriptTracker.handleEvent(name, ev);
479
+ });
480
+ teammateEventUnsubs.set(name, unsub);
481
+ renderWidget();
482
+
483
+ // On crash/close, unassign tasks like Claude.
484
+ const leaderTeamId = teamId;
485
+ t.onClose((code) => {
486
+ try {
487
+ teammateEventUnsubs.get(name)?.();
488
+ } catch {
489
+ // ignore
490
+ }
491
+ teammateEventUnsubs.delete(name);
492
+ tracker.reset(name);
493
+ transcriptTracker.reset(name);
494
+
495
+ if (currentTeamId !== leaderTeamId) return;
496
+ const effectiveTlId = taskListId ?? leaderTeamId;
497
+ void unassignTasksForAgent(
498
+ teamDir,
499
+ effectiveTlId,
500
+ name,
501
+ `${formatMemberDisplayName(style, name)} ${getTeamsStrings(style).leftVerb}`,
502
+ ).finally(() => {
503
+ void refreshTasks().finally(renderWidget);
504
+ });
505
+ void setMemberStatus(teamDir, name, "offline", { meta: { exitCode: code ?? undefined } });
506
+ });
507
+
508
+ const builtInToolSet = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
509
+ const tools = (pi.getActiveTools() ?? []).filter((t) => builtInToolSet.has(t));
510
+ const argsForChild: string[] = [];
511
+ if (sessionFile) argsForChild.push("--session", sessionFile);
512
+ argsForChild.push("--session-dir", teamSessionsDir);
513
+ if (tools.length) argsForChild.push("--tools", tools.join(","));
514
+
515
+ // Model + thinking for the child process.
516
+ if (childModelId) {
517
+ if (childProvider) argsForChild.push("--provider", childProvider);
518
+ argsForChild.push("--model", childModelId);
519
+ }
520
+ argsForChild.push("--thinking", thinkingLevel);
521
+
522
+ const teamsEntry = getTeamsExtensionEntryPath();
523
+ if (teamsEntry) {
524
+ argsForChild.push("--no-extensions", "-e", teamsEntry);
525
+ }
526
+
527
+ const strings = getTeamsStrings(style);
528
+ const systemAppend = `You are ${strings.memberTitle.toLowerCase()} '${name}'. You collaborate with the ${strings.leaderTitle.toLowerCase()}. Prefer working from the shared task list.\n`;
529
+ argsForChild.push("--append-system-prompt", systemAppend);
530
+
531
+ const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1";
532
+
533
+ let childCwd = ctx.cwd;
534
+ if (workspaceMode === "worktree") {
535
+ const res = await ensureWorktreeCwd({ leaderCwd: ctx.cwd, teamDir, teamId, agentName: name });
536
+ childCwd = res.cwd;
537
+ workspaceMode = res.mode;
538
+ warnings.push(...res.warnings);
539
+ }
540
+
541
+ try {
542
+ await t.start({
543
+ cwd: childCwd,
544
+ env: {
545
+ PI_TEAMS_WORKER: "1",
546
+ PI_TEAMS_TEAM_ID: teamId,
547
+ PI_TEAMS_TASK_LIST_ID: taskListId ?? teamId,
548
+ PI_TEAMS_AGENT_NAME: name,
549
+ PI_TEAMS_LEAD_NAME: "team-lead",
550
+ PI_TEAMS_STYLE: style,
551
+ PI_TEAMS_AUTO_CLAIM: autoClaim ? "1" : "0",
552
+ ...(opts.planRequired ? { PI_TEAMS_PLAN_REQUIRED: "1" } : {}),
553
+ },
554
+ args: argsForChild,
555
+ });
556
+ } catch (err) {
557
+ teammates.delete(name);
558
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
559
+ }
560
+
561
+ const sessionName = `pi agent teams - ${strings.memberTitle.toLowerCase()} ${name}`;
562
+
563
+ // Leader-driven session naming (so teammates are easy to spot in /resume).
564
+ try {
565
+ await t.setSessionName(sessionName);
566
+ } catch (err) {
567
+ warnings.push(`Failed to set session name for ${name}: ${err instanceof Error ? err.message : String(err)}`);
568
+ }
569
+
570
+ // Also send via mailbox so non-RPC/manual workers can be named the same way.
571
+ try {
572
+ const ts = new Date().toISOString();
573
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
574
+ from: "team-lead",
575
+ text: JSON.stringify({ type: "set_session_name", name: sessionName, from: "team-lead", timestamp: ts }),
576
+ timestamp: ts,
577
+ });
578
+ } catch {
579
+ // ignore
580
+ }
581
+
582
+ await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead", style });
583
+ const childModel = formatProviderModel(childProvider, childModelId);
584
+ await upsertMember(teamDir, {
585
+ name,
586
+ role: "worker",
587
+ status: "online",
588
+ cwd: childCwd,
589
+ sessionFile,
590
+ meta: {
591
+ workspaceMode,
592
+ sessionName,
593
+ thinkingLevel,
594
+ ...(childModel ? { model: childModel } : {}),
595
+ },
596
+ });
597
+
598
+ await refreshTasks();
599
+ renderWidget();
600
+
601
+ return { ok: true, name, mode, workspaceMode, childCwd, note, warnings };
602
+ };
603
+
604
+ const pollLeaderInbox = async () => {
605
+ if (!currentCtx || !currentTeamId) return;
606
+ const teamDir = getTeamDir(currentTeamId);
607
+ const effectiveTaskListId = taskListId ?? currentTeamId;
608
+ await pollLeaderInboxImpl({
609
+ ctx: currentCtx,
610
+ teamId: currentTeamId,
611
+ teamDir,
612
+ taskListId: effectiveTaskListId,
613
+ leadName: teamConfig?.leadName ?? "team-lead",
614
+ style,
615
+ pendingPlanApprovals,
616
+ enqueueHook,
617
+ });
618
+ };
619
+
620
+ pi.on("tool_call", (event, _ctx) => {
621
+ if (!delegateMode) return;
622
+ const blockedTools = new Set(["bash", "edit", "write"]);
623
+ if (blockedTools.has(event.toolName)) {
624
+ return { block: true, reason: "Delegate mode is active - use comrades for implementation." };
625
+ }
626
+ });
627
+
628
+ pi.on("session_start", async (_event, ctx) => {
629
+ currentCtx = ctx;
630
+ currentTeamId = currentCtx.sessionManager.getSessionId();
631
+ // Keep the task list aligned with the active session. If you want a shared namespace,
632
+ // use `/team task use <taskListId>` after switching.
633
+ taskListId = currentTeamId;
634
+ lastAttachClaimHeartbeatMs = 0;
635
+
636
+ // Claude-style: a persisted team config file.
637
+ await ensureTeamConfig(getTeamDir(currentTeamId), {
638
+ teamId: currentTeamId,
639
+ taskListId: taskListId,
640
+ leadName: "team-lead",
641
+ style,
642
+ });
643
+
644
+ await refreshTasks();
645
+ renderWidget();
646
+
647
+ stopLoops();
648
+ refreshTimer = setInterval(async () => {
649
+ if (isStopping) return;
650
+ if (refreshInFlight) return;
651
+ refreshInFlight = true;
652
+ try {
653
+ await heartbeatActiveAttachClaim(ctx);
654
+ await refreshTasks();
655
+ renderWidget();
656
+ } finally {
657
+ refreshInFlight = false;
658
+ }
659
+ }, 1000);
660
+
661
+ inboxTimer = setInterval(async () => {
662
+ if (isStopping) return;
663
+ if (inboxInFlight) return;
664
+ inboxInFlight = true;
665
+ try {
666
+ await pollLeaderInbox();
667
+ } finally {
668
+ inboxInFlight = false;
669
+ }
670
+ }, 700);
671
+ });
672
+
673
+ pi.on("session_switch", async (_event, ctx) => {
674
+ if (currentCtx) {
675
+ await releaseActiveAttachClaim(currentCtx);
676
+ const strings = getTeamsStrings(style);
677
+ await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is dissolved — leader moved on`);
678
+ }
679
+ stopLoops();
680
+
681
+ currentCtx = ctx;
682
+ currentTeamId = currentCtx.sessionManager.getSessionId();
683
+ // Keep the task list aligned with the active session. If you want a shared namespace,
684
+ // use `/team task use <taskListId>` after switching.
685
+ taskListId = currentTeamId;
686
+ lastAttachClaimHeartbeatMs = 0;
687
+
688
+ await ensureTeamConfig(getTeamDir(currentTeamId), {
689
+ teamId: currentTeamId,
690
+ taskListId: taskListId,
691
+ leadName: "team-lead",
692
+ style,
693
+ });
694
+
695
+ await refreshTasks();
696
+ renderWidget();
697
+
698
+ // Restart background refresh/poll loops for the new session.
699
+ refreshTimer = setInterval(async () => {
700
+ if (isStopping) return;
701
+ if (refreshInFlight) return;
702
+ refreshInFlight = true;
703
+ try {
704
+ await heartbeatActiveAttachClaim(ctx);
705
+ await refreshTasks();
706
+ renderWidget();
707
+ } finally {
708
+ refreshInFlight = false;
709
+ }
710
+ }, 1000);
711
+
712
+ inboxTimer = setInterval(async () => {
713
+ if (isStopping) return;
714
+ if (inboxInFlight) return;
715
+ inboxInFlight = true;
716
+ try {
717
+ await pollLeaderInbox();
718
+ } finally {
719
+ inboxInFlight = false;
720
+ }
721
+ }, 700);
722
+ });
723
+
724
+ pi.on("session_shutdown", async () => {
725
+ if (!currentCtx) return;
726
+ await releaseActiveAttachClaim(currentCtx);
727
+ stopLoops();
728
+ const strings = getTeamsStrings(style);
729
+ await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
730
+ });
731
+
732
+ registerTeamsTool({
733
+ pi,
734
+ teammates,
735
+ spawnTeammate,
736
+ getTeamId: (ctx) => currentTeamId ?? ctx.sessionManager.getSessionId(),
737
+ getTaskListId: () => taskListId,
738
+ refreshTasks,
739
+ renderWidget,
740
+ pendingPlanApprovals,
741
+ });
742
+
743
+ const openWidget = async (ctx: ExtensionCommandContext) => {
744
+ const teamId = currentTeamId ?? ctx.sessionManager.getSessionId();
745
+ const teamDir = getTeamDir(teamId);
746
+ const effectiveTlId = taskListId ?? teamId;
747
+ const leadName = teamConfig?.leadName ?? "team-lead";
748
+ const strings = getTeamsStrings(style);
749
+
750
+ await openInteractiveWidget(ctx, {
751
+ getTeammates: () => teammates,
752
+ getTracker: () => tracker,
753
+ getTranscript: (n: string) => transcriptTracker.get(n),
754
+ getTasks: () => tasks,
755
+ getTeamConfig: () => teamConfig,
756
+ getStyle: () => style,
757
+ isDelegateMode: () => delegateMode,
758
+ async sendMessage(name: string, message: string) {
759
+ const rpc = teammates.get(name);
760
+ if (rpc) {
761
+ if (rpc.status === "streaming") await rpc.followUp(message);
762
+ else await rpc.prompt(message);
763
+ return;
764
+ }
765
+
766
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
767
+ from: leadName,
768
+ text: message,
769
+ timestamp: new Date().toISOString(),
770
+ });
771
+ },
772
+ abortMember(name: string) {
773
+ const rpc = teammates.get(name);
774
+ if (rpc) void rpc.abort();
775
+ },
776
+ killMember(name: string) {
777
+ const rpc = teammates.get(name);
778
+ if (!rpc) return;
779
+
780
+ void rpc.stop();
781
+ teammates.delete(name);
782
+
783
+ const displayName = formatMemberDisplayName(style, name);
784
+ void unassignTasksForAgent(teamDir, effectiveTlId, name, `${displayName} ${strings.killedVerb}`);
785
+ void setMemberStatus(teamDir, name, "offline", { meta: { killedAt: new Date().toISOString() } });
786
+ void refreshTasks();
787
+ },
788
+ async setTaskStatus(taskId: string, status: TeamTask["status"]) {
789
+ const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
790
+ if (cur.status === status) return cur;
791
+ const metadata = { ...(cur.metadata ?? {}) };
792
+ if (status === "completed") metadata.completedAt = new Date().toISOString();
793
+ if (status !== "completed" && cur.status === "completed") metadata.reopenedAt = new Date().toISOString();
794
+ return { ...cur, status, metadata };
795
+ });
796
+ if (!updated) return false;
797
+ await refreshTasks();
798
+ renderWidget();
799
+ return true;
800
+ },
801
+ async unassignTask(taskId: string) {
802
+ const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
803
+ if (!cur.owner) return cur;
804
+ if (cur.status === "completed") return { ...cur, owner: undefined };
805
+ const metadata = { ...(cur.metadata ?? {}) };
806
+ metadata.unassignedAt = new Date().toISOString();
807
+ metadata.unassignedReason = "leader-panel";
808
+ return { ...cur, owner: undefined, status: "pending", metadata };
809
+ });
810
+ if (!updated) return false;
811
+ await refreshTasks();
812
+ renderWidget();
813
+ return true;
814
+ },
815
+ async assignTask(taskId: string, ownerName: string) {
816
+ const owner = sanitizeName(ownerName);
817
+ if (!owner) return false;
818
+ const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
819
+ const metadata = { ...(cur.metadata ?? {}) };
820
+ metadata.reassignedAt = new Date().toISOString();
821
+ metadata.reassignedBy = leadName;
822
+ metadata.reassignedTo = owner;
823
+ if (cur.status === "completed") return { ...cur, owner, metadata };
824
+ return { ...cur, owner, status: "pending", metadata };
825
+ });
826
+ if (!updated) return false;
827
+
828
+ await writeToMailbox(teamDir, effectiveTlId, owner, {
829
+ from: leadName,
830
+ text: JSON.stringify(taskAssignmentPayload(updated, leadName)),
831
+ timestamp: new Date().toISOString(),
832
+ });
833
+
834
+ await refreshTasks();
835
+ renderWidget();
836
+ return true;
837
+ },
838
+ getActiveTeamId() {
839
+ return currentTeamId;
840
+ },
841
+ getSessionTeamId() {
842
+ return ctx.sessionManager.getSessionId();
843
+ },
844
+ suppressWidget() {
845
+ widgetSuppressed = true;
846
+ ctx.ui.setWidget("pi-teams", undefined);
847
+ },
848
+ restoreWidget() {
849
+ widgetSuppressed = false;
850
+ renderWidget();
851
+ },
852
+ });
853
+ };
854
+
855
+ pi.registerCommand("tw", {
856
+ description: "Teams: open interactive widget panel",
857
+ handler: async (_args, ctx) => {
858
+ currentCtx = ctx;
859
+ if (!currentTeamId) currentTeamId = ctx.sessionManager.getSessionId();
860
+ await openWidget(ctx);
861
+ },
862
+ });
863
+
864
+ pi.registerCommand("team-widget", {
865
+ description: "Teams: open interactive widget panel (alias for /team widget)",
866
+ handler: async (_args, ctx) => {
867
+ currentCtx = ctx;
868
+ if (!currentTeamId) currentTeamId = ctx.sessionManager.getSessionId();
869
+ await openWidget(ctx);
870
+ },
871
+ });
872
+
873
+ pi.registerCommand("swarm", {
874
+ description: "Start a team of agents to work on a task",
875
+ handler: async (args, _ctx) => {
876
+ const task = args.trim();
877
+ if (!task) {
878
+ 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.");
879
+ return;
880
+ }
881
+ pi.sendUserMessage(`Use your /team commands to spawn a team of agents and coordinate them to complete this task:\n\n${task}`);
882
+ },
883
+ });
884
+
885
+ pi.registerCommand("team", {
886
+ description: "Teams: spawn comrades + coordinate via Claude-like task list",
887
+ handler: async (args, ctx) => {
888
+ currentCtx = ctx;
889
+ if (!currentTeamId) currentTeamId = ctx.sessionManager.getSessionId();
890
+
891
+ await handleTeamCommand({
892
+ args,
893
+ ctx,
894
+ teammates,
895
+ getTeamConfig: () => teamConfig,
896
+ getTasks: () => tasks,
897
+ refreshTasks,
898
+ renderWidget,
899
+ getTaskListId: () => taskListId,
900
+ setTaskListId: (id) => {
901
+ taskListId = id;
902
+ },
903
+ getActiveTeamId: () => currentTeamId ?? ctx.sessionManager.getSessionId(),
904
+ setActiveTeamId: (teamId) => {
905
+ currentTeamId = teamId;
906
+ },
907
+ pendingPlanApprovals,
908
+ getDelegateMode: () => delegateMode,
909
+ setDelegateMode: (next) => {
910
+ delegateMode = next;
911
+ },
912
+ getStyle: () => style,
913
+ setStyle: (next) => {
914
+ style = next;
915
+ },
916
+ spawnTeammate,
917
+ openWidget,
918
+ getTeamsExtensionEntryPath,
919
+ shellQuote,
920
+ getCurrentCtx: () => currentCtx,
921
+ stopAllTeammates,
922
+ });
923
+ },
924
+ });
925
+ }