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.
@@ -1,10 +1,11 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
- import { chmodSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
- import { delimiter, dirname, join, resolve } from "node:path";
7
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
8
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { assertNarrower, assertStableGrants, mountSet, within, containerProblems, dockerCreateArgs, sandboxMounts, maxSandboxed, memberImage, packageRoot, readable, resolveAccess, sandboxStatus, type Access, type SandboxPlan, type SandboxStatus } from "./access.ts";
10
11
  import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.ts";
@@ -15,7 +16,8 @@ import { TerminalAgent, tmux } from "./terminal-agent.ts";
15
16
  import { authentic, isRecord, readLines, send } from "./connection.ts";
16
17
  import { validate } from "./schemas.ts";
17
18
  import { createWorktree, removeWorktree } from "./worktrees.ts";
18
- import { isDescendant, isLive, taskName, type AgentArgs, type Member, type MessageArgs, type TaskArgs, type TeamState, type UserAction, type Message, type ContextUsage } from "./types.ts";
19
+ import { launchMain, mainEnv, teamEntryType, handoverPrompt, type MainLaunch, type TeamEntry } from "./mains.ts";
20
+ import { canControl, isDescendant, isLive, isMain, memberName, taskName, type AgentArgs, type Member, type MessageArgs, type TaskArgs, type TeamState, type UserAction, type Message, type ContextUsage } from "./types.ts";
19
21
 
20
22
  export const taskRequestGuidance = [
21
23
  "Treat user task requests as proposals to assess, not commands to create tasks.",
@@ -34,7 +36,18 @@ export function assistantEvent(message: unknown): { type: string; text: string }
34
36
  return text ? { type: "summary", text } : undefined;
35
37
  }
36
38
  export interface CoordinatorOptions {
39
+ /** Team ID: the session ID of the session that created the team. */
37
40
  id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
41
+ /** This session's member ID in the team (default "root", the creator); sessionId defaults to the team ID. */
42
+ self?: string; sessionId?: string;
43
+ /** Add this session to an existing team as a new main session (/teams join with no coordinator running). */
44
+ join?: boolean;
45
+ /** Another main session asked this owner session to shut down. */
46
+ shutdown?: () => void;
47
+ /** Test hook: opens a main session's terminal (default: a Herdr tab, else tmux). */
48
+ launchMain?: (spec: MainLaunch) => Promise<string>;
49
+ /** Test hook: runs after each recorded promotion step. */
50
+ promotionStep?: (step: string) => Promise<void> | void;
38
51
  extension: string; home?: string; command?: string;
39
52
  interactive?: boolean; model?: string; thinking?: string;
40
53
  /** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
@@ -73,10 +86,13 @@ export class Coordinator {
73
86
  private sandbox?: { status: SandboxStatus; at: number };
74
87
 
75
88
  private options: CoordinatorOptions;
89
+ /** The main session hosting this coordinator. */
90
+ readonly self: string;
76
91
  private constructor(options: CoordinatorOptions) {
77
92
  this.options = options;
78
- const root: Member = { id: "root", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile, model: options.model, thinking: options.thinking };
79
- const initial: TeamState = { version: 3, id: options.id, root: "root", limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
93
+ this.self = options.self ?? "root";
94
+ const root: Member = { id: "root", role: "main", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile, model: options.model, thinking: options.thinking };
95
+ const initial: TeamState = { version: 4, id: options.id, key: randomUUID(), limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
80
96
  this.store = new Store(initial, options.home);
81
97
  this.socketPath = join(this.store.dir, "coordinator.sock");
82
98
  }
@@ -84,7 +100,7 @@ export class Coordinator {
84
100
  const coordinator = new Coordinator(options);
85
101
  try {
86
102
  for (const member of Object.values(coordinator.store.state.members)) {
87
- if (member.id !== "root" && isLive(member) && member.pid && processAlive(member.pid, member.identity)) {
103
+ if (!isMain(member) && isLive(member) && member.pid && processAlive(member.pid, member.identity)) {
88
104
  throw new Error(`Previous member ${member.id} is still live or uncertain; stop it explicitly before recovery`);
89
105
  }
90
106
  }
@@ -92,19 +108,25 @@ export class Coordinator {
92
108
  state.limit = options.limit; state.depth = options.depth;
93
109
  const names = new Set(Object.values(state.members).map(member => member.name));
94
110
  let number = 0;
111
+ if (options.join) coordinator.addMain(state, coordinator.self, options.sessionId ?? options.id, options.sessionFile, options.cwd);
112
+ const self = state.members[coordinator.self];
113
+ if (!isMain(self)) throw new Error("This session is not a main session of the team");
95
114
  for (const member of Object.values(state.members)) if (member.id !== "root" && !member.name) {
96
115
  do { number++; } while (names.has(`teammate-${number}`) || Object.hasOwn(state.members, `teammate-${number}`));
97
116
  member.name = `teammate-${number}`; names.add(member.name);
98
117
  }
99
- state.members.root.status = "idle";
100
- state.members.root.sessionFile = options.sessionFile;
101
- state.members.root.model = options.model;
102
- state.members.root.thinking = options.thinking;
118
+ self.status = "idle"; delete self.error;
119
+ self.sessionFile = options.sessionFile ?? self.sessionFile;
120
+ self.model = options.model;
121
+ self.thinking = options.thinking;
103
122
  for (const member of Object.values(state.members)) {
104
123
  delete member.needsInput;
105
- if (member.id !== "root" && isLive(member)) member.status = "interrupted";
124
+ // Other main sessions reconnect as clients; until then they are not running here.
125
+ if (member.id !== coordinator.self && isLive(member)) member.status = "interrupted";
106
126
  }
107
127
  });
128
+ // A promotion cut short by a crash: return the member to where it started.
129
+ for (const member of Object.values(coordinator.store.state.members)) if (member.promotion) coordinator.rollback(member.id, "Promotion was interrupted");
108
130
  // Owner lock held and no member process alive: containers left by a killed coordinator are orphans of this store.
109
131
  for (const member of Object.values(coordinator.store.state.members)) if (member.access) {
110
132
  const name = coordinator.containerName(member.id);
@@ -119,7 +141,10 @@ export class Coordinator {
119
141
  coordinator.server!.listen(coordinator.socketPath, () => { chmodSync(coordinator.socketPath, 0o600); done(); });
120
142
  });
121
143
  coordinator.server.on("error", error => options.warning(error.message));
122
- coordinator.notify("root");
144
+ coordinator.notify(coordinator.self);
145
+ const pass = coordinator.resumePass();
146
+ coordinator.operations.add(pass);
147
+ void pass.finally(() => coordinator.operations.delete(pass));
123
148
  return coordinator;
124
149
  } catch (error) { coordinator.server?.close(); coordinator.store.close(); throw error; }
125
150
  }
@@ -133,7 +158,12 @@ export class Coordinator {
133
158
  if (bound && this.sockets.get(bound) === socket) {
134
159
  this.sockets.delete(bound);
135
160
  if (!this.closed) {
136
- this.store.update(state => { delete state.members[bound!].needsInput; });
161
+ this.store.update(state => {
162
+ const member = state.members[bound!];
163
+ delete member.needsInput;
164
+ // A main session is not a coordinator process: its socket is its liveness.
165
+ if (isMain(member) && isLive(member)) member.status = member.status === "stopping" ? "stopped" : "interrupted";
166
+ });
137
167
  this.publishStatus();
138
168
  }
139
169
  for (const [id, pending] of this.controls) if (pending.member === bound) {
@@ -143,8 +173,20 @@ export class Coordinator {
143
173
  });
144
174
  readLines(socket, value => {
145
175
  if (!isRecord(value) || typeof value.id !== "string" || value.id.length > 128 || typeof value.member !== "string" || typeof value.token !== "string" || typeof value.operation !== "string") { socket.destroy(); return; }
176
+ if (value.operation === "add_main" && !bound && !this.closed && authentic(value.token, this.store.state.key)) {
177
+ // The team key sits in the private store, so only an unsandboxed session of this user can join as a main session.
178
+ const args = value.args;
179
+ void Promise.resolve().then(() => {
180
+ if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.cwd !== "string" || (args.sessionFile !== undefined && typeof args.sessionFile !== "string")) throw new Error("Invalid join request");
181
+ const id = randomUUID().slice(0, 16);
182
+ this.store.update(state => this.addMain(state, id, args.sessionId as string, args.sessionFile as string | undefined, args.cwd as string));
183
+ return { id, token: this.store.state.members[id].token };
184
+ }).then(data => send(socket, { id: value.id, data }), error => send(socket, { id: value.id, error: String(error instanceof Error ? error.message : error) }));
185
+ return;
186
+ }
146
187
  const member = this.store.state.members[value.member];
147
- if ((this.closed && value.operation !== "control_result") || !member || !isLive(member) || !authentic(value.token, member.token) || member.id === "root" || (bound && bound !== member.id)) { socket.destroy(); return; }
188
+ const main = isMain(member);
189
+ if ((this.closed && value.operation !== "control_result") || !member || !(main || isLive(member)) || !authentic(value.token, member.token) || member.id === this.self || (bound && bound !== member.id)) { socket.destroy(); return; }
148
190
  bound = member.id;
149
191
  const operation = value.operation;
150
192
  const run = async () => {
@@ -153,6 +195,19 @@ export class Coordinator {
153
195
  if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
154
196
  const args = value.args;
155
197
  if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.sessionFile !== "string" || !Array.isArray(args.tools) || !["team_agent", "team_task", "team_message"].every(name => Array.isArray(args.tools) && args.tools.includes(name))) throw new Error("Incomplete ac-teams readiness handshake");
198
+ if (main) {
199
+ // A main session's identity never changes; its transcript may live anywhere the user keeps sessions.
200
+ if (member.sessionId && args.sessionId !== member.sessionId) throw new Error("This main session's record belongs to another Pi session");
201
+ this.store.update(state => {
202
+ const current = state.members[member.id];
203
+ current.status = "idle"; delete current.error;
204
+ current.sessionId = args.sessionId as string; current.sessionFile = args.sessionFile as string;
205
+ });
206
+ this.sockets.set(member.id, socket);
207
+ this.publishStatus();
208
+ this.notify(member.id);
209
+ return { id: this.store.state.id, member: this.publicMember(this.store.state.members[member.id]), limit: this.store.state.limit, depth: this.store.state.depth };
210
+ }
156
211
  // The session file chooses a host mount on resume, so it must stay in the directory ac-teams assigned.
157
212
  const assigned = this.sessionDirs.get(member.id) ?? this.memberSessionDir(member.id);
158
213
  if (!assigned || !within(resolve(args.sessionFile as string), assigned)) throw new Error("Member session file is outside its assigned directory");
@@ -230,8 +285,8 @@ export class Coordinator {
230
285
  else if (event.type === "model_error") current.error = event.text || "Model request failed";
231
286
  });
232
287
  this.publishStatus();
233
- if (event.type === "model_error" && actor !== "root" && member.status !== "stopping") {
234
- const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : "root";
288
+ if (event.type === "model_error" && !isMain(member) && member.status !== "stopping") {
289
+ const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : this.self;
235
290
  this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
236
291
  this.notify(recipient);
237
292
  }
@@ -243,7 +298,7 @@ export class Coordinator {
243
298
  if (!member) throw new Error("Unknown member ID");
244
299
  if (member.status === "stopping") throw new Error("Member is stopping; retry after it stops");
245
300
  if (isLive(member)) return;
246
- const launch = this.launch("root", { action: "resume", id, brief: "Resume the native session without replaying work.", model: member.model ?? args.model, thinking: member.thinking ?? args.thinking }, false);
301
+ const launch = this.launch(this.self, { action: "resume", id, brief: "Resume the native session without replaying work.", model: member.model ?? args.model, thinking: member.thinking ?? args.thinking }, false);
247
302
  this.operations.add(launch);
248
303
  try { await launch; } finally { this.operations.delete(launch); }
249
304
  };
@@ -251,22 +306,30 @@ export class Coordinator {
251
306
  if (!args.id) throw new Error("A member ID is required");
252
307
  await resume(args.id); return `Resumed ${args.id}`;
253
308
  }
309
+ // Promoting and leaving change authority, so only the user, from a main session's TUI, can do them.
310
+ if (args.action === "promote" || args.action === "leave") {
311
+ if (!isMain(this.store.state.members[actor])) throw new Error("Only a main session can promote members or leave the team");
312
+ const run = args.action === "promote" ? this.promote(args.id ?? "", args.confirmed === true) : Promise.resolve(this.leave(actor));
313
+ this.operations.add(run);
314
+ try { return await run; } finally { this.operations.delete(run); }
315
+ }
254
316
  if (!args.text?.trim()) throw new Error("Write a request or feedback before sending");
255
- let recipient = "root", fallback = "";
317
+ const main = isMain(this.store.state.members[actor]) ? actor : this.self;
318
+ let recipient = main, fallback = "";
256
319
  let text = `User task request (review before changing tasks):\n${taskRequestGuidance}\n\nRequest:\n${args.text}`;
257
320
  if (args.action === "feedback") {
258
321
  const task = this.store.state.tasks[args.id ?? ""];
259
322
  if (!task) throw new Error("Unknown task ID");
260
- recipient = task.creator ?? "root";
323
+ recipient = task.creator && this.store.state.members[task.creator]?.role ? task.creator : main;
261
324
  text = `User feedback on task ${taskName(task)} [${task.id}] (${task.title}):\n${args.text}`;
262
325
  if (!isLive(this.store.state.members[recipient]) || this.store.state.members[recipient].status === "stopping") {
263
326
  try { await resume(recipient); }
264
- catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to Main instead. `; recipient = "root"; }
327
+ catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to ${memberName(this.store.state.members[main])} instead. `; recipient = main; }
265
328
  }
266
329
  }
267
330
  const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
268
331
  this.notify(recipient);
269
- return `${fallback}Sent to ${recipient === "root" ? "Main" : recipient} · message ${message.seq}`;
332
+ return `${fallback}Sent to ${isMain(this.store.state.members[recipient]) ? memberName(this.store.state.members[recipient]) : recipient} · message ${message.seq}`;
270
333
  }
271
334
  private publicMember(member: Member) {
272
335
  const { token: _token, identity: _identity, ...view } = member;
@@ -282,7 +345,8 @@ export class Coordinator {
282
345
  }
283
346
  return this.sandbox.status;
284
347
  }
285
- live(): Member[] { return Object.values(this.store.state.members).filter(member => member.id !== "root" && isLive(member)); }
348
+ /** Live members this coordinator runs; main sessions run in their own terminals and never count. */
349
+ live(): Member[] { return Object.values(this.store.state.members).filter(member => !isMain(member) && isLive(member)); }
286
350
  ack(id: string, through: number): void {
287
351
  const last = this.store.state.messages.at(-1)?.seq ?? 0;
288
352
  if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
@@ -298,7 +362,7 @@ export class Coordinator {
298
362
  const through = pending.at(-1)?.seq;
299
363
  if (!through) return;
300
364
  try {
301
- if (id === "root") this.options.notice(through, pending.slice(-5));
365
+ if (id === this.self) this.options.notice(through, pending.slice(-5));
302
366
  else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
303
367
  } catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
304
368
  }, 50));
@@ -326,7 +390,7 @@ export class Coordinator {
326
390
  const state = this.store.state;
327
391
  if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
328
392
  if (args.action === "limits") {
329
- if (actor !== "root") throw new Error("Only the root can change team limits");
393
+ if (!isMain(state.members[actor])) throw new Error("Only a main session can change team limits");
330
394
  if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
331
395
  this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
332
396
  return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
@@ -339,7 +403,7 @@ export class Coordinator {
339
403
  const member = args.id ? state.members[args.id] : undefined;
340
404
  if (!member) throw new Error("Unknown member ID");
341
405
  if (args.action === "status") return this.publicMember(member);
342
- if (member.id === "root" || !isDescendant(state, member.id, actor)) throw new Error("Only a member's ancestor can stop or clean it up");
406
+ if (!canControl(state, actor, member.id)) throw new Error("Only a main session or a member's ancestor can stop or clean it up");
343
407
  if (args.action === "cleanup") {
344
408
  if (state.members[actor].access) throw new Error("Sandboxed members cannot remove worktrees");
345
409
  if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
@@ -355,7 +419,8 @@ export class Coordinator {
355
419
  this.operations.add(cleanup);
356
420
  try { return await cleanup; } finally { this.cleaning.delete(member.id); this.operations.delete(cleanup); }
357
421
  }
358
- await this.stopTree(member.id);
422
+ if (isMain(member)) await this.stopMain(member.id);
423
+ else await this.stopTree(member.id);
359
424
  return this.publicMember(this.store.state.members[member.id]);
360
425
  }
361
426
  private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
@@ -364,7 +429,8 @@ export class Coordinator {
364
429
  if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
365
430
  if (!args.model || !args.model.includes("/")) throw new Error("A provider/model is required (normally inherited from the requesting member)");
366
431
  const resumed = args.action === "resume" ? state.members[args.id ?? ""] : undefined;
367
- if (args.action === "resume" && (!resumed || resumed.id === "root" || !resumed.sessionFile || !isDescendant(state, resumed.id, actor))) throw new Error("Resume requires a stopped descendant with a native session file");
432
+ if (args.action === "resume" && (!resumed || !resumed.sessionFile || !canControl(state, actor, resumed.id))) throw new Error("Resume requires a stopped member you control with a native session file");
433
+ if (resumed && isMain(resumed)) return this.resumeMain(resumed.id);
368
434
  if (resumed && this.cleaning.has(resumed.id)) throw new Error("Worktree cleanup is running; cannot resume this member");
369
435
  if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
370
436
  const depth = resumed ? resumed.depth : parent.depth + 1;
@@ -388,7 +454,7 @@ export class Coordinator {
388
454
  const provider = args.model.slice(0, args.model.indexOf("/"));
389
455
  const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
390
456
  if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
391
- const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined } : { id, name: args.name, parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
457
+ const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined, handover: undefined } : { id, name: args.name, role: "member", parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
392
458
  // Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
393
459
  this.store.update(next => { next.members[id] = member; });
394
460
  this.publishStatus();
@@ -419,7 +485,8 @@ export class Coordinator {
419
485
  this.sessionDirs.set(id, sessions);
420
486
  const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", sessions, "-e", this.options.extension, "--provider", provider, "--model", modelId, "--thinking", args.thinking ?? "off"];
421
487
  if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
422
- const identity = { HERDR_ENV: "0", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_ACCESS: JSON.stringify(access ? { enforced: true, ...access } : { enforced: false }) };
488
+ // ACSTACK_AGENT_ROLE tells ACStack hooks this is not a main session; children inherit it.
489
+ const identity = { HERDR_ENV: "0", ACSTACK_AGENT_ROLE: "member", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_ACCESS: JSON.stringify(access ? { enforced: true, ...access } : { enforced: false }) };
423
490
  const spawnOptions = access
424
491
  ? await this.sandboxed(id, access, member.cwd, provider, modelId, rpcArgs, identity, sessions)
425
492
  : { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, ...identity, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
@@ -447,7 +514,8 @@ export class Coordinator {
447
514
  if (this.closed) return;
448
515
  this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
449
516
  this.publishStatus();
450
- void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
517
+ // A promoted member keeps its descendants.
518
+ if (!this.store.state.members[id].promotion) void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
451
519
  });
452
520
  if (agent instanceof TerminalAgent) await agent.start();
453
521
  member.pid = agent.process.pid;
@@ -474,6 +542,129 @@ export class Coordinator {
474
542
  throw error;
475
543
  }
476
544
  }
545
+ /** Adds a main session record; the caller writes the team entry into its transcript. */
546
+ private addMain(state: TeamState, id: string, sessionId: string, sessionFile: string | undefined, cwd: string): void {
547
+ if (Object.values(state.members).some(member => member.sessionId === sessionId)) throw new Error("This Pi session is already in the team");
548
+ const names = new Set(Object.values(state.members).map(member => member.name));
549
+ let number = 1;
550
+ do { number++; } while (names.has(`main-${number}`));
551
+ state.members[id] = { id, name: `main-${number}`, role: "main", depth: 0, token: randomUUID(), cwd, status: "stopped", notified: state.messages.at(-1)?.seq ?? 0, sessionId, sessionFile };
552
+ }
553
+ /** Restores members a previous owner stopped to the state it recorded. */
554
+ private async resumePass(): Promise<void> {
555
+ for (const member of Object.values(this.store.state.members)) {
556
+ const state = member.handover;
557
+ if (!state || isLive(member)) continue;
558
+ try {
559
+ if (!member.model) throw new Error("no recorded model");
560
+ await this.launch(this.self, { action: "resume", id: member.id, brief: state === "busy" ? handoverPrompt : "Resume the native session without replaying work.", model: member.model, thinking: member.thinking }, state === "busy");
561
+ if (state === "input") this.recordEvent(member.id, { type: "blocked", active: true });
562
+ } catch (error) {
563
+ if (this.closed) return;
564
+ this.options.warning(`Could not resume ${memberName(member)} after the coordinator handover: ${error instanceof Error ? error.message : String(error)}`);
565
+ if (this.store.state.members[member.id]?.handover) this.store.update(next => { delete next.members[member.id].handover; });
566
+ }
567
+ }
568
+ }
569
+ /** The top-level session folder: Main's session directory without ac-teams nesting. */
570
+ private topSessionDir(): string {
571
+ return resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
572
+ }
573
+ /** Opens a main session's transcript in its own terminal with a clean environment. */
574
+ private async openMain(id: string): Promise<string> {
575
+ const member = this.store.state.members[id];
576
+ const notice = await (this.options.launchMain ?? launchMain)({
577
+ id, name: memberName(member), cwd: member.cwd, socket: join(this.store.dir, `m-${id}.sock`), env: mainEnv(),
578
+ command: [this.options.command ?? process.env.AC_TEAMS_PI ?? "pi", "--session", member.sessionFile!],
579
+ });
580
+ this.store.update(state => { state.members[id].status = "starting"; delete state.members[id].error; });
581
+ return notice;
582
+ }
583
+ private async resumeMain(id: string): Promise<string> {
584
+ const member = this.store.state.members[id];
585
+ if (isLive(member) || this.sockets.has(id)) throw new Error("That main session is already running");
586
+ return this.openMain(id);
587
+ }
588
+ private async stopMain(id: string): Promise<void> {
589
+ if (id === this.self) {
590
+ if (!this.options.shutdown) throw new Error("This main session cannot be stopped remotely");
591
+ this.options.shutdown(); return;
592
+ }
593
+ if (!this.sockets.has(id)) throw new Error("That main session is not running");
594
+ this.store.update(state => { state.members[id].status = "stopping"; });
595
+ await this.control(id, "shutdown");
596
+ }
597
+ /** Stops a member and relaunches its transcript as a main session. Each step is recorded so a failure can roll back. */
598
+ private async promote(id: string, confirmed: boolean): Promise<string> {
599
+ const member = this.store.state.members[id];
600
+ if (!member || member.role !== "member") throw new Error("Only a member can be promoted");
601
+ if (!member.sessionFile) throw new Error("The member has no native session file yet");
602
+ if (member.promotion) throw new Error("The member is already being promoted");
603
+ if (member.access && !confirmed) throw new Error("Promoting a sandboxed member runs it with your full access; confirm before promoting");
604
+ const original = member.sessionFile;
605
+ const step = async (name: NonNullable<Member["promotion"]>["step"], change: (current: Member) => void = () => {}) => {
606
+ this.store.update(state => { const current = state.members[id]; change(current); current.promotion = { ...current.promotion!, step: name }; });
607
+ await this.options.promotionStep?.(name);
608
+ };
609
+ this.store.update(state => { state.members[id].promotion = { step: "stopped", sessionFile: original, parent: member.parent, access: member.access, provider: member.provider }; });
610
+ try {
611
+ await this.stopOne(id);
612
+ await step("stopped");
613
+ const target = join(this.topSessionDir(), basename(original));
614
+ if (existsSync(target)) throw new Error(`A session file already exists at ${target}`);
615
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
616
+ moveFile(original, target);
617
+ await step("moved", current => { current.sessionFile = target; });
618
+ rmSync(memberConfigDir(id), { recursive: true, force: true });
619
+ await step("role", current => {
620
+ current.role = "main"; current.depth = 0; current.token = randomUUID();
621
+ delete current.parent; delete current.access; delete current.provider; delete current.handover;
622
+ });
623
+ this.store.update(recomputeDepths);
624
+ const entry: TeamEntry = { team: this.store.state.id, member: id };
625
+ SessionManager.open(target).appendCustomEntry(teamEntryType, entry);
626
+ await step("entry");
627
+ const notice = await this.openMain(id);
628
+ this.store.update(state => { delete state.members[id].promotion; });
629
+ return `Promoted ${memberName(member)} to a main session. ${notice}`;
630
+ } catch (error) {
631
+ this.rollback(id, error instanceof Error ? error.message : String(error));
632
+ throw error;
633
+ }
634
+ }
635
+ /** Returns a partly promoted member to a stopped member with its original transcript path. */
636
+ private rollback(id: string, reason: string): void {
637
+ const promotion = this.store.state.members[id].promotion!;
638
+ const current = this.store.state.members[id].sessionFile;
639
+ try {
640
+ if (current && current !== promotion.sessionFile && existsSync(current)) {
641
+ // The moved transcript may already claim main-session membership; record the departure first.
642
+ if (promotion.step === "entry") SessionManager.open(current).appendCustomEntry(teamEntryType, { team: this.store.state.id, member: id, left: true } satisfies TeamEntry);
643
+ moveFile(current, promotion.sessionFile);
644
+ }
645
+ } catch (error) { this.options.warning(`Promotion rollback could not restore ${promotion.sessionFile}: ${String(error)}`); }
646
+ this.store.update(state => {
647
+ const member = state.members[id];
648
+ Object.assign(member, { role: "member", sessionFile: promotion.sessionFile, parent: promotion.parent, access: promotion.access, provider: promotion.provider, status: "stopped", error: `Promotion failed: ${reason}`.slice(0, 2000) });
649
+ for (const key of ["parent", "access", "provider"] as const) if (member[key] === undefined) delete member[key];
650
+ if (member.parent && !state.members[member.parent]) delete member.parent;
651
+ delete member.promotion;
652
+ recomputeDepths(state);
653
+ });
654
+ }
655
+ /** A main session leaves: its in-progress tasks return to todo and its record keeps history without a role. */
656
+ private leave(actor: string): string {
657
+ const state = this.store.state;
658
+ const others = Object.values(state.members).filter(member => isMain(member) && member.id !== actor);
659
+ if (!others.length && this.live().length) throw new Error("You are the only main session and members are still live; stop them first");
660
+ this.store.update(next => {
661
+ for (const task of Object.values(next.tasks)) if (task.owner === actor && task.status !== "done") { task.status = "todo"; delete task.owner; }
662
+ const member = next.members[actor];
663
+ delete member.role;
664
+ member.status = "stopped";
665
+ });
666
+ return `Left team ${state.id}`;
667
+ }
477
668
  private image() {
478
669
  return memberImage(this.options.piVersion ?? JSON.parse(readFileSync(join(packageRoot(command("pi")), "package.json"), "utf8")).version);
479
670
  }
@@ -624,6 +815,13 @@ export class Coordinator {
624
815
  private async closeOnce(): Promise<void> {
625
816
  this.closed = true;
626
817
  for (const timer of this.notices.values()) clearTimeout(timer);
818
+ // The next owner's resume pass restores each member to the state recorded here.
819
+ this.store.update(state => {
820
+ for (const member of Object.values(state.members)) if (!isMain(member) && isLive(member) && member.status !== "stopping") {
821
+ member.handover = member.needsInput ? "input" : member.status === "idle" ? "idle" : "busy";
822
+ }
823
+ if (state.members[this.self]?.status !== undefined && isLive(state.members[this.self])) state.members[this.self].status = "stopped";
824
+ });
627
825
  for (const member of this.live().sort((a, b) => b.depth - a.depth)) await this.stopOne(member.id);
628
826
  // Finish recording worktree operations already in flight before the store closes.
629
827
  await Promise.allSettled([...this.operations]);
@@ -644,6 +842,19 @@ export class Coordinator {
644
842
 
645
843
  const memberConfigDir = (id: string) => join(stateRoot(), "agents", id);
646
844
 
845
+ function moveFile(from: string, to: string): void {
846
+ try { renameSync(from, to); }
847
+ catch (error) {
848
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
849
+ copyFileSync(from, to, constants.COPYFILE_EXCL); unlinkSync(from);
850
+ }
851
+ }
852
+ /** Depth follows ancestry, so a promotion moves the promoted member's descendants up. */
853
+ function recomputeDepths(state: TeamState): void {
854
+ const depth = (member: Member, seen = 0): number => member.parent && state.members[member.parent] && seen < 64 ? depth(state.members[member.parent], seen + 1) + 1 : 0;
855
+ for (const member of Object.values(state.members)) member.depth = depth(member);
856
+ }
857
+
647
858
  function command(name: string): string {
648
859
  if (name.includes("/")) return realpathSync(name);
649
860
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {