ac-teams 0.0.7 → 0.0.8

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" }));
@@ -196,34 +300,104 @@ export default function teams(pi: ExtensionAPI) {
196
300
  disposeUI?.(); disposeUI = undefined;
197
301
  connection?.close(); connection = undefined;
198
302
  if (opening) await (await opening).close();
199
- coordinator = undefined; opening = undefined;
303
+ coordinator = undefined; opening = undefined; linkedSession = undefined;
304
+ }
305
+ /** A resumed main session reconnects to its team, or offers to run the coordinator and resume the team's members. */
306
+ async function rejoin(ctx: ExtensionContext) {
307
+ try {
308
+ if (await connectMain(ctx)) return;
309
+ const dir = join(teamsHome(), teamId(ctx));
310
+ if (!existsSync(join(dir, "state.json"))) return;
311
+ const state = readState(dir);
312
+ if (!isMain(state.members[memberId()])) { disable("This session is no longer a main session of its team; use /teams join"); return; }
313
+ const others = Object.values(state.members).filter(member => member.id !== memberId() && member.role);
314
+ if (!ctx.hasUI || !others.length) return;
315
+ 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);
316
+ else disable("You declined to run the team coordinator; team tools are off in this session. Resume the session again to take over.");
317
+ } catch (error) { warn(cleanText(String(error))); }
318
+ }
319
+ async function repository(cwd: string): Promise<string> {
320
+ try { return (await promisify(execFile)("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 })).stdout.trim(); }
321
+ catch { return cwd; }
322
+ }
323
+ /** /teams join: adds this session to another team in the same project as a main session. */
324
+ async function joinTeam(ctx: ExtensionContext) {
325
+ if (child) throw new Error("A member cannot join another team");
326
+ if (!ctx.hasUI) throw new Error("/teams join requires an interactive session");
327
+ const sessionId = ctx.sessionManager.getSessionId(), home = teamsHome();
328
+ const current = existsSync(join(home, teamId(ctx), "state.json")) ? readState(join(home, teamId(ctx))) : undefined;
329
+ 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");
330
+ const repo = await repository(ctx.cwd), teams: TeamState[] = [];
331
+ for (const name of existsSync(home) ? readdirSync(home) : []) {
332
+ try {
333
+ const state = readState(join(home, name)), main = Object.values(state.members).find(isMain);
334
+ if (state.id !== current?.id && main && await repository(main.cwd) === repo) teams.push(state);
335
+ } catch { /* Unreadable or foreign team folders are not offered. */ }
336
+ }
337
+ if (!teams.length) { ctx.ui.notify("No other teams in this project", "info"); return; }
338
+ const describe = (state: TeamState) => {
339
+ const members = Object.values(state.members);
340
+ 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)}`;
341
+ };
342
+ const labels = teams.map(describe), choice = await ctx.ui.select("Join which team?", labels);
343
+ const chosen = teams[labels.indexOf(choice ?? "")];
344
+ if (!chosen) return;
345
+ if (Object.values(chosen.members).some(member => member.sessionId === sessionId)) throw new Error("This session is already in that team");
346
+ await close(); exiting = false; enable();
347
+ const dir = join(home, chosen.id), probe = new Connection(join(dir, "coordinator.sock"), "join", chosen.key);
348
+ let running = false;
349
+ try { await probe.open(); running = true; } catch { /* No coordinator: this session runs it. */ }
350
+ if (running) {
351
+ try {
352
+ const added = await probe.request("add_main", { sessionId, sessionFile: ctx.sessionManager.getSessionFile(), cwd: ctx.cwd }) as { id: string };
353
+ remember({ team: chosen.id, member: added.id });
354
+ } finally { probe.close(); }
355
+ if (!await connectMain(ctx)) throw new Error("The team coordinator stopped while joining; resume this session to retry");
356
+ } else {
357
+ const member = randomUUID().slice(0, 16);
358
+ await root(ctx, { id: chosen.id, member });
359
+ remember({ team: chosen.id, member });
360
+ }
361
+ const state = readState(dir), name = (id?: string) => id ? memberName(state.members[id] ?? { id }) : "unowned";
362
+ const open = Object.values(state.tasks).filter(task => task.status !== "done");
363
+ const recent = state.messages.slice(-5);
364
+ pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
365
+ `Joined team ${state.id} as ${name(team!.member)}, a main session.`,
366
+ open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
367
+ recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
368
+ ].join("\n\n") }, { triggerTurn: false });
369
+ }
370
+ /** /teams leave: hands this session's work back and drops its main-session role. */
371
+ async function leaveTeam(ctx: ExtensionContext) {
372
+ if (child) throw new Error("A member cannot leave its team; its parent stops it");
373
+ await link(ctx);
374
+ const left = { team: teamId(ctx), member: memberId(), left: true };
375
+ const result = connection ? await connection.request("ui", { action: "leave" }) : await coordinator!.handle(coordinator!.self, "ui", { action: "leave" });
376
+ pi.appendEntry(teamEntryType, left);
377
+ team = undefined;
378
+ await close(); exiting = false;
379
+ disable("This session left its team; use /teams join to join one");
380
+ ctx.ui.notify(String(result), "info");
200
381
  }
201
382
  pi.on("session_start", async (_event, ctx) => {
202
- if (!child && coordinator && coordinator.store.state.id !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
383
+ if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
203
384
  context = ctx; exiting = false; inputCount = 0;
204
385
  if (child) {
205
386
  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() });
387
+ const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
388
+ connection = link;
389
+ link.on("notice", (through, messages) => notify(through, messages));
390
+ link.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
391
+ link.on("command", onCommand(ctx, link));
392
+ await link.open();
393
+ await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
394
+ } else if (!linkedSession) {
395
+ const entry = ctx.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === teamEntryType).at(-1);
396
+ const data = entry?.type === "custom" ? entry.data as TeamEntry : undefined;
397
+ team = data && !data.left ? { team: data.team, member: data.member } : undefined;
398
+ disabled = undefined;
399
+ if (data?.left) disable("This session left its team; use /teams join to join one");
400
+ else if (team) await rejoin(ctx);
227
401
  }
228
402
  startTelemetry(ctx);
229
403
  if (!warned) {
@@ -241,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
241
415
  });
242
416
  pi.on("session_shutdown", close);
243
417
  const switchGuard = () => {
244
- if (child || coordinator?.live().length) {
418
+ if (child || coordinator?.live().length || connection) {
245
419
  warn("Stop team members before switching/forking/traversing sessions (team_agent stop, or /teams stop)");
246
420
  return { cancel: true as const };
247
421
  }
@@ -262,12 +436,12 @@ export default function teams(pi: ExtensionAPI) {
262
436
  handler: async ctx => { if (ctx.mode === "tui") await host(ctx).visit("root"); },
263
437
  });
264
438
  pi.registerCommand("teams", {
265
- description: "Team panel; /teams teammate <name-or-id>, config, back, stop, or status",
439
+ description: "Team panel; /teams teammate <name-or-id>, config, back, stop, status, join, or leave",
266
440
  async getArgumentCompletions(prefix) {
267
441
  if (!prefix.includes(" ")) return teamsCompletions(prefix, []);
268
442
  if (!context) return null;
269
443
  try {
270
- const members = child || opening ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
444
+ const members = child || opening || connection ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
271
445
  return teamsCompletions(prefix, members);
272
446
  } catch { return null; }
273
447
  },
@@ -279,13 +453,20 @@ export default function teams(pi: ExtensionAPI) {
279
453
  catch (error) { ctx.ui.notify(cleanText(String(error)), "error"); }
280
454
  return;
281
455
  }
456
+ if (args.trim() === "join" || args.trim() === "leave") {
457
+ try { await (args.trim() === "join" ? joinTeam(ctx) : leaveTeam(ctx)); }
458
+ catch (error) { ctx.ui.notify(cleanText(String(error instanceof Error ? error.message : error)), "error"); }
459
+ return;
460
+ }
282
461
  if (args.trim() === "stop") {
283
462
  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);
463
+ await link(ctx);
464
+ // Main sessions keep running; /teams stop stops members only.
465
+ const { members } = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members">;
466
+ 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
467
  }
287
468
  if (ctx.mode === "tui" && args.trim() !== "stop" && args.trim() !== "status") {
288
- if (!child) await root(ctx);
469
+ if (!child) await link(ctx);
289
470
  const api = host(ctx);
290
471
  if (args.trim() === "back") await api.visit("root");
291
472
  else if (/^(teammate|open)\s+/.test(args.trim())) {
@@ -296,7 +477,7 @@ export default function teams(pi: ExtensionAPI) {
296
477
  else ctx.ui.notify(`Unknown teammate: ${cleanText(value)}. Use autocomplete or /teams panel.`, "error");
297
478
  }
298
479
  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");
480
+ else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status|join|leave]", "info");
300
481
  } else {
301
482
  const status = await handle("agent", { action: "list" }, ctx);
302
483
  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 });
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { stateRoot } from "./config.ts";
5
5
  import { validatePolicy } from "./egress.ts";
6
6
  import { stripVTControlCharacters } from "node:util";
7
- import { isDescendant, isLive, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isLive, isMain, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
8
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
9
9
 
10
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -54,7 +54,18 @@ function taskView(state: TeamState, task: Task): TaskView {
54
54
  }
55
55
 
56
56
  function migrate(state: unknown): boolean {
57
- if (!object(state) || (state.version !== 1 && state.version !== 2)) return false;
57
+ if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
58
+ if (state.version !== 3) migrateTasks(state);
59
+ // Schema 4: roles replace the special root ID; the creator's record becomes a main session.
60
+ const root = state.root ?? "root";
61
+ requireValue(object(state.members) && typeof root === "string" && Object.hasOwn(state.members, root), "Invalid legacy root member");
62
+ for (const [key, member] of Object.entries(state.members)) if (object(member)) member.role = key === root ? "main" : "member";
63
+ delete state.root;
64
+ state.key = randomUUID();
65
+ state.version = 4;
66
+ return true;
67
+ }
68
+ function migrateTasks(state: Record<string, unknown>): void {
58
69
  requireValue(object(state.tasks), "Invalid legacy state tasks");
59
70
  let number = 0;
60
71
  for (const task of Object.values(state.tasks)) {
@@ -69,14 +80,12 @@ function migrate(state: unknown): boolean {
69
80
  task.number = ++number;
70
81
  task.label = stripVTControlCharacters(task.title).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ").slice(0, taskLabelMaxLength).trim() || "Task";
71
82
  }
72
- state.version = 3;
73
- return true;
74
83
  }
75
84
 
76
85
  function validate(state: unknown): asserts state is TeamState {
77
86
  requireValue(object(state), "Invalid state snapshot");
78
87
  id(state.id);
79
- requireValue(state.version === 3 && text(state.root) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
88
+ requireValue(state.version === 4 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
80
89
  const members = state.members;
81
90
  const names = new Set<string>(["main", "root", ...Object.keys(members)]);
82
91
  for (const [key, member] of Object.entries(members)) {
@@ -91,6 +100,10 @@ function validate(state: unknown): asserts state is TeamState {
91
100
  const usage = member.contextUsage;
92
101
  requireValue(object(usage) && integer(usage.contextWindow, 1) && (usage.tokens === null || integer(usage.tokens)) && (usage.percent === null || typeof usage.percent === "number" && Number.isFinite(usage.percent) && usage.percent >= 0), "Invalid member context usage");
93
102
  }
103
+ requireValue(member.role === undefined || member.role === "main" || member.role === "member", "Invalid member role");
104
+ requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
105
+ requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
106
+ requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
94
107
  requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
95
108
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
96
109
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
@@ -105,7 +118,6 @@ function validate(state: unknown): asserts state is TeamState {
105
118
  parent = (members[parent] as Record<string, unknown>).parent;
106
119
  }
107
120
  }
108
- requireValue(Object.hasOwn(members, state.root), "Invalid root member");
109
121
  const tasks = state.tasks;
110
122
  const edges: Record<string, string[]> = {};
111
123
  const taskNumbers = new Set<number>();
@@ -156,13 +168,24 @@ function privateFile(path: string, flags: number): number {
156
168
  } catch (error) { closeSync(fd); throw error; }
157
169
  }
158
170
 
171
+ export const teamsHome = () => join(stateRoot(), "teams");
172
+ /** Reads a team snapshot without taking ownership, for sessions that connect to its coordinator as clients. */
173
+ export function readState(dir: string): TeamState {
174
+ const fd = privateFile(join(dir, "state.json"), constants.O_RDONLY);
175
+ try {
176
+ const stored: unknown = JSON.parse(readFileSync(fd, "utf8"));
177
+ migrate(stored); validate(stored);
178
+ return stored;
179
+ } finally { closeSync(fd); }
180
+ }
181
+
159
182
  export class Store {
160
183
  readonly dir: string;
161
184
  state: TeamState;
162
185
  private lock?: number;
163
186
  private closed = false;
164
187
 
165
- constructor(initial: TeamState, home = join(stateRoot(), "teams")) {
188
+ constructor(initial: TeamState, home = teamsHome()) {
166
189
  validate(initial);
167
190
  this.dir = join(resolve(home), initial.id);
168
191
  this.state = structuredClone(initial);
@@ -299,12 +322,12 @@ export class Store {
299
322
  } else if (args.action === "release") {
300
323
  requireValue(task.owner, "Task has no owner to release");
301
324
  const interrupted = ["interrupted", "stopped"].includes(state.members[task.owner].status);
302
- requireValue(task.owner === actor || (interrupted && (actor === state.root || isDescendant(state, task.owner, actor))), "Only the owner can release this task; ancestors may release interrupted descendants");
325
+ requireValue(task.owner === actor || (interrupted && (isMain(state.members[actor]) || isDescendant(state, task.owner, actor))), "Only the owner can release this task; ancestors may release interrupted descendants");
303
326
  if (task.status === "in_progress") task.status = "todo";
304
327
  delete task.owner;
305
328
  } else if (args.action === "reopen") {
306
329
  requireValue(task.status === "done", "Only done tasks can be reopened");
307
- requireValue(!task.owner || task.owner === actor || actor === state.root, "Only the owner or root can reopen an owned task");
330
+ requireValue(!task.owner || task.owner === actor || isMain(state.members[actor]), "Only the owner or a main session can reopen an owned task");
308
331
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
309
332
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
310
333
  task.status = "todo";
@@ -44,7 +44,7 @@ export class TerminalAgent extends EventEmitter {
44
44
  writeFileSync(config, "set -g status off\nset -g prefix None\nset -s extended-keys always\nbind-key -n C-S-Left detach-client\n", { flag: "wx", mode: 0o600 });
45
45
  this.files.push(config);
46
46
  const exports = Object.entries(this.options.env)
47
- .filter(([key, value]) => (key.startsWith("AC_TEAMS_") || key === "HERDR_ENV") && value !== undefined)
47
+ .filter(([key, value]) => (key.startsWith("AC_TEAMS_") || key === "HERDR_ENV" || key === "ACSTACK_AGENT_ROLE") && value !== undefined)
48
48
  .map(([key, value]) => `export ${key}=${quote(value!)}`);
49
49
  const command = this.options.command ?? process.env.AC_TEAMS_PI ?? "pi";
50
50
  writeFileSync(script, [...exports,