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,18 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "indusagi-coding-agent";
|
|
2
|
+
import { runLeader } from "./leader.js";
|
|
3
|
+
import { runWorker } from "./worker.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* pi-teams
|
|
7
|
+
*
|
|
8
|
+
* Two roles in one extension (Claude-style):
|
|
9
|
+
* - Leader process: spawn teammates, manage task list + mailbox UI/commands
|
|
10
|
+
* - Worker process: poll mailbox + auto-claim tasks from shared task list
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const IS_WORKER = process.env.PI_TEAMS_WORKER === "1";
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI) {
|
|
16
|
+
if (IS_WORKER) runWorker(pi);
|
|
17
|
+
else runLeader(pi);
|
|
18
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
2
|
+
import { getTeamDir } from "./paths.js";
|
|
3
|
+
import {
|
|
4
|
+
acquireTeamAttachClaim,
|
|
5
|
+
releaseTeamAttachClaim,
|
|
6
|
+
TEAM_ATTACH_CLAIM_STALE_MS,
|
|
7
|
+
} from "./team-attach-claim.js";
|
|
8
|
+
import { loadTeamConfig } from "./team-config.js";
|
|
9
|
+
import { listDiscoveredTeams } from "./team-discovery.js";
|
|
10
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
11
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
12
|
+
|
|
13
|
+
function formatClaimAge(heartbeatAt?: string): string {
|
|
14
|
+
if (!heartbeatAt) return "unknown";
|
|
15
|
+
const heartbeatMs = Date.parse(heartbeatAt);
|
|
16
|
+
if (!Number.isFinite(heartbeatMs)) return "unknown";
|
|
17
|
+
const ageMs = Math.max(0, Date.now() - heartbeatMs);
|
|
18
|
+
const ageSec = Math.floor(ageMs / 1000);
|
|
19
|
+
return `${ageSec}s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function handleTeamAttachCommand(opts: {
|
|
23
|
+
ctx: ExtensionCommandContext;
|
|
24
|
+
rest: string[];
|
|
25
|
+
defaultTeamId: string;
|
|
26
|
+
teammates: Map<string, TeammateRpc>;
|
|
27
|
+
getActiveTeamId: () => string;
|
|
28
|
+
setActiveTeamId: (teamId: string) => void;
|
|
29
|
+
setStyle: (style: TeamsStyle) => void;
|
|
30
|
+
setTaskListId: (id: string) => void;
|
|
31
|
+
refreshTasks: () => Promise<void>;
|
|
32
|
+
renderWidget: () => void;
|
|
33
|
+
}): Promise<void> {
|
|
34
|
+
const {
|
|
35
|
+
ctx,
|
|
36
|
+
rest,
|
|
37
|
+
defaultTeamId,
|
|
38
|
+
teammates,
|
|
39
|
+
getActiveTeamId,
|
|
40
|
+
setActiveTeamId,
|
|
41
|
+
setStyle,
|
|
42
|
+
setTaskListId,
|
|
43
|
+
refreshTasks,
|
|
44
|
+
renderWidget,
|
|
45
|
+
} = opts;
|
|
46
|
+
|
|
47
|
+
const tokens = rest.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
48
|
+
const activeTeamId = getActiveTeamId();
|
|
49
|
+
if (tokens.length === 0 || tokens[0] === "help") {
|
|
50
|
+
ctx.ui.notify(
|
|
51
|
+
[
|
|
52
|
+
"Usage:",
|
|
53
|
+
" /team attach list",
|
|
54
|
+
" /team attach <teamId> [--claim]",
|
|
55
|
+
"",
|
|
56
|
+
`current: ${activeTeamId}${activeTeamId === defaultTeamId ? " (session)" : " (attached)"}`,
|
|
57
|
+
`session: ${defaultTeamId}`,
|
|
58
|
+
].join("\n"),
|
|
59
|
+
"info",
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (tokens[0] === "list") {
|
|
65
|
+
const teams = await listDiscoveredTeams();
|
|
66
|
+
if (teams.length === 0) {
|
|
67
|
+
ctx.ui.notify("No existing teams found", "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines: string[] = ["Known teams:"];
|
|
72
|
+
for (const t of teams.slice(0, 30)) {
|
|
73
|
+
const marks: string[] = [];
|
|
74
|
+
if (t.teamId === activeTeamId) marks.push("current");
|
|
75
|
+
if (t.teamId === defaultTeamId) marks.push("session");
|
|
76
|
+
if (t.attachedBySessionId) {
|
|
77
|
+
const ownerMark = t.attachedBySessionId === defaultTeamId ? "claimed:you" : `claimed:${t.attachedBySessionId}`;
|
|
78
|
+
const staleMark = t.attachClaimStale ? "stale" : "live";
|
|
79
|
+
const age = formatClaimAge(t.attachHeartbeatAt);
|
|
80
|
+
marks.push(`${ownerMark}:${staleMark}:${age}`);
|
|
81
|
+
}
|
|
82
|
+
const mark = marks.length ? ` [${marks.join(",")}]` : "";
|
|
83
|
+
lines.push(
|
|
84
|
+
`- ${t.teamId}${mark} · style=${t.style} · workers=${t.onlineWorkerCount}/${t.workerCount} · taskList=${t.taskListId}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (teams.length > 30) lines.push(`... +${teams.length - 30} more`);
|
|
88
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const unknownFlags = tokens.filter((t) => t.startsWith("--") && t !== "--claim");
|
|
93
|
+
if (unknownFlags.length > 0) {
|
|
94
|
+
ctx.ui.notify(`Unknown attach flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const forceClaim = tokens.includes("--claim");
|
|
98
|
+
const positional = tokens.filter((t) => !t.startsWith("--"));
|
|
99
|
+
const targetTeamId = positional[0]?.trim() ?? "";
|
|
100
|
+
if (!targetTeamId || positional.length !== 1) {
|
|
101
|
+
ctx.ui.notify("Usage: /team attach <teamId> [--claim]", "error");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (targetTeamId === activeTeamId) {
|
|
105
|
+
ctx.ui.notify(`Already attached to team: ${targetTeamId}`, "info");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (teammates.size > 0) {
|
|
110
|
+
ctx.ui.notify(
|
|
111
|
+
`Refusing to attach while ${teammates.size} RPC teammate(s) are running. Run /team shutdown first.`,
|
|
112
|
+
"error",
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetDir = getTeamDir(targetTeamId);
|
|
118
|
+
const cfg = await loadTeamConfig(targetDir);
|
|
119
|
+
if (!cfg) {
|
|
120
|
+
ctx.ui.notify(`Team not found: ${targetTeamId}\nExpected config at: ${targetDir}/config.json`, "error");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
125
|
+
const ok = await ctx.ui.confirm(
|
|
126
|
+
"Attach to team",
|
|
127
|
+
[
|
|
128
|
+
`Attach this session to team ${cfg.teamId}?`,
|
|
129
|
+
"",
|
|
130
|
+
`taskListId: ${cfg.taskListId}`,
|
|
131
|
+
`style: ${cfg.style ?? "normal"}`,
|
|
132
|
+
`workers: ${cfg.members.filter((m) => m.role === "worker").length}`,
|
|
133
|
+
forceClaim ? "mode: force-claim" : "mode: normal claim",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
);
|
|
136
|
+
if (!ok) return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const claimResult = await acquireTeamAttachClaim(targetDir, defaultTeamId, {
|
|
140
|
+
force: forceClaim,
|
|
141
|
+
staleMs: TEAM_ATTACH_CLAIM_STALE_MS,
|
|
142
|
+
});
|
|
143
|
+
if (!claimResult.ok) {
|
|
144
|
+
const heldFor = formatClaimAge(claimResult.claim.heartbeatAt);
|
|
145
|
+
ctx.ui.notify(
|
|
146
|
+
[
|
|
147
|
+
`Team ${cfg.teamId} is currently claimed by session ${claimResult.claim.holderSessionId}.`,
|
|
148
|
+
`last heartbeat: ${heldFor} ago`,
|
|
149
|
+
"Run '/team attach <teamId> --claim' to force takeover.",
|
|
150
|
+
].join("\n"),
|
|
151
|
+
"error",
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const previouslyAttachedTeamId = activeTeamId !== defaultTeamId ? activeTeamId : null;
|
|
157
|
+
if (previouslyAttachedTeamId && previouslyAttachedTeamId !== cfg.teamId) {
|
|
158
|
+
const previousDir = getTeamDir(previouslyAttachedTeamId);
|
|
159
|
+
await releaseTeamAttachClaim(previousDir, defaultTeamId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setActiveTeamId(cfg.teamId);
|
|
163
|
+
setTaskListId(cfg.taskListId);
|
|
164
|
+
setStyle(cfg.style ?? "normal");
|
|
165
|
+
await refreshTasks();
|
|
166
|
+
renderWidget();
|
|
167
|
+
|
|
168
|
+
const lines: string[] = [
|
|
169
|
+
`Attached to team: ${cfg.teamId}`,
|
|
170
|
+
`taskListId: ${cfg.taskListId}`,
|
|
171
|
+
`style: ${cfg.style ?? "normal"}`,
|
|
172
|
+
];
|
|
173
|
+
if (claimResult.replacedClaim && claimResult.replacedClaim.holderSessionId !== defaultTeamId) {
|
|
174
|
+
lines.push(`claim: took over from ${claimResult.replacedClaim.holderSessionId}`);
|
|
175
|
+
}
|
|
176
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function handleTeamDetachCommand(opts: {
|
|
180
|
+
ctx: ExtensionCommandContext;
|
|
181
|
+
defaultTeamId: string;
|
|
182
|
+
teammates: Map<string, TeammateRpc>;
|
|
183
|
+
getActiveTeamId: () => string;
|
|
184
|
+
setActiveTeamId: (teamId: string) => void;
|
|
185
|
+
setTaskListId: (id: string) => void;
|
|
186
|
+
refreshTasks: () => Promise<void>;
|
|
187
|
+
renderWidget: () => void;
|
|
188
|
+
}): Promise<void> {
|
|
189
|
+
const { ctx, defaultTeamId, teammates, getActiveTeamId, setActiveTeamId, setTaskListId, refreshTasks, renderWidget } = opts;
|
|
190
|
+
|
|
191
|
+
const activeTeamId = getActiveTeamId();
|
|
192
|
+
if (activeTeamId === defaultTeamId) {
|
|
193
|
+
ctx.ui.notify("Already using this session's team", "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (teammates.size > 0) {
|
|
197
|
+
ctx.ui.notify(
|
|
198
|
+
`Refusing to detach while ${teammates.size} RPC teammate(s) are running. Run /team shutdown first.`,
|
|
199
|
+
"error",
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const activeDir = getTeamDir(activeTeamId);
|
|
205
|
+
const releaseResult = await releaseTeamAttachClaim(activeDir, defaultTeamId);
|
|
206
|
+
|
|
207
|
+
setActiveTeamId(defaultTeamId);
|
|
208
|
+
setTaskListId(defaultTeamId);
|
|
209
|
+
await refreshTasks();
|
|
210
|
+
renderWidget();
|
|
211
|
+
|
|
212
|
+
if (releaseResult === "not_owner") {
|
|
213
|
+
ctx.ui.notify(
|
|
214
|
+
`Detached from external team ${activeTeamId}, but attach claim belonged to another session.`,
|
|
215
|
+
"warning",
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ctx.ui.notify(`Detached from external team. Back to session team: ${defaultTeamId}`, "info");
|
|
221
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ExtensionContext } from "indusagi-coding-agent";
|
|
2
|
+
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
3
|
+
import { sanitizeName } from "./names.js";
|
|
4
|
+
import {
|
|
5
|
+
TEAM_MAILBOX_NS,
|
|
6
|
+
isIdleNotification,
|
|
7
|
+
isPeerDmSent,
|
|
8
|
+
isPlanApprovalRequest,
|
|
9
|
+
isShutdownApproved,
|
|
10
|
+
isShutdownRejected,
|
|
11
|
+
} from "./protocol.js";
|
|
12
|
+
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
13
|
+
import { getTask } from "./task-store.js";
|
|
14
|
+
|
|
15
|
+
import type { TeamsHookInvocation } from "./hooks.js";
|
|
16
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
17
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
18
|
+
|
|
19
|
+
export async function pollLeaderInbox(opts: {
|
|
20
|
+
ctx: ExtensionContext;
|
|
21
|
+
teamId: string;
|
|
22
|
+
teamDir: string;
|
|
23
|
+
taskListId: string;
|
|
24
|
+
leadName: string;
|
|
25
|
+
style: TeamsStyle;
|
|
26
|
+
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
27
|
+
enqueueHook?: (invocation: TeamsHookInvocation) => void;
|
|
28
|
+
}): Promise<void> {
|
|
29
|
+
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook } = opts;
|
|
30
|
+
const strings = getTeamsStrings(style);
|
|
31
|
+
|
|
32
|
+
let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
|
|
33
|
+
try {
|
|
34
|
+
msgs = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, leadName);
|
|
35
|
+
} catch (err: unknown) {
|
|
36
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "warning");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!msgs.length) return;
|
|
40
|
+
|
|
41
|
+
for (const m of msgs) {
|
|
42
|
+
const approved = isShutdownApproved(m.text);
|
|
43
|
+
if (approved) {
|
|
44
|
+
const name = sanitizeName(approved.from);
|
|
45
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
46
|
+
teamId,
|
|
47
|
+
taskListId,
|
|
48
|
+
leadName,
|
|
49
|
+
style,
|
|
50
|
+
});
|
|
51
|
+
if (!cfg.members.some((mm) => mm.name === name)) {
|
|
52
|
+
await upsertMember(teamDir, { name, role: "worker", status: "offline" });
|
|
53
|
+
}
|
|
54
|
+
await setMemberStatus(teamDir, name, "offline", {
|
|
55
|
+
lastSeenAt: approved.timestamp,
|
|
56
|
+
meta: {
|
|
57
|
+
shutdownApprovedRequestId: approved.requestId,
|
|
58
|
+
shutdownApprovedAt: approved.timestamp ?? new Date().toISOString(),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownCompletedVerb}`, "info");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rejected = isShutdownRejected(m.text);
|
|
66
|
+
if (rejected) {
|
|
67
|
+
const name = sanitizeName(rejected.from);
|
|
68
|
+
await setMemberStatus(teamDir, name, "online", {
|
|
69
|
+
lastSeenAt: rejected.timestamp,
|
|
70
|
+
meta: {
|
|
71
|
+
shutdownRejectedAt: rejected.timestamp ?? new Date().toISOString(),
|
|
72
|
+
shutdownRejectedReason: rejected.reason,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownRefusedVerb}: ${rejected.reason}`, "warning");
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const planReq = isPlanApprovalRequest(m.text);
|
|
80
|
+
if (planReq) {
|
|
81
|
+
const name = sanitizeName(planReq.from);
|
|
82
|
+
const preview = planReq.plan.length > 500 ? planReq.plan.slice(0, 500) + "..." : planReq.plan;
|
|
83
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} requests plan approval:\n${preview}`, "info");
|
|
84
|
+
pendingPlanApprovals.set(name, {
|
|
85
|
+
requestId: planReq.requestId,
|
|
86
|
+
name,
|
|
87
|
+
taskId: planReq.taskId,
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const peerDm = isPeerDmSent(m.text);
|
|
93
|
+
if (peerDm) {
|
|
94
|
+
ctx.ui.notify(`${peerDm.from} → ${peerDm.to}: ${peerDm.summary}`, "info");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const idle = isIdleNotification(m.text);
|
|
99
|
+
if (idle) {
|
|
100
|
+
const name = sanitizeName(idle.from);
|
|
101
|
+
|
|
102
|
+
// Hook: always emit "idle" (best-effort, non-blocking)
|
|
103
|
+
try {
|
|
104
|
+
enqueueHook?.({
|
|
105
|
+
event: "idle",
|
|
106
|
+
teamId,
|
|
107
|
+
teamDir,
|
|
108
|
+
taskListId,
|
|
109
|
+
style,
|
|
110
|
+
memberName: name,
|
|
111
|
+
timestamp: idle.timestamp,
|
|
112
|
+
completedTask: null,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore hook enqueue errors
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Hook: task completion / failure
|
|
119
|
+
if (idle.completedTaskId) {
|
|
120
|
+
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
121
|
+
try {
|
|
122
|
+
enqueueHook?.({
|
|
123
|
+
event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
|
|
124
|
+
teamId,
|
|
125
|
+
teamDir,
|
|
126
|
+
taskListId,
|
|
127
|
+
style,
|
|
128
|
+
memberName: name,
|
|
129
|
+
timestamp: idle.timestamp,
|
|
130
|
+
completedTask,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore hook enqueue errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (idle.failureReason) {
|
|
138
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
139
|
+
teamId,
|
|
140
|
+
taskListId,
|
|
141
|
+
leadName,
|
|
142
|
+
style,
|
|
143
|
+
});
|
|
144
|
+
if (!cfg.members.some((mm) => mm.name === name)) {
|
|
145
|
+
await upsertMember(teamDir, { name, role: "worker", status: "offline" });
|
|
146
|
+
}
|
|
147
|
+
await setMemberStatus(teamDir, name, "offline", {
|
|
148
|
+
lastSeenAt: idle.timestamp,
|
|
149
|
+
meta: { offlineReason: idle.failureReason },
|
|
150
|
+
});
|
|
151
|
+
ctx.ui.notify(`${name} went offline (${idle.failureReason})`, "warning");
|
|
152
|
+
} else {
|
|
153
|
+
const desiredSessionName = `pi agent teams - ${strings.memberTitle.toLowerCase()} ${name}`;
|
|
154
|
+
|
|
155
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
156
|
+
teamId,
|
|
157
|
+
taskListId,
|
|
158
|
+
leadName,
|
|
159
|
+
style,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const member = cfg.members.find((mm) => mm.name === name);
|
|
163
|
+
const existingSessionNameRaw = member?.meta?.["sessionName"];
|
|
164
|
+
const existingSessionName = typeof existingSessionNameRaw === "string" ? existingSessionNameRaw : undefined;
|
|
165
|
+
const shouldSendName = existingSessionName !== desiredSessionName;
|
|
166
|
+
|
|
167
|
+
if (!member) {
|
|
168
|
+
// Manual tmux worker: learn from idle notifications.
|
|
169
|
+
await upsertMember(teamDir, {
|
|
170
|
+
name,
|
|
171
|
+
role: "worker",
|
|
172
|
+
status: "online",
|
|
173
|
+
lastSeenAt: idle.timestamp,
|
|
174
|
+
meta: { sessionName: desiredSessionName },
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
await setMemberStatus(teamDir, name, "online", {
|
|
178
|
+
lastSeenAt: idle.timestamp,
|
|
179
|
+
meta: { sessionName: desiredSessionName },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (shouldSendName) {
|
|
184
|
+
try {
|
|
185
|
+
const ts = new Date().toISOString();
|
|
186
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
187
|
+
from: leadName,
|
|
188
|
+
text: JSON.stringify({
|
|
189
|
+
type: "set_session_name",
|
|
190
|
+
name: desiredSessionName,
|
|
191
|
+
from: leadName,
|
|
192
|
+
timestamp: ts,
|
|
193
|
+
}),
|
|
194
|
+
timestamp: ts,
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (idle.completedTaskId && idle.completedStatus === "failed") {
|
|
202
|
+
ctx.ui.notify(`${name} aborted task #${idle.completedTaskId}`, "warning");
|
|
203
|
+
} else if (idle.completedTaskId) {
|
|
204
|
+
ctx.ui.notify(`${name} is idle task #${idle.completedTaskId}`, "info");
|
|
205
|
+
} else {
|
|
206
|
+
ctx.ui.notify(`${name} is idle`, "info");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
ctx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
2
|
+
import { sanitizeName } from "./names.js";
|
|
3
|
+
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
4
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
5
|
+
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
6
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
7
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
8
|
+
|
|
9
|
+
export async function handleTeamListCommand(opts: {
|
|
10
|
+
ctx: ExtensionCommandContext;
|
|
11
|
+
teammates: Map<string, TeammateRpc>;
|
|
12
|
+
getTeamConfig: () => TeamConfig | null;
|
|
13
|
+
style: TeamsStyle;
|
|
14
|
+
refreshTasks: () => Promise<void>;
|
|
15
|
+
renderWidget: () => void;
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
const { ctx, teammates, getTeamConfig, style, refreshTasks, renderWidget } = opts;
|
|
18
|
+
const strings = getTeamsStrings(style);
|
|
19
|
+
|
|
20
|
+
await refreshTasks();
|
|
21
|
+
|
|
22
|
+
const teamConfig = getTeamConfig();
|
|
23
|
+
const cfgWorkers = (teamConfig?.members ?? []).filter((m) => m.role === "worker");
|
|
24
|
+
const cfgByName = new Map<string, TeamMember>();
|
|
25
|
+
for (const m of cfgWorkers) cfgByName.set(m.name, m);
|
|
26
|
+
|
|
27
|
+
const names = new Set<string>();
|
|
28
|
+
for (const name of teammates.keys()) names.add(name);
|
|
29
|
+
for (const name of cfgByName.keys()) names.add(name);
|
|
30
|
+
|
|
31
|
+
if (names.size === 0) {
|
|
32
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s`, "info");
|
|
33
|
+
renderWidget();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
for (const name of Array.from(names).sort()) {
|
|
39
|
+
const rpc = teammates.get(name);
|
|
40
|
+
const cfg = cfgByName.get(name);
|
|
41
|
+
const status = rpc ? rpc.status : cfg?.status ?? "offline";
|
|
42
|
+
const kind = rpc ? "rpc" : cfg ? "manual" : "unknown";
|
|
43
|
+
lines.push(`${formatMemberDisplayName(style, name)}: ${status} (${kind})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
47
|
+
renderWidget();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleTeamIdCommand(opts: {
|
|
51
|
+
ctx: ExtensionCommandContext;
|
|
52
|
+
teamId: string;
|
|
53
|
+
taskListId: string | null;
|
|
54
|
+
leadName: string;
|
|
55
|
+
style: TeamsStyle;
|
|
56
|
+
}): Promise<void> {
|
|
57
|
+
const { ctx, teamId, taskListId, leadName, style } = opts;
|
|
58
|
+
const sessionTeamId = ctx.sessionManager.getSessionId();
|
|
59
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
60
|
+
const teamsRoot = getTeamsRootDir();
|
|
61
|
+
const teamDir = getTeamDir(teamId);
|
|
62
|
+
|
|
63
|
+
ctx.ui.notify(
|
|
64
|
+
[
|
|
65
|
+
`teamId: ${teamId}`,
|
|
66
|
+
...(teamId !== sessionTeamId ? [`sessionTeamId: ${sessionTeamId}`] : []),
|
|
67
|
+
`taskListId: ${effectiveTlId}`,
|
|
68
|
+
`leadName: ${leadName}`,
|
|
69
|
+
`style: ${style}`,
|
|
70
|
+
`teamsRoot: ${teamsRoot}`,
|
|
71
|
+
`teamDir: ${teamDir}`,
|
|
72
|
+
].join("\n"),
|
|
73
|
+
"info",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function handleTeamEnvCommand(opts: {
|
|
78
|
+
ctx: ExtensionCommandContext;
|
|
79
|
+
rest: string[];
|
|
80
|
+
teamId: string;
|
|
81
|
+
taskListId: string | null;
|
|
82
|
+
leadName: string;
|
|
83
|
+
style: TeamsStyle;
|
|
84
|
+
getTeamsExtensionEntryPath: () => string | null;
|
|
85
|
+
shellQuote: (v: string) => string;
|
|
86
|
+
}): Promise<void> {
|
|
87
|
+
const { ctx, rest, teamId, taskListId, leadName, style, getTeamsExtensionEntryPath, shellQuote } = opts;
|
|
88
|
+
|
|
89
|
+
const nameRaw = rest[0];
|
|
90
|
+
if (!nameRaw) {
|
|
91
|
+
ctx.ui.notify("Usage: /team env <name>", "error");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const name = sanitizeName(nameRaw);
|
|
96
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
97
|
+
const teamsRoot = getTeamsRootDir();
|
|
98
|
+
const teamDir = getTeamDir(teamId);
|
|
99
|
+
const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1" ? "1" : "0";
|
|
100
|
+
|
|
101
|
+
const teamsEntry = getTeamsExtensionEntryPath();
|
|
102
|
+
const cliCmd = teamsEntry ? `indusagi --no-extensions -e ${shellQuote(teamsEntry)}` : "indusagi";
|
|
103
|
+
|
|
104
|
+
const env: Record<string, string> = {
|
|
105
|
+
PI_TEAMS_ROOT_DIR: teamsRoot,
|
|
106
|
+
PI_TEAMS_WORKER: "1",
|
|
107
|
+
PI_TEAMS_TEAM_ID: teamId,
|
|
108
|
+
PI_TEAMS_TASK_LIST_ID: effectiveTlId,
|
|
109
|
+
PI_TEAMS_AGENT_NAME: name,
|
|
110
|
+
PI_TEAMS_LEAD_NAME: leadName,
|
|
111
|
+
PI_TEAMS_STYLE: style,
|
|
112
|
+
PI_TEAMS_AUTO_CLAIM: autoClaim,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const exportLines = Object.entries(env)
|
|
116
|
+
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
|
117
|
+
.join("\n");
|
|
118
|
+
|
|
119
|
+
const oneLiner = Object.entries(env)
|
|
120
|
+
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
121
|
+
.join(" ")
|
|
122
|
+
.concat(` ${cliCmd}`);
|
|
123
|
+
|
|
124
|
+
ctx.ui.notify(
|
|
125
|
+
[
|
|
126
|
+
`teamId: ${teamId}`,
|
|
127
|
+
`taskListId: ${effectiveTlId}`,
|
|
128
|
+
`leadName: ${leadName}`,
|
|
129
|
+
`teamsRoot: ${teamsRoot}`,
|
|
130
|
+
`teamDir: ${teamDir}`,
|
|
131
|
+
"",
|
|
132
|
+
"Env (copy/paste):",
|
|
133
|
+
exportLines,
|
|
134
|
+
"",
|
|
135
|
+
"Run:",
|
|
136
|
+
oneLiner,
|
|
137
|
+
].join("\n"),
|
|
138
|
+
"info",
|
|
139
|
+
);
|
|
140
|
+
}
|