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/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 self = environment[1] ?? "root";
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("root", through);
56
+ else coordinator?.ack(coordinator.self, through);
42
57
  };
43
- async function root(ctx: ExtensionContext): Promise<Coordinator> {
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.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(), sessionDir: ctx.sessionManager.getSessionDir(),
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 (ctx.isIdle?.() === false) value.recordEvent("root", { type: "agent_start" });
67
- if (inputCount > 0) value.recordEvent("root", { type: "blocked", active: true });
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
- if (child) {
84
- if (!connection) throw new Error("Team coordinator is not connected");
85
- return connection.request(operation, args);
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 (await root(ctx)).handle("root", operation, args, authorized);
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
- if (child) {
111
- if (!connection) throw new Error("Team coordinator is not connected");
112
- return await connection.request("ui", value) as string;
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 === self) return;
120
- const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join((await root(ctx)).store.dir, "t.sock");
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 (child && ctx.mode === "tui") void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
133
- else if (!child) coordinator?.recordEvent("root", event);
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 (child) await connection?.request("event", event);
145
- else coordinator?.recordEvent("root", event);
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 (child) void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
161
- else coordinator?.recordEvent("root", event);
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 && coordinator && coordinator.store.state.id !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
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
- connection = new Connection(environment[0]!, environment[1]!, environment[2]!);
207
- connection.on("notice", (through, messages) => notify(through, messages));
208
- connection.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
209
- connection.on("command", (command: { id: string; command: string; args: Record<string, unknown> }) => {
210
- void (async () => {
211
- let data: unknown = {}, error: string | undefined;
212
- try {
213
- 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() };
214
- else if (command.command === "prompt") {
215
- if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
216
- pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
217
- } else if (command.command === "abort") ctx.abort();
218
- else if (command.command !== "shutdown") throw new Error("Unsupported native command");
219
- } catch (failure) { error = String(failure).slice(0, 2000); }
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 status",
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
- const team = await root(ctx);
285
- for (const member of [...team.live()].sort((a, b) => b.depth - a.depth)) await team.stopTree(member.id);
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 root(ctx);
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
- done(id);
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 });