ac-teams 0.0.7 → 0.0.9
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/README.md +1 -1
- package/docs/access.md +4 -0
- package/docs/design/session-roles/README.md +343 -0
- package/docs/design/session-roles/coordinator.yaml +44 -0
- package/docs/design/session-roles/design.yaml +70 -0
- package/docs/design/session-roles/herdr.yaml +17 -0
- package/docs/design/session-roles/journey.md +139 -0
- package/docs/design/session-roles/main-session.yaml +34 -0
- package/docs/design/session-roles/member.yaml +28 -0
- package/docs/design/session-roles/read-guard.yaml +17 -0
- package/docs/design/session-roles/team-store.yaml +13 -0
- package/docs/design/session-roles/transcripts.yaml +23 -0
- package/docs/design/session-roles/user.yaml +17 -0
- package/docs/operations.md +13 -2
- package/docs/usage.md +31 -9
- package/package.json +1 -1
- package/src/commands.ts +1 -1
- package/src/coordinator.ts +242 -31
- package/src/index.ts +243 -61
- package/src/mains.ts +41 -0
- package/src/panel.ts +9 -1
- package/src/schemas.ts +2 -2
- package/src/store.ts +32 -9
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +6 -3
- package/src/types.ts +19 -4
- package/src/ui-types.ts +4 -0
package/src/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ import { resolveTeammate, teamsCompletions } from "./commands.ts";
|
|
|
9
9
|
import { showTeamsPanel } from "./panel.ts";
|
|
10
10
|
import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { readState, teamsHome } from "./store.ts";
|
|
17
|
+
import { teamEntryType, type TeamEntry } from "./mains.ts";
|
|
18
|
+
import { isMain, memberName, taskName, type TeamState } from "./types.ts";
|
|
12
19
|
import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
|
|
13
20
|
import { Connection, isRecord } from "./connection.ts";
|
|
14
21
|
import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
|
|
@@ -30,21 +37,113 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
30
37
|
let disposeUI: (() => void) | undefined;
|
|
31
38
|
let telemetryTimer: NodeJS.Timeout | undefined;
|
|
32
39
|
let inputCount = 0;
|
|
40
|
+
/** This main session's team, from its transcript's team entry; a session with no entry leads its own team as "root". */
|
|
41
|
+
let team: TeamEntry | undefined;
|
|
42
|
+
/** Why team tools are off in this session, if they are. */
|
|
43
|
+
let disabled: string | undefined;
|
|
44
|
+
let linking: Promise<void> | undefined;
|
|
45
|
+
let linkedSession: string | undefined;
|
|
33
46
|
const drafts = new Map<string, string>();
|
|
34
47
|
const display = { ...defaultDisplaySettings };
|
|
35
|
-
const
|
|
48
|
+
const teamTools = ["team_agent", "team_task", "team_message"];
|
|
49
|
+
const memberId = () => child ? environment[1]! : team?.member ?? "root";
|
|
50
|
+
const teamId = (ctx: ExtensionContext) => team?.team ?? ctx.sessionManager.getSessionId();
|
|
36
51
|
pi.registerMessageRenderer("ac-teams", renderTeamMessage);
|
|
37
52
|
const warn = (message: string) => context?.ui.notify(message, "warning");
|
|
38
53
|
const notify = (through: number, messages: Message[] = []) => {
|
|
39
54
|
pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
|
|
40
55
|
if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
|
|
41
|
-
else coordinator?.ack(
|
|
56
|
+
else coordinator?.ack(coordinator.self, through);
|
|
42
57
|
};
|
|
43
|
-
|
|
58
|
+
function disable(reason: string) {
|
|
59
|
+
disabled = reason;
|
|
60
|
+
pi.setActiveTools(pi.getActiveTools().filter(name => !teamTools.includes(name)));
|
|
61
|
+
}
|
|
62
|
+
function enable() {
|
|
63
|
+
disabled = undefined;
|
|
64
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...teamTools])]);
|
|
65
|
+
}
|
|
66
|
+
function remember(entry: TeamEntry) {
|
|
67
|
+
team = entry;
|
|
68
|
+
pi.appendEntry(teamEntryType, entry);
|
|
69
|
+
}
|
|
70
|
+
/** Runs a native command sent by the coordinator: members get these from their parent, main sessions from other mains. */
|
|
71
|
+
function onCommand(ctx: ExtensionContext, link: Connection) {
|
|
72
|
+
return (command: { id: string; command: string; args: Record<string, unknown> }) => {
|
|
73
|
+
void (async () => {
|
|
74
|
+
let data: unknown = {}, error: string | undefined;
|
|
75
|
+
try {
|
|
76
|
+
if (command.command === "get_state") data = { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider, name: ctx.model.name } : undefined, contextUsage: readTelemetry(ctx).contextUsage, isStreaming: !ctx.isIdle() };
|
|
77
|
+
else if (command.command === "prompt") {
|
|
78
|
+
if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
|
|
79
|
+
pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
|
|
80
|
+
} else if (command.command === "abort") ctx.abort();
|
|
81
|
+
else if (command.command !== "shutdown") throw new Error("Unsupported native command");
|
|
82
|
+
} catch (failure) { error = String(failure).slice(0, 2000); }
|
|
83
|
+
try { await link.request("control_result", { id: command.id, data, error }); }
|
|
84
|
+
catch { return; }
|
|
85
|
+
if (command.command === "shutdown" && !error) { exiting = true; ctx.shutdown(); }
|
|
86
|
+
})();
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Connects to a running coordinator as a main-session client; false when none is listening. */
|
|
90
|
+
async function connectMain(ctx: ExtensionContext): Promise<boolean> {
|
|
91
|
+
const dir = join(teamsHome(), teamId(ctx)), socket = join(dir, "coordinator.sock");
|
|
92
|
+
if (!existsSync(socket)) return false;
|
|
93
|
+
const record = readState(dir).members[memberId()];
|
|
94
|
+
if (!isMain(record)) throw new Error("This session is no longer a main session of its team; use /teams join");
|
|
95
|
+
const link = new Connection(socket, record.id, record.token);
|
|
96
|
+
try { await link.open(); } catch { return false; }
|
|
97
|
+
link.on("notice", (through, messages) => notify(through, messages));
|
|
98
|
+
link.on("command", onCommand(ctx, link));
|
|
99
|
+
link.on("disconnect", () => {
|
|
100
|
+
if (connection !== link) return;
|
|
101
|
+
connection = undefined;
|
|
102
|
+
if (!exiting) void takeover(ctx);
|
|
103
|
+
});
|
|
104
|
+
try { await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() }); }
|
|
105
|
+
catch (error) { link.close(); throw error; }
|
|
106
|
+
connection = link; linkedSession = ctx.sessionManager.getSessionId();
|
|
107
|
+
if (!team) remember({ team: teamId(ctx), member: record.id });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** The coordinator owner went away: one main session takes the owner lock, the rest reconnect to it. */
|
|
111
|
+
async function takeover(ctx: ExtensionContext) {
|
|
112
|
+
warn("The team coordinator stopped; taking over or reconnecting");
|
|
113
|
+
for (let attempt = 0; attempt < 40 && !exiting; attempt++) {
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 250));
|
|
115
|
+
try {
|
|
116
|
+
await link(ctx);
|
|
117
|
+
warn(coordinator ? "This session now runs the team coordinator" : "Reconnected to the team coordinator");
|
|
118
|
+
return;
|
|
119
|
+
} catch { /* The new owner may still be starting. */ }
|
|
120
|
+
}
|
|
121
|
+
if (!exiting) warn("Could not reach or take over the team coordinator; team tools will retry on next use");
|
|
122
|
+
}
|
|
123
|
+
/** Makes this session an owner or client of its team's coordinator. */
|
|
124
|
+
async function link(ctx: ExtensionContext): Promise<void> {
|
|
125
|
+
if (child) { if (!connection) throw new Error("Team coordinator is not connected"); return; }
|
|
126
|
+
if (exiting) throw new Error("Team is stopping");
|
|
127
|
+
if (disabled) throw new Error(disabled);
|
|
128
|
+
if (connection || coordinator) return;
|
|
129
|
+
linking ??= (async () => {
|
|
130
|
+
if (await connectMain(ctx)) return;
|
|
131
|
+
try { await root(ctx); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Another main session won the owner lock first; join it as a client.
|
|
134
|
+
if (String(error).includes("live owner lock") && await connectMain(ctx)) return;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
})().finally(() => { linking = undefined; });
|
|
138
|
+
return linking;
|
|
139
|
+
}
|
|
140
|
+
async function root(ctx: ExtensionContext, join?: { id: string; member: string }): Promise<Coordinator> {
|
|
44
141
|
if (exiting) throw new Error("Team is stopping");
|
|
45
142
|
context = ctx;
|
|
46
143
|
opening ??= Coordinator.open({
|
|
47
|
-
id: ctx
|
|
144
|
+
id: join?.id ?? teamId(ctx), self: join?.member ?? memberId(), join: join !== undefined, sessionId: ctx.sessionManager.getSessionId(),
|
|
145
|
+
shutdown: () => { exiting = true; ctx.shutdown(); },
|
|
146
|
+
cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(), sessionDir: ctx.sessionManager.getSessionDir(),
|
|
48
147
|
...policy(ctx.cwd, pi.getFlag("teams-project-config") === true, { limit: pi.getFlag("teams-limit") as string | undefined, depth: pi.getFlag("teams-depth") as string | undefined }),
|
|
49
148
|
extension: fileURLToPath(import.meta.url), notice: notify, warning: warn, piVersion: VERSION,
|
|
50
149
|
status: (kind, active) => pi.events.emit(`herdr:${kind}`, { active, source: "ac-teams" }),
|
|
@@ -62,9 +161,10 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
62
161
|
},
|
|
63
162
|
interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
|
|
64
163
|
}).then(value => {
|
|
65
|
-
coordinator = value;
|
|
66
|
-
if (
|
|
67
|
-
if (
|
|
164
|
+
coordinator = value; linkedSession = ctx.sessionManager.getSessionId();
|
|
165
|
+
if (!team) remember({ team: value.store.state.id, member: value.self });
|
|
166
|
+
if (ctx.isIdle?.() === false) value.recordEvent(value.self, { type: "agent_start" });
|
|
167
|
+
if (inputCount > 0) value.recordEvent(value.self, { type: "blocked", active: true });
|
|
68
168
|
return value;
|
|
69
169
|
}, error => { opening = undefined; throw error; });
|
|
70
170
|
return opening;
|
|
@@ -80,19 +180,18 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
80
180
|
}
|
|
81
181
|
args = agent;
|
|
82
182
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
183
|
+
await link(ctx);
|
|
184
|
+
// shortcut: client main sessions cannot approve a ceiling above 10, only the owner can; add a confirmation relay if needed.
|
|
185
|
+
if (connection) return connection.request(operation, args);
|
|
87
186
|
let authorized = false;
|
|
88
187
|
if (operation === "agent" && (args as AgentArgs).action === "limits" && ((args as AgentArgs).limit ?? 0) > 10) {
|
|
89
188
|
if (!ctx.hasUI || !await ctx.ui.confirm("Raise team ceiling?", `Allow up to ${(args as AgentArgs).limit} live Pi members, including idle members? This increases possible model costs.`)) throw new Error("Ceiling increase was not explicitly approved");
|
|
90
189
|
authorized = true;
|
|
91
190
|
}
|
|
92
|
-
return
|
|
191
|
+
return coordinator!.handle(coordinator!.self, operation, args, authorized);
|
|
93
192
|
}
|
|
94
193
|
async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
|
|
95
|
-
if (!child && !opening) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
194
|
+
if (!child && !opening && !connection) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
96
195
|
const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
|
|
97
196
|
const tasks: TaskView[] = [];
|
|
98
197
|
let total = 0;
|
|
@@ -107,30 +206,35 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
107
206
|
function host(ctx: ExtensionContext): PanelHost {
|
|
108
207
|
const userAction = async (args: UserAction): Promise<string> => {
|
|
109
208
|
const value = { ...args, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel() };
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
return await (await root(ctx)).handle("root", "ui", value) as string;
|
|
209
|
+
await link(ctx);
|
|
210
|
+
if (connection) return await connection.request("ui", value) as string;
|
|
211
|
+
return await coordinator!.handle(coordinator!.self, "ui", value) as string;
|
|
115
212
|
};
|
|
116
213
|
const api: PanelHost = {
|
|
117
|
-
self, drafts, snapshot: () => snapshot(ctx),
|
|
214
|
+
self: memberId(), child, drafts, snapshot: () => snapshot(ctx),
|
|
118
215
|
visit: async id => {
|
|
119
|
-
if (id ===
|
|
120
|
-
|
|
216
|
+
if (id === memberId()) return;
|
|
217
|
+
if (!child) await link(ctx);
|
|
218
|
+
const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join(teamsHome(), teamId(ctx), "t.sock");
|
|
121
219
|
if (!socket) throw new Error("This member has no live terminal transport");
|
|
122
220
|
await visitMember(ctx, api, socket, id);
|
|
123
221
|
},
|
|
124
222
|
requestTask: text => userAction({ action: "task_request", text }),
|
|
125
223
|
feedback: (task, text) => userAction({ action: "feedback", id: task.id, text }),
|
|
126
224
|
resume: async id => { await userAction({ action: "resume", id }); },
|
|
225
|
+
promote: async id => {
|
|
226
|
+
const member = (await snapshot(ctx)).members.find(member => member.id === id);
|
|
227
|
+
const confirmed = member?.access ? await ctx.ui.confirm("Promote a sandboxed member?", `${memberName(member)} will leave its sandbox and run as a main session with your full access.`) : false;
|
|
228
|
+
if (member?.access && !confirmed) return;
|
|
229
|
+
ctx.ui.notify(await userAction({ action: "promote", id, confirmed }), "info");
|
|
230
|
+
},
|
|
127
231
|
};
|
|
128
232
|
return api;
|
|
129
233
|
}
|
|
130
234
|
function report(ctx: ExtensionContext, event: { type: string; text?: string; active?: boolean }) {
|
|
131
235
|
if (exiting) return;
|
|
132
|
-
if (
|
|
133
|
-
else
|
|
236
|
+
if (connection && (!child || ctx.mode === "tui")) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
237
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
134
238
|
}
|
|
135
239
|
function startTelemetry(ctx: ExtensionContext) {
|
|
136
240
|
clearInterval(telemetryTimer);
|
|
@@ -141,8 +245,8 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
141
245
|
try {
|
|
142
246
|
const event = readTelemetry(ctx), encoded = JSON.stringify(event);
|
|
143
247
|
if (encoded === previous) return;
|
|
144
|
-
if (
|
|
145
|
-
else coordinator?.recordEvent(
|
|
248
|
+
if (connection) await connection.request("event", event);
|
|
249
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
146
250
|
previous = encoded;
|
|
147
251
|
} catch (error) { if (!exiting) warn(cleanText(String(error))); }
|
|
148
252
|
finally { pending = false; }
|
|
@@ -157,8 +261,8 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
157
261
|
inputCount = Math.max(0, inputCount + (data.active ? 1 : -1));
|
|
158
262
|
if (context) {
|
|
159
263
|
const event = { type: "blocked", active: inputCount > 0 };
|
|
160
|
-
if (
|
|
161
|
-
else coordinator?.recordEvent(
|
|
264
|
+
if (connection) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
265
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
162
266
|
}
|
|
163
267
|
});
|
|
164
268
|
pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
|
|
@@ -170,16 +274,17 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
170
274
|
});
|
|
171
275
|
function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
|
|
172
276
|
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use stable member/task IDs; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
|
|
277
|
+
const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and include its ID in the member's brief. Track independently owned outcomes, not tool calls or procedural steps; do not create one task per teammate by default. Skip tasks for quick lookups, clarification, and brief second opinions unless results need coordination or follow-up. Task owners claim and start their work, then complete it after checking acceptance criteria or report blockers.";
|
|
173
278
|
pi.registerTool({
|
|
174
|
-
name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees.",
|
|
175
|
-
promptGuidelines: [guidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
|
|
279
|
+
name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees. Create or reuse team_task entries for delegated deliverables; quick lookups need no task unless results need coordination or follow-up.",
|
|
280
|
+
promptGuidelines: [guidance, taskTrackingGuidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
|
|
176
281
|
parameters: AgentSchema,
|
|
177
282
|
...teamToolRenderers("team_agent"),
|
|
178
283
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
|
|
179
284
|
});
|
|
180
285
|
pi.registerTool({
|
|
181
|
-
name: "team_task", label: "Team task", description: "Manage shared tasks: create/list/read/edit, claim a reservation, start work, complete, release ownership, or explicitly reopen. Progress is todo/in_progress/done; blockedBy is derived from unfinished dependencies. Mutations support revision checks.",
|
|
182
|
-
promptGuidelines: [taskRequestGuidance, "Give every task a concise 1–3-word label, alongside its full title and acceptance criteria. Stable team-local numbers are assigned automatically; use UUIDs for API references. Interim and final goals are ordinary tasks. Add dependencies only for real prerequisites, never merely to connect the diagram.", "Claim reserves a to-do task even if blocked; start requires ownership and all dependencies done. Complete requires in-progress work and acceptance=true after checking the criteria. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents."],
|
|
286
|
+
name: "team_task", label: "Team task", description: "Track delegated deliverables, not every agent or action. Create or reuse tasks before delegation; quick lookups need no task unless results need coordination or follow-up. Manage shared tasks: create/list/read/edit, claim a reservation, start work, complete, release ownership, or explicitly reopen. Progress is todo/in_progress/done; blockedBy is derived from unfinished dependencies. Mutations support revision checks.",
|
|
287
|
+
promptGuidelines: [taskTrackingGuidance, taskRequestGuidance, "Give every task a concise 1–3-word label, alongside its full title and acceptance criteria. Stable team-local numbers are assigned automatically; use UUIDs for API references. Interim and final goals are ordinary tasks. Add dependencies only for real prerequisites, never merely to connect the diagram.", "Claim reserves a to-do task even if blocked; start requires ownership and all dependencies done. Complete requires in-progress work and acceptance=true after checking the criteria. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents."],
|
|
183
288
|
parameters: TaskSchema,
|
|
184
289
|
...teamToolRenderers("team_task"),
|
|
185
290
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("task", args, ctx)); },
|
|
@@ -196,34 +301,104 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
196
301
|
disposeUI?.(); disposeUI = undefined;
|
|
197
302
|
connection?.close(); connection = undefined;
|
|
198
303
|
if (opening) await (await opening).close();
|
|
199
|
-
coordinator = undefined; opening = undefined;
|
|
304
|
+
coordinator = undefined; opening = undefined; linkedSession = undefined;
|
|
305
|
+
}
|
|
306
|
+
/** A resumed main session reconnects to its team, or offers to run the coordinator and resume the team's members. */
|
|
307
|
+
async function rejoin(ctx: ExtensionContext) {
|
|
308
|
+
try {
|
|
309
|
+
if (await connectMain(ctx)) return;
|
|
310
|
+
const dir = join(teamsHome(), teamId(ctx));
|
|
311
|
+
if (!existsSync(join(dir, "state.json"))) return;
|
|
312
|
+
const state = readState(dir);
|
|
313
|
+
if (!isMain(state.members[memberId()])) { disable("This session is no longer a main session of its team; use /teams join"); return; }
|
|
314
|
+
const others = Object.values(state.members).filter(member => member.id !== memberId() && member.role);
|
|
315
|
+
if (!ctx.hasUI || !others.length) return;
|
|
316
|
+
if (await ctx.ui.confirm("Run the team coordinator?", `This is a main session of a team with ${others.length} other member(s), and no coordinator is running. Run it here and resume the members that were working?`)) await link(ctx);
|
|
317
|
+
else disable("You declined to run the team coordinator; team tools are off in this session. Resume the session again to take over.");
|
|
318
|
+
} catch (error) { warn(cleanText(String(error))); }
|
|
319
|
+
}
|
|
320
|
+
async function repository(cwd: string): Promise<string> {
|
|
321
|
+
try { return (await promisify(execFile)("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 })).stdout.trim(); }
|
|
322
|
+
catch { return cwd; }
|
|
323
|
+
}
|
|
324
|
+
/** /teams join: adds this session to another team in the same project as a main session. */
|
|
325
|
+
async function joinTeam(ctx: ExtensionContext) {
|
|
326
|
+
if (child) throw new Error("A member cannot join another team");
|
|
327
|
+
if (!ctx.hasUI) throw new Error("/teams join requires an interactive session");
|
|
328
|
+
const sessionId = ctx.sessionManager.getSessionId(), home = teamsHome();
|
|
329
|
+
const current = existsSync(join(home, teamId(ctx), "state.json")) ? readState(join(home, teamId(ctx))) : undefined;
|
|
330
|
+
if (current && Object.values(current.members).some(member => member.id !== memberId() && member.role)) throw new Error("This session is already in a team with other members; use /teams leave first");
|
|
331
|
+
const repo = await repository(ctx.cwd), teams: TeamState[] = [];
|
|
332
|
+
for (const name of existsSync(home) ? readdirSync(home) : []) {
|
|
333
|
+
try {
|
|
334
|
+
const state = readState(join(home, name)), main = Object.values(state.members).find(isMain);
|
|
335
|
+
if (state.id !== current?.id && main && await repository(main.cwd) === repo) teams.push(state);
|
|
336
|
+
} catch { /* Unreadable or foreign team folders are not offered. */ }
|
|
337
|
+
}
|
|
338
|
+
if (!teams.length) { ctx.ui.notify("No other teams in this project", "info"); return; }
|
|
339
|
+
const describe = (state: TeamState) => {
|
|
340
|
+
const members = Object.values(state.members);
|
|
341
|
+
return `${members.filter(isMain).map(memberName).join(", ")} · ${members.filter(member => member.role === "member").length} member(s) · ${Object.values(state.tasks).filter(task => task.status !== "done").length} open task(s) · ${state.id.slice(0, 8)}`;
|
|
342
|
+
};
|
|
343
|
+
const labels = teams.map(describe), choice = await ctx.ui.select("Join which team?", labels);
|
|
344
|
+
const chosen = teams[labels.indexOf(choice ?? "")];
|
|
345
|
+
if (!chosen) return;
|
|
346
|
+
if (Object.values(chosen.members).some(member => member.sessionId === sessionId)) throw new Error("This session is already in that team");
|
|
347
|
+
await close(); exiting = false; enable();
|
|
348
|
+
const dir = join(home, chosen.id), probe = new Connection(join(dir, "coordinator.sock"), "join", chosen.key);
|
|
349
|
+
let running = false;
|
|
350
|
+
try { await probe.open(); running = true; } catch { /* No coordinator: this session runs it. */ }
|
|
351
|
+
if (running) {
|
|
352
|
+
try {
|
|
353
|
+
const added = await probe.request("add_main", { sessionId, sessionFile: ctx.sessionManager.getSessionFile(), cwd: ctx.cwd }) as { id: string };
|
|
354
|
+
remember({ team: chosen.id, member: added.id });
|
|
355
|
+
} finally { probe.close(); }
|
|
356
|
+
if (!await connectMain(ctx)) throw new Error("The team coordinator stopped while joining; resume this session to retry");
|
|
357
|
+
} else {
|
|
358
|
+
const member = randomUUID().slice(0, 16);
|
|
359
|
+
await root(ctx, { id: chosen.id, member });
|
|
360
|
+
remember({ team: chosen.id, member });
|
|
361
|
+
}
|
|
362
|
+
const state = readState(dir), name = (id?: string) => id ? memberName(state.members[id] ?? { id }) : "unowned";
|
|
363
|
+
const open = Object.values(state.tasks).filter(task => task.status !== "done");
|
|
364
|
+
const recent = state.messages.slice(-5);
|
|
365
|
+
pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
|
|
366
|
+
`Joined team ${state.id} as ${name(team!.member)}, a main session.`,
|
|
367
|
+
open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
|
|
368
|
+
recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
|
|
369
|
+
].join("\n\n") }, { triggerTurn: false });
|
|
370
|
+
}
|
|
371
|
+
/** /teams leave: hands this session's work back and drops its main-session role. */
|
|
372
|
+
async function leaveTeam(ctx: ExtensionContext) {
|
|
373
|
+
if (child) throw new Error("A member cannot leave its team; its parent stops it");
|
|
374
|
+
await link(ctx);
|
|
375
|
+
const left = { team: teamId(ctx), member: memberId(), left: true };
|
|
376
|
+
const result = connection ? await connection.request("ui", { action: "leave" }) : await coordinator!.handle(coordinator!.self, "ui", { action: "leave" });
|
|
377
|
+
pi.appendEntry(teamEntryType, left);
|
|
378
|
+
team = undefined;
|
|
379
|
+
await close(); exiting = false;
|
|
380
|
+
disable("This session left its team; use /teams join to join one");
|
|
381
|
+
ctx.ui.notify(String(result), "info");
|
|
200
382
|
}
|
|
201
383
|
pi.on("session_start", async (_event, ctx) => {
|
|
202
|
-
if (!child &&
|
|
384
|
+
if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
|
|
203
385
|
context = ctx; exiting = false; inputCount = 0;
|
|
204
386
|
if (child) {
|
|
205
387
|
if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
|
|
206
|
-
|
|
207
|
-
connection
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
try { await connection?.request("control_result", { id: command.id, data, error }); }
|
|
221
|
-
catch { return; }
|
|
222
|
-
if (command.command === "shutdown" && !error) ctx.shutdown();
|
|
223
|
-
})();
|
|
224
|
-
});
|
|
225
|
-
await connection.open();
|
|
226
|
-
await connection.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
|
|
388
|
+
const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
|
|
389
|
+
connection = link;
|
|
390
|
+
link.on("notice", (through, messages) => notify(through, messages));
|
|
391
|
+
link.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
|
|
392
|
+
link.on("command", onCommand(ctx, link));
|
|
393
|
+
await link.open();
|
|
394
|
+
await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
|
|
395
|
+
} else if (!linkedSession) {
|
|
396
|
+
const entry = ctx.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === teamEntryType).at(-1);
|
|
397
|
+
const data = entry?.type === "custom" ? entry.data as TeamEntry : undefined;
|
|
398
|
+
team = data && !data.left ? { team: data.team, member: data.member } : undefined;
|
|
399
|
+
disabled = undefined;
|
|
400
|
+
if (data?.left) disable("This session left its team; use /teams join to join one");
|
|
401
|
+
else if (team) await rejoin(ctx);
|
|
227
402
|
}
|
|
228
403
|
startTelemetry(ctx);
|
|
229
404
|
if (!warned) {
|
|
@@ -241,7 +416,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
241
416
|
});
|
|
242
417
|
pi.on("session_shutdown", close);
|
|
243
418
|
const switchGuard = () => {
|
|
244
|
-
if (child || coordinator?.live().length) {
|
|
419
|
+
if (child || coordinator?.live().length || connection) {
|
|
245
420
|
warn("Stop team members before switching/forking/traversing sessions (team_agent stop, or /teams stop)");
|
|
246
421
|
return { cancel: true as const };
|
|
247
422
|
}
|
|
@@ -262,12 +437,12 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
262
437
|
handler: async ctx => { if (ctx.mode === "tui") await host(ctx).visit("root"); },
|
|
263
438
|
});
|
|
264
439
|
pi.registerCommand("teams", {
|
|
265
|
-
description: "Team panel; /teams teammate <name-or-id>, config, back, stop, or
|
|
440
|
+
description: "Team panel; /teams teammate <name-or-id>, config, back, stop, status, join, or leave",
|
|
266
441
|
async getArgumentCompletions(prefix) {
|
|
267
442
|
if (!prefix.includes(" ")) return teamsCompletions(prefix, []);
|
|
268
443
|
if (!context) return null;
|
|
269
444
|
try {
|
|
270
|
-
const members = child || opening ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
|
|
445
|
+
const members = child || opening || connection ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
|
|
271
446
|
return teamsCompletions(prefix, members);
|
|
272
447
|
} catch { return null; }
|
|
273
448
|
},
|
|
@@ -279,13 +454,20 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
279
454
|
catch (error) { ctx.ui.notify(cleanText(String(error)), "error"); }
|
|
280
455
|
return;
|
|
281
456
|
}
|
|
457
|
+
if (args.trim() === "join" || args.trim() === "leave") {
|
|
458
|
+
try { await (args.trim() === "join" ? joinTeam(ctx) : leaveTeam(ctx)); }
|
|
459
|
+
catch (error) { ctx.ui.notify(cleanText(String(error instanceof Error ? error.message : error)), "error"); }
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
282
462
|
if (args.trim() === "stop") {
|
|
283
463
|
if (child) throw new Error("A member can only stop its descendants using team_agent");
|
|
284
|
-
|
|
285
|
-
|
|
464
|
+
await link(ctx);
|
|
465
|
+
// Main sessions keep running; /teams stop stops members only.
|
|
466
|
+
const { members } = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members">;
|
|
467
|
+
for (const member of members.filter(member => member.role === "member" && !member.parent && ["starting", "busy", "idle"].includes(member.status))) await handle("agent", { action: "stop", id: member.id }, ctx);
|
|
286
468
|
}
|
|
287
469
|
if (ctx.mode === "tui" && args.trim() !== "stop" && args.trim() !== "status") {
|
|
288
|
-
if (!child) await
|
|
470
|
+
if (!child) await link(ctx);
|
|
289
471
|
const api = host(ctx);
|
|
290
472
|
if (args.trim() === "back") await api.visit("root");
|
|
291
473
|
else if (/^(teammate|open)\s+/.test(args.trim())) {
|
|
@@ -296,7 +478,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
296
478
|
else ctx.ui.notify(`Unknown teammate: ${cleanText(value)}. Use autocomplete or /teams panel.`, "error");
|
|
297
479
|
}
|
|
298
480
|
else if (!args.trim() || args.trim() === "panel") await showTeamsPanel(ctx, api);
|
|
299
|
-
else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status]", "info");
|
|
481
|
+
else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status|join|leave]", "info");
|
|
300
482
|
} else {
|
|
301
483
|
const status = await handle("agent", { action: "list" }, ctx);
|
|
302
484
|
ctx.ui.notify(JSON.stringify(status, null, 2), "info");
|
package/src/mains.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
/** Pi custom entry in a main session's transcript: which team it belongs to. Holds no secrets. */
|
|
6
|
+
export const teamEntryType = "ac-teams-team";
|
|
7
|
+
export interface TeamEntry { team: string; member: string; left?: boolean }
|
|
8
|
+
export const handoverPrompt = "Your process was restarted during a coordinator handover. Check your last steps and continue your current task.";
|
|
9
|
+
|
|
10
|
+
/** A main session's environment: no member identity, member role, or member Herdr opt-out. */
|
|
11
|
+
export function mainEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
12
|
+
const clean: Record<string, string> = {};
|
|
13
|
+
for (const [key, value] of Object.entries(env)) {
|
|
14
|
+
if (value === undefined || key === "ACSTACK_AGENT_ROLE" || key.startsWith("AC_TEAMS_") || (key === "HERDR_ENV" && value === "0") || key === "TMUX" || key === "TMUX_PANE") continue;
|
|
15
|
+
clean[key] = value;
|
|
16
|
+
}
|
|
17
|
+
return clean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MainLaunch { id: string; name: string; cwd: string; command: string[]; env: Record<string, string>; socket: string }
|
|
21
|
+
/**
|
|
22
|
+
* Opens a main session where the user can switch to it: its own Herdr tab in the workspace for its worktree, or else a
|
|
23
|
+
* tmux session on a socket of its own, so the coordinator owner's tmux kill-server cannot reach it. Returns a user notice.
|
|
24
|
+
*/
|
|
25
|
+
export async function launchMain(spec: MainLaunch): Promise<string> {
|
|
26
|
+
if (spec.env.HERDR_ENV === "1") {
|
|
27
|
+
const herdr = async (...args: string[]) => JSON.parse((await exec("herdr", args, { env: spec.env, timeout: 10000, maxBuffer: 4 * 1024 * 1024 })).stdout);
|
|
28
|
+
const workspaces = (await herdr("workspace", "list")).result?.workspaces as { workspace_id: string; worktree?: { checkout_path?: string } }[] | undefined;
|
|
29
|
+
const workspace = workspaces?.find(entry => entry.worktree?.checkout_path === spec.cwd);
|
|
30
|
+
const created = workspace
|
|
31
|
+
? await herdr("tab", "create", "--workspace", workspace.workspace_id, "--cwd", spec.cwd, "--label", spec.name, "--focus")
|
|
32
|
+
: await herdr("workspace", "create", "--cwd", spec.cwd, "--label", spec.name, "--focus");
|
|
33
|
+
const pane = created.result?.root_pane?.pane_id;
|
|
34
|
+
if (typeof pane !== "string") throw new Error("Herdr did not report the new tab's pane");
|
|
35
|
+
// Herdr types the command into the pane's shell, so each word is quoted for it.
|
|
36
|
+
await exec("herdr", ["pane", "run", pane, spec.command.map(word => `'${word.replaceAll("'", "'\\''")}'`).join(" ")], { env: spec.env, timeout: 10000 });
|
|
37
|
+
return `${spec.name} opened in its own Herdr tab`;
|
|
38
|
+
}
|
|
39
|
+
await exec("tmux", ["-S", spec.socket, "new-session", "-d", "-s", spec.id, "-c", spec.cwd, "--", ...spec.command], { env: spec.env, timeout: 10000 });
|
|
40
|
+
return `${spec.name} runs in tmux; attach with: tmux -S ${spec.socket} attach -t ${spec.id}`;
|
|
41
|
+
}
|
package/src/panel.ts
CHANGED
|
@@ -93,7 +93,9 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
93
93
|
try {
|
|
94
94
|
const member = snapshot.members.find(member => member.id === id);
|
|
95
95
|
if (member && ["stopped", "interrupted"].includes(member.status)) await host.resume(id);
|
|
96
|
-
|
|
96
|
+
// Main sessions run in their own terminals; resuming one opens it there.
|
|
97
|
+
if (member?.role === "main" && id !== host.self) receipt = `${name(id)} runs in its own terminal`;
|
|
98
|
+
else done(id);
|
|
97
99
|
} catch (error) { receipt = `Could not open ${name(id)}: ${clean(String(error))}`; }
|
|
98
100
|
finally { opening = false; refresh(); }
|
|
99
101
|
}
|
|
@@ -237,6 +239,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
237
239
|
heading("Message with full context"), "Open the native Pi session to read its conversation and type your message there.", "",
|
|
238
240
|
fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open full session"),
|
|
239
241
|
fg("muted", "No separate message composer."),
|
|
242
|
+
...(!host.child && member?.role === "member" ? ["", fg("accent", "P Promote to main session"), fg("muted", "Stops it and reopens its transcript as a main session in its own terminal.")] : []),
|
|
240
243
|
], width);
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -294,6 +297,11 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
294
297
|
} else if (data.toLowerCase() === "g" && tab === 0 && !detail) { graph = !graph; scroll = 0; }
|
|
295
298
|
else if (data.toLowerCase() === "n") begin("new");
|
|
296
299
|
else if (data.toLowerCase() === "c" && tab === 0) begin("feedback");
|
|
300
|
+
else if (data.toLowerCase() === "p" && tab === 1 && !host.child) {
|
|
301
|
+
const id = memberIds()[memberIndex];
|
|
302
|
+
if (snapshot.members.find(member => member.id === id)?.role !== "member") receipt = "Only a member can be promoted.";
|
|
303
|
+
else { done(undefined); void host.promote(id).catch(error => ctx.ui.notify(`Promotion failed: ${clean(String(error))}`, "error")); return; }
|
|
304
|
+
}
|
|
297
305
|
else if (matchesKey(data, "enter") || data.toLowerCase() === "o") {
|
|
298
306
|
if (tab === 1 || data.toLowerCase() === "o") {
|
|
299
307
|
const id = tab === 1 ? memberIds()[memberIndex] : tab === 0 ? selectedTask()?.owner : events[eventIndex]?.from;
|
package/src/schemas.ts
CHANGED
|
@@ -37,8 +37,8 @@ export const MessageSchema = Type.Object({
|
|
|
37
37
|
after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
38
38
|
}, { additionalProperties: false });
|
|
39
39
|
const UserActionSchema = Type.Object({
|
|
40
|
-
action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume")]),
|
|
41
|
-
id: Type.Optional(id), text: Type.Optional(text),
|
|
40
|
+
action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume"), Type.Literal("promote"), Type.Literal("leave")]),
|
|
41
|
+
id: Type.Optional(id), text: Type.Optional(text), confirmed: Type.Optional(Type.Boolean()),
|
|
42
42
|
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
43
43
|
thinking: AgentSchema.properties.thinking,
|
|
44
44
|
}, { additionalProperties: false });
|