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,322 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { sanitizeName, COMRADE_NAME_POOL, PIRATE_NAME_POOL } from "./names.js";
4
+ import { getTeamsStylesDir } from "./paths.js";
5
+
6
+ /**
7
+ * Teams UI style id.
8
+ *
9
+ * Built-in styles ship with the extension. Users can add custom styles by
10
+ * creating JSON files under `${getTeamsStylesDir()}`.
11
+ */
12
+ export type TeamsStyle = string;
13
+
14
+ export const TEAMS_STYLES = ["normal", "soviet", "pirate"] as const;
15
+ export type BuiltinTeamsStyle = (typeof TEAMS_STYLES)[number];
16
+
17
+ export type TeamsStrings = {
18
+ leaderTitle: string;
19
+ leaderControlTitle: string;
20
+ memberTitle: string;
21
+ memberPrefix: string;
22
+ teamNoun: string;
23
+ joinedVerb: string;
24
+ leftVerb: string;
25
+ killedVerb: string;
26
+
27
+ // Lifecycle copy (all shown as "<member> <verb...>")
28
+ shutdownRequestedVerb: string;
29
+ shutdownCompletedVerb: string;
30
+ shutdownRefusedVerb: string;
31
+ abortRequestedVerb: string;
32
+
33
+ // Templates (supports {members} and/or {count})
34
+ noMembersToShutdown: string;
35
+ shutdownAllPrompt: string;
36
+ teamEndedAllStopped: string;
37
+ };
38
+
39
+ export type TeamsAutoNameStrategy =
40
+ | { kind: "agent" }
41
+ | { kind: "pool"; pool: readonly string[]; fallbackBase: string };
42
+
43
+ export type TeamsNamingRules = {
44
+ /** If true, `/team spawn` requires an explicit name. */
45
+ requireExplicitSpawnName: boolean;
46
+ /** Default naming strategy for auto-spawn (e.g. tool-driven). */
47
+ autoNameStrategy: TeamsAutoNameStrategy;
48
+ };
49
+
50
+ export type TeamsStyleDefinition = {
51
+ id: TeamsStyle;
52
+ strings: TeamsStrings;
53
+ naming: TeamsNamingRules;
54
+ };
55
+
56
+ function isRecord(v: unknown): v is Record<string, unknown> {
57
+ return typeof v === "object" && v !== null;
58
+ }
59
+
60
+ export function formatTeamsTemplate(template: string, vars: Record<string, string>): string {
61
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, key: string) => vars[key] ?? "");
62
+ }
63
+
64
+ export function normalizeTeamsStyleId(raw: unknown): TeamsStyle | null {
65
+ if (typeof raw !== "string") return null;
66
+ const trimmed = raw.trim();
67
+ if (!trimmed) return null;
68
+ const sanitized = sanitizeName(trimmed).toLowerCase();
69
+ return sanitized ? sanitized : null;
70
+ }
71
+
72
+ export function getTeamsStyleFromEnv(env: NodeJS.ProcessEnv = process.env): TeamsStyle {
73
+ return normalizeTeamsStyleId(env.PI_TEAMS_STYLE) ?? "normal";
74
+ }
75
+
76
+ function builtinStyle(id: BuiltinTeamsStyle): TeamsStyleDefinition {
77
+ if (id === "soviet") {
78
+ return {
79
+ id,
80
+ strings: {
81
+ leaderTitle: "Chairman",
82
+ leaderControlTitle: "Chairman (control)",
83
+ memberTitle: "Comrade",
84
+ memberPrefix: "Comrade ",
85
+ teamNoun: "Party",
86
+ joinedVerb: "has joined the Party",
87
+ leftVerb: "has left the Party",
88
+ killedVerb: "sent to the gulag",
89
+
90
+ shutdownRequestedVerb: "was asked to stand down",
91
+ shutdownCompletedVerb: "stood down",
92
+ shutdownRefusedVerb: "refused to comply",
93
+ abortRequestedVerb: "was ordered to stop",
94
+
95
+ noMembersToShutdown: "No {members} to shut down",
96
+ shutdownAllPrompt: "Stop all {count} {members}?",
97
+ teamEndedAllStopped: "Team ended: all {members} stopped (leader session remains active)",
98
+ },
99
+ naming: {
100
+ requireExplicitSpawnName: false,
101
+ autoNameStrategy: { kind: "pool", pool: COMRADE_NAME_POOL, fallbackBase: "comrade" },
102
+ },
103
+ };
104
+ }
105
+ if (id === "pirate") {
106
+ return {
107
+ id,
108
+ strings: {
109
+ leaderTitle: "Captain",
110
+ leaderControlTitle: "Captain (control)",
111
+ memberTitle: "Matey",
112
+ memberPrefix: "Matey ",
113
+ teamNoun: "crew",
114
+ joinedVerb: "joined the crew",
115
+ leftVerb: "abandoned ship",
116
+ killedVerb: "walked the plank",
117
+
118
+ shutdownRequestedVerb: "was ordered to strike the colors",
119
+ shutdownCompletedVerb: "struck their colors",
120
+ shutdownRefusedVerb: "defied the captain",
121
+ abortRequestedVerb: "was ordered to belay that",
122
+
123
+ noMembersToShutdown: "No {members} to send below decks",
124
+ shutdownAllPrompt: "Dismiss all {count} {members}?",
125
+ teamEndedAllStopped: "Crew dismissed: all {members} struck their colors (leader session remains active)",
126
+ },
127
+ naming: {
128
+ requireExplicitSpawnName: false,
129
+ autoNameStrategy: { kind: "pool", pool: PIRATE_NAME_POOL, fallbackBase: "matey" },
130
+ },
131
+ };
132
+ }
133
+
134
+ // normal
135
+ return {
136
+ id,
137
+ strings: {
138
+ leaderTitle: "Team leader",
139
+ leaderControlTitle: "Leader (control)",
140
+ memberTitle: "Teammate",
141
+ memberPrefix: "Teammate ",
142
+ teamNoun: "team",
143
+ joinedVerb: "joined the team",
144
+ leftVerb: "left the team",
145
+ killedVerb: "stopped",
146
+
147
+ shutdownRequestedVerb: "was asked to shut down",
148
+ shutdownCompletedVerb: "shut down",
149
+ shutdownRefusedVerb: "refused to shut down",
150
+ abortRequestedVerb: "was asked to stop",
151
+
152
+ noMembersToShutdown: "No {members} to shut down",
153
+ shutdownAllPrompt: "Stop all {count} {members}?",
154
+ teamEndedAllStopped: "Team ended: all {members} stopped (leader session remains active)",
155
+ },
156
+ naming: {
157
+ requireExplicitSpawnName: true,
158
+ autoNameStrategy: { kind: "agent" },
159
+ },
160
+ };
161
+ }
162
+
163
+ const BUILTINS: Record<BuiltinTeamsStyle, TeamsStyleDefinition> = {
164
+ normal: builtinStyle("normal"),
165
+ soviet: builtinStyle("soviet"),
166
+ pirate: builtinStyle("pirate"),
167
+ };
168
+
169
+ function isBuiltinStyleId(id: TeamsStyle): id is BuiltinTeamsStyle {
170
+ return (TEAMS_STYLES as readonly string[]).includes(id);
171
+ }
172
+
173
+ function getTeamsStyleConfigPath(styleId: TeamsStyle): string {
174
+ return path.join(getTeamsStylesDir(), `${styleId}.json`);
175
+ }
176
+
177
+ type CustomCacheEntry =
178
+ | { kind: "def"; def: TeamsStyleDefinition }
179
+ | { kind: "error"; error: string }
180
+ | { kind: "missing" };
181
+
182
+ const customCache = new Map<string, CustomCacheEntry>();
183
+
184
+ function coerceStringsPartial(obj: unknown): Partial<TeamsStrings> {
185
+ if (!isRecord(obj)) return {};
186
+ const out: Partial<TeamsStrings> = {};
187
+ const keys: Array<keyof TeamsStrings> = [
188
+ "leaderTitle",
189
+ "leaderControlTitle",
190
+ "memberTitle",
191
+ "memberPrefix",
192
+ "teamNoun",
193
+ "joinedVerb",
194
+ "leftVerb",
195
+ "killedVerb",
196
+ "shutdownRequestedVerb",
197
+ "shutdownCompletedVerb",
198
+ "shutdownRefusedVerb",
199
+ "abortRequestedVerb",
200
+ "noMembersToShutdown",
201
+ "shutdownAllPrompt",
202
+ "teamEndedAllStopped",
203
+ ];
204
+ for (const k of keys) {
205
+ const v = obj[k];
206
+ if (typeof v === "string") out[k] = v;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ function coerceAutoNameStrategy(obj: unknown): TeamsAutoNameStrategy | null {
212
+ if (!isRecord(obj)) return null;
213
+ const kind = obj.kind;
214
+ if (kind === "agent") return { kind: "agent" };
215
+ if (kind === "pool") {
216
+ const poolRaw = obj.pool;
217
+ if (!Array.isArray(poolRaw)) return null;
218
+ const pool = poolRaw
219
+ .filter((v): v is string => typeof v === "string")
220
+ .map((s) => sanitizeName(s.trim()).toLowerCase())
221
+ .filter((s) => s.length > 0);
222
+ const fallbackBase =
223
+ typeof obj.fallbackBase === "string"
224
+ ? sanitizeName(obj.fallbackBase.trim()).toLowerCase() || "member"
225
+ : "member";
226
+ return { kind: "pool", pool, fallbackBase };
227
+ }
228
+ return null;
229
+ }
230
+
231
+ function readCustomStyleDefinition(styleId: TeamsStyle): CustomCacheEntry {
232
+ const file = getTeamsStyleConfigPath(styleId);
233
+ try {
234
+ if (!fs.existsSync(file)) return { kind: "missing" };
235
+ const raw = fs.readFileSync(file, "utf8");
236
+ const parsed: unknown = JSON.parse(raw);
237
+ if (!isRecord(parsed)) return { kind: "error", error: `Style file is not an object: ${file}` };
238
+
239
+ // Optional: base/extends
240
+ const extendsRaw = normalizeTeamsStyleId(parsed.extends);
241
+ const base: TeamsStyleDefinition =
242
+ extendsRaw && isBuiltinStyleId(extendsRaw) ? BUILTINS[extendsRaw] : BUILTINS.normal;
243
+
244
+ const stringsPatch = coerceStringsPartial(parsed.strings);
245
+ const namingObj = parsed.naming;
246
+ const requireExplicitSpawnName =
247
+ isRecord(namingObj) && typeof namingObj.requireExplicitSpawnName === "boolean"
248
+ ? namingObj.requireExplicitSpawnName
249
+ : base.naming.requireExplicitSpawnName;
250
+ const autoNameStrategy =
251
+ isRecord(namingObj) && "autoNameStrategy" in namingObj
252
+ ? coerceAutoNameStrategy((namingObj as Record<string, unknown>).autoNameStrategy) ?? base.naming.autoNameStrategy
253
+ : base.naming.autoNameStrategy;
254
+
255
+ const def: TeamsStyleDefinition = {
256
+ id: styleId,
257
+ strings: { ...base.strings, ...stringsPatch },
258
+ naming: { requireExplicitSpawnName, autoNameStrategy },
259
+ };
260
+ return { kind: "def", def };
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ return { kind: "error", error: `Failed to load style '${styleId}' from ${file}: ${msg}` };
264
+ }
265
+ }
266
+
267
+ export function resolveTeamsStyleDefinition(styleIdRaw: TeamsStyle, opts?: { strict?: boolean }): TeamsStyleDefinition {
268
+ const strict = opts?.strict === true;
269
+ const styleId = normalizeTeamsStyleId(styleIdRaw) ?? "normal";
270
+
271
+ if (isBuiltinStyleId(styleId)) return BUILTINS[styleId];
272
+
273
+ const cached = customCache.get(styleId);
274
+ const entry = cached ?? readCustomStyleDefinition(styleId);
275
+ if (!cached) customCache.set(styleId, entry);
276
+
277
+ if (entry.kind === "def") return entry.def;
278
+ if (strict) {
279
+ if (entry.kind === "missing") {
280
+ throw new Error(
281
+ `Unknown teams style: ${styleId}. Create ${getTeamsStyleConfigPath(styleId)} or use one of: ${TEAMS_STYLES.join(", ")}`,
282
+ );
283
+ }
284
+ throw new Error(entry.error);
285
+ }
286
+
287
+ return BUILTINS.normal;
288
+ }
289
+
290
+ export function getTeamsStrings(style: TeamsStyle): TeamsStrings {
291
+ return resolveTeamsStyleDefinition(style).strings;
292
+ }
293
+
294
+ export function getTeamsNamingRules(style: TeamsStyle): TeamsNamingRules {
295
+ return resolveTeamsStyleDefinition(style).naming;
296
+ }
297
+
298
+ export function listAvailableTeamsStyles(): { dir: string; builtins: readonly string[]; customs: string[]; all: string[] } {
299
+ const dir = getTeamsStylesDir();
300
+ let customs: string[] = [];
301
+ try {
302
+ if (fs.existsSync(dir)) {
303
+ customs = fs
304
+ .readdirSync(dir)
305
+ .filter((f) => f.endsWith(".json"))
306
+ .map((f) => f.slice(0, -".json".length))
307
+ .map((id) => normalizeTeamsStyleId(id))
308
+ .filter((id): id is string => id !== null);
309
+ }
310
+ } catch {
311
+ customs = [];
312
+ }
313
+
314
+ const builtins = TEAMS_STYLES as readonly string[];
315
+ const all = Array.from(new Set([...builtins, ...customs])).sort();
316
+ return { dir, builtins, customs, all };
317
+ }
318
+
319
+ export function formatMemberDisplayName(style: TeamsStyle, name: string): string {
320
+ const s = getTeamsStrings(style);
321
+ return s.memberPrefix ? `${s.memberPrefix}${name}` : name;
322
+ }
@@ -0,0 +1,89 @@
1
+ import type { ThemeColor } from "indusagi-coding-agent";
2
+ import { visibleWidth } from "indusagi/tui";
3
+ import type { TeamConfig, TeamMember } from "./team-config.js";
4
+ import type { TeamTask } from "./task-store.js";
5
+ import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
6
+
7
+ // Status icon and color mapping (shared by widget + interactive panel)
8
+ export const STATUS_ICON: Record<TeammateStatus, string> = {
9
+ streaming: "\u25c9",
10
+ idle: "\u25cf",
11
+ starting: "\u25cb",
12
+ stopped: "\u2717",
13
+ error: "\u2717",
14
+ };
15
+
16
+ export const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
17
+ streaming: "accent",
18
+ idle: "success",
19
+ starting: "muted",
20
+ stopped: "dim",
21
+ error: "error",
22
+ };
23
+
24
+ export const TOOL_VERB: Record<string, string> = {
25
+ read: "reading\u2026",
26
+ edit: "editing\u2026",
27
+ write: "writing\u2026",
28
+ grep: "searching\u2026",
29
+ glob: "finding files\u2026",
30
+ bash: "running\u2026",
31
+ task: "delegating\u2026",
32
+ webfetch: "fetching\u2026",
33
+ websearch: "searching web\u2026",
34
+ };
35
+
36
+ export function toolVerb(toolName: string): string {
37
+ const key = toolName.toLowerCase();
38
+ return TOOL_VERB[key] ?? `${toolName}\u2026`;
39
+ }
40
+
41
+ export function toolActivity(toolName: string | null): string {
42
+ if (!toolName) return "";
43
+ return toolVerb(toolName);
44
+ }
45
+
46
+ export function padRight(str: string, targetWidth: number): string {
47
+ const w = visibleWidth(str);
48
+ return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
49
+ }
50
+
51
+ export function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
52
+ if (rpc) return rpc.status;
53
+ return cfg?.status === "online" ? "idle" : "stopped";
54
+ }
55
+
56
+ export function formatTokens(n: number): string {
57
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
58
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
59
+ return String(n);
60
+ }
61
+
62
+ /**
63
+ * Compute the set of worker names that should be visible in the UI.
64
+ *
65
+ * Rule: show any worker that is:
66
+ * - currently spawned/known as a teammate RPC
67
+ * - online in team config
68
+ * - owning an in-progress task (even if RPC is disconnected)
69
+ */
70
+ export function getVisibleWorkerNames(opts: {
71
+ teammates: ReadonlyMap<string, TeammateRpc>;
72
+ teamConfig: TeamConfig | null;
73
+ tasks: readonly TeamTask[];
74
+ }): string[] {
75
+ const { teammates, teamConfig, tasks } = opts;
76
+ const leadName = teamConfig?.leadName;
77
+ const cfgMembers = teamConfig?.members ?? [];
78
+
79
+ const names = new Set<string>();
80
+ for (const name of teammates.keys()) names.add(name);
81
+ for (const m of cfgMembers) {
82
+ if (m.role === "worker" && m.status === "online") names.add(m.name);
83
+ }
84
+ for (const t of tasks) {
85
+ if (t.owner && t.owner !== leadName && t.status === "in_progress") names.add(t.owner);
86
+ }
87
+
88
+ return Array.from(names).sort();
89
+ }
@@ -0,0 +1,212 @@
1
+ import { truncateToWidth, visibleWidth } from "indusagi/tui";
2
+ import type { Component, TUI } from "indusagi/tui";
3
+ import type { Theme, ThemeColor } from "indusagi-coding-agent";
4
+ import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
5
+ import type { ActivityTracker } from "./activity-tracker.js";
6
+ import type { TeamTask } from "./task-store.js";
7
+ import type { TeamConfig, TeamMember } from "./team-config.js";
8
+ import type { TeamsStyle } from "./teams-style.js";
9
+ import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
10
+ import {
11
+ STATUS_COLOR,
12
+ STATUS_ICON,
13
+ formatTokens,
14
+ getVisibleWorkerNames,
15
+ padRight,
16
+ resolveStatus,
17
+ toolActivity,
18
+ } from "./teams-ui-shared.js";
19
+
20
+ export interface WidgetDeps {
21
+ getTeammates(): Map<string, TeammateRpc>;
22
+ getTracker(): ActivityTracker;
23
+ getTasks(): TeamTask[];
24
+ getTeamConfig(): TeamConfig | null;
25
+ getStyle(): TeamsStyle;
26
+ isDelegateMode(): boolean;
27
+ getActiveTeamId(): string | null;
28
+ getSessionTeamId(): string | null;
29
+ }
30
+
31
+ export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
32
+
33
+ interface WidgetRow {
34
+ icon: string; // raw char (before styling)
35
+ iconColor: ThemeColor;
36
+ displayName: string;
37
+ statusKey: TeammateStatus;
38
+ pending: number;
39
+ completed: number;
40
+ tokensStr: string; // "—" for chairman
41
+ activityText: string;
42
+ }
43
+
44
+ function shortTeamId(teamId: string): string {
45
+ return teamId.length <= 12 ? teamId : `${teamId.slice(0, 8)}…`;
46
+ }
47
+
48
+ function hasQualityGateFailure(task: TeamTask): boolean {
49
+ return task.metadata?.["qualityGateStatus"] === "failed";
50
+ }
51
+
52
+ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
53
+ return (_tui: TUI, theme: Theme): Component => {
54
+ return {
55
+ render(width: number): string[] {
56
+ const teammates = deps.getTeammates();
57
+ const tracker = deps.getTracker();
58
+ const tasks = deps.getTasks();
59
+ const teamConfig = deps.getTeamConfig();
60
+ const style = deps.getStyle();
61
+ const strings = getTeamsStrings(style);
62
+ const delegateMode = deps.isDelegateMode();
63
+
64
+ // Hide when no active team state.
65
+ // We intentionally ignore "completed-only" task lists so the widget doesn't stick around
66
+ // after /team shutdown.
67
+ const hasOnlineMembers = (teamConfig?.members ?? []).some(
68
+ (m) => m.role === "worker" && m.status === "online",
69
+ );
70
+ const hasActiveTasks = tasks.some((t) => t.status !== "completed");
71
+ if (teammates.size === 0 && !hasOnlineMembers && !hasActiveTasks) {
72
+ return [];
73
+ }
74
+
75
+ const lines: string[] = [];
76
+
77
+ // ── Header line ──
78
+ let header = " " + theme.bold(theme.fg("accent", "Teams"));
79
+ if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
80
+ lines.push(truncateToWidth(header, width));
81
+
82
+ const activeTeamId = deps.getActiveTeamId();
83
+ const sessionTeamId = deps.getSessionTeamId();
84
+ if (activeTeamId && sessionTeamId && activeTeamId !== sessionTeamId) {
85
+ const attachLine = theme.fg(
86
+ "warning",
87
+ ` attached: ${shortTeamId(activeTeamId)} (session ${shortTeamId(sessionTeamId)}) · /team detach`,
88
+ );
89
+ lines.push(truncateToWidth(attachLine, width));
90
+ }
91
+
92
+ const qgFailures = tasks.filter((task) => hasQualityGateFailure(task)).length;
93
+ if (qgFailures > 0) {
94
+ lines.push(
95
+ truncateToWidth(theme.fg("warning", ` quality gate failures: ${qgFailures} · /team task list`), width),
96
+ );
97
+ }
98
+
99
+ // ── Build row data ──
100
+ const cfgMembers = teamConfig?.members ?? [];
101
+ const cfgByName = new Map<string, TeamMember>();
102
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
103
+
104
+ const rows: WidgetRow[] = [];
105
+
106
+ // Leader control row (always first when team is active)
107
+ const leadName = teamConfig?.leadName;
108
+ if (leadName) {
109
+ const leadTasks = tasks.filter((t) => t.owner === leadName);
110
+ rows.push({
111
+ icon: "\u25c6",
112
+ iconColor: "accent",
113
+ displayName: strings.leaderControlTitle,
114
+ statusKey: "idle",
115
+ pending: leadTasks.filter((t) => t.status === "pending").length,
116
+ completed: leadTasks.filter((t) => t.status === "completed").length,
117
+ tokensStr: "\u2014",
118
+ activityText: "",
119
+ });
120
+ }
121
+
122
+ const workerNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
123
+ if (workerNames.length === 0 && rows.length === 0) {
124
+ lines.push(
125
+ truncateToWidth(
126
+ " " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
127
+ width,
128
+ ),
129
+ );
130
+ } else {
131
+ for (const name of workerNames) {
132
+ const rpc = teammates.get(name);
133
+ const cfg = cfgByName.get(name);
134
+ const statusKey = resolveStatus(rpc, cfg);
135
+ const activity = tracker.get(name);
136
+ const owned = tasks.filter((t) => t.owner === name);
137
+
138
+ rows.push({
139
+ icon: STATUS_ICON[statusKey],
140
+ iconColor: STATUS_COLOR[statusKey],
141
+ displayName: formatMemberDisplayName(style, name),
142
+ statusKey,
143
+ pending: owned.filter((t) => t.status === "pending").length,
144
+ completed: owned.filter((t) => t.status === "completed").length,
145
+ tokensStr: formatTokens(activity.totalTokens),
146
+ activityText: toolActivity(activity.currentToolName),
147
+ });
148
+ }
149
+
150
+ // ── Compute column widths ──
151
+ const totalPending = tasks.filter((t) => t.status === "pending").length;
152
+ const totalCompleted = tasks.filter((t) => t.status === "completed").length;
153
+ let totalTokensRaw = 0;
154
+ for (const name of workerNames) totalTokensRaw += tracker.get(name).totalTokens;
155
+ const totalTokensStr = formatTokens(totalTokensRaw);
156
+
157
+ const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
158
+ const pW = Math.max(...rows.map((r) => String(r.pending).length), String(totalPending).length);
159
+ const cW = Math.max(...rows.map((r) => String(r.completed).length), String(totalCompleted).length);
160
+ const tokW = Math.max(...rows.map((r) => r.tokensStr.length), totalTokensStr.length);
161
+
162
+ // ── Render rows ──
163
+ for (const r of rows) {
164
+ const icon = theme.fg(r.iconColor, r.icon);
165
+ const styledName = theme.bold(r.displayName);
166
+ const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
167
+ const pNum = String(r.pending).padStart(pW);
168
+ const cNum = String(r.completed).padStart(cW);
169
+ const tokStr = r.tokensStr.padStart(tokW);
170
+ const cols = theme.fg(
171
+ "dim",
172
+ ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
173
+ );
174
+ const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
175
+
176
+ const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
177
+ lines.push(truncateToWidth(row, width));
178
+ }
179
+
180
+ // ── Total row ──
181
+ const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
182
+ lines.push(truncateToWidth(sepLine, width));
183
+
184
+ const totalLabel = theme.bold("Total");
185
+ const totalTaskCount = totalPending + totalCompleted;
186
+ const pct = totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
187
+ const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
188
+ const tpNum = String(totalPending).padStart(pW);
189
+ const tcNum = String(totalCompleted).padStart(cW);
190
+ const ttokStr = totalTokensStr.padStart(tokW);
191
+ const totalSuffix = theme.fg(
192
+ "muted",
193
+ ` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
194
+ );
195
+ // nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
196
+ const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
197
+ lines.push(truncateToWidth(totalRow, width));
198
+ }
199
+
200
+ // ── Hints line ──
201
+ const hints = theme.fg(
202
+ "dim",
203
+ " /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
204
+ );
205
+ lines.push(truncateToWidth(hints, width));
206
+
207
+ return lines;
208
+ },
209
+ invalidate() {},
210
+ };
211
+ };
212
+ }