ac-teams 0.0.6 → 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,14 +1,23 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
- import { chmodSync, mkdirSync, realpathSync, statSync, unlinkSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
5
  import { randomUUID } from "node:crypto";
4
- import { join, resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
8
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
9
+ import { fileURLToPath } from "node:url";
10
+ import { assertNarrower, assertStableGrants, mountSet, within, containerProblems, dockerCreateArgs, sandboxMounts, maxSandboxed, memberImage, packageRoot, readable, resolveAccess, sandboxStatus, type Access, type SandboxPlan, type SandboxStatus } from "./access.ts";
11
+ import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.ts";
5
12
  import { Store, processAlive, processIdentity } from "./store.ts";
13
+ import { stateRoot } from "./config.ts";
6
14
  import { RpcAgent } from "./rpc-agent.ts";
7
15
  import { TerminalAgent, tmux } from "./terminal-agent.ts";
8
16
  import { authentic, isRecord, readLines, send } from "./connection.ts";
9
17
  import { validate } from "./schemas.ts";
10
18
  import { createWorktree, removeWorktree } from "./worktrees.ts";
11
- 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";
12
21
 
13
22
  export const taskRequestGuidance = [
14
23
  "Treat user task requests as proposals to assess, not commands to create tasks.",
@@ -27,9 +36,30 @@ export function assistantEvent(message: unknown): { type: string; text: string }
27
36
  return text ? { type: "summary", text } : undefined;
28
37
  }
29
38
  export interface CoordinatorOptions {
39
+ /** Team ID: the session ID of the session that created the team. */
30
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;
31
51
  extension: string; home?: string; command?: string;
32
52
  interactive?: boolean; model?: string; thinking?: string;
53
+ /** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
54
+ piVersion?: string;
55
+ /** Test hook: provider token refresh interval (default one minute). */
56
+ refreshMs?: number;
57
+ /** Test hook: runs after a member container passes all checks and before it starts. */
58
+ beforeStart?: (member: string) => Promise<void>;
59
+ /** Main's session directory; member transcripts live under <it>/ac-teams/<team>/<member>. */
60
+ sessionDir?: string;
61
+ /** Parent-side provider credentials for sandboxed members; without it enforced access is unavailable. */
62
+ credentials?: Credentials;
33
63
  notice: (through: number, messages: Message[]) => void;
34
64
  warning: (message: string) => void;
35
65
  status?: (kind: "busy" | "blocked", active: boolean) => void;
@@ -49,12 +79,20 @@ export class Coordinator {
49
79
  private closed = false;
50
80
  private status = { busy: false, blocked: false };
51
81
  private closePromise?: Promise<void>;
82
+ private sandboxes = new Map<string, { egress: Egress; grants: { path: string; fd: number; dev: number; ino: number }[]; release: (go: boolean) => void; refresh: NodeJS.Timeout; token: string }>();
83
+ private plans = new Map<string, SandboxPlan>();
84
+ private releasing = new Map<string, Promise<void>>();
85
+ private sessionDirs = new Map<string, string>();
86
+ private sandbox?: { status: SandboxStatus; at: number };
52
87
 
53
88
  private options: CoordinatorOptions;
89
+ /** The main session hosting this coordinator. */
90
+ readonly self: string;
54
91
  private constructor(options: CoordinatorOptions) {
55
92
  this.options = options;
56
- 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 };
57
- 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: [] };
58
96
  this.store = new Store(initial, options.home);
59
97
  this.socketPath = join(this.store.dir, "coordinator.sock");
60
98
  }
@@ -62,7 +100,7 @@ export class Coordinator {
62
100
  const coordinator = new Coordinator(options);
63
101
  try {
64
102
  for (const member of Object.values(coordinator.store.state.members)) {
65
- 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)) {
66
104
  throw new Error(`Previous member ${member.id} is still live or uncertain; stop it explicitly before recovery`);
67
105
  }
68
106
  }
@@ -70,19 +108,32 @@ export class Coordinator {
70
108
  state.limit = options.limit; state.depth = options.depth;
71
109
  const names = new Set(Object.values(state.members).map(member => member.name));
72
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");
73
114
  for (const member of Object.values(state.members)) if (member.id !== "root" && !member.name) {
74
115
  do { number++; } while (names.has(`teammate-${number}`) || Object.hasOwn(state.members, `teammate-${number}`));
75
116
  member.name = `teammate-${number}`; names.add(member.name);
76
117
  }
77
- state.members.root.status = "idle";
78
- state.members.root.sessionFile = options.sessionFile;
79
- state.members.root.model = options.model;
80
- 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;
81
122
  for (const member of Object.values(state.members)) {
82
123
  delete member.needsInput;
83
- 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";
84
126
  }
85
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");
130
+ // Owner lock held and no member process alive: containers left by a killed coordinator are orphans of this store.
131
+ for (const member of Object.values(coordinator.store.state.members)) if (member.access) {
132
+ const name = coordinator.containerName(member.id);
133
+ const owner = await docker(["inspect", "--format", '{{index .Config.Labels "ac-teams.store"}}', name]).catch(() => "");
134
+ if (owner.trim() === coordinator.store.dir) await docker(["rm", "-f", name]).catch(error => options.warning(`Cannot remove orphaned member container ${name}: ${String(error)}`));
135
+ rmSync(memberConfigDir(member.id), { recursive: true, force: true });
136
+ }
86
137
  try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
87
138
  coordinator.server = createServer(socket => coordinator.accept(socket));
88
139
  await new Promise<void>((done, fail) => {
@@ -90,7 +141,10 @@ export class Coordinator {
90
141
  coordinator.server!.listen(coordinator.socketPath, () => { chmodSync(coordinator.socketPath, 0o600); done(); });
91
142
  });
92
143
  coordinator.server.on("error", error => options.warning(error.message));
93
- 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));
94
148
  return coordinator;
95
149
  } catch (error) { coordinator.server?.close(); coordinator.store.close(); throw error; }
96
150
  }
@@ -104,7 +158,12 @@ export class Coordinator {
104
158
  if (bound && this.sockets.get(bound) === socket) {
105
159
  this.sockets.delete(bound);
106
160
  if (!this.closed) {
107
- 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
+ });
108
167
  this.publishStatus();
109
168
  }
110
169
  for (const [id, pending] of this.controls) if (pending.member === bound) {
@@ -114,8 +173,20 @@ export class Coordinator {
114
173
  });
115
174
  readLines(socket, value => {
116
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
+ }
117
187
  const member = this.store.state.members[value.member];
118
- 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; }
119
190
  bound = member.id;
120
191
  const operation = value.operation;
121
192
  const run = async () => {
@@ -124,6 +195,22 @@ export class Coordinator {
124
195
  if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
125
196
  const args = value.args;
126
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
+ }
211
+ // The session file chooses a host mount on resume, so it must stay in the directory ac-teams assigned.
212
+ const assigned = this.sessionDirs.get(member.id) ?? this.memberSessionDir(member.id);
213
+ if (!assigned || !within(resolve(args.sessionFile as string), assigned)) throw new Error("Member session file is outside its assigned directory");
127
214
  if (args.trusted !== true) throw new Error("Pi skipped protected project resources. Approve the member cwd with Pi's /trust command before retrying; ac-teams never auto-approves a project.");
128
215
  this.store.update(state => {
129
216
  state.members[member.id].sessionId = args.sessionId as string;
@@ -198,8 +285,8 @@ export class Coordinator {
198
285
  else if (event.type === "model_error") current.error = event.text || "Model request failed";
199
286
  });
200
287
  this.publishStatus();
201
- if (event.type === "model_error" && actor !== "root" && member.status !== "stopping") {
202
- 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;
203
290
  this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
204
291
  this.notify(recipient);
205
292
  }
@@ -211,7 +298,7 @@ export class Coordinator {
211
298
  if (!member) throw new Error("Unknown member ID");
212
299
  if (member.status === "stopping") throw new Error("Member is stopping; retry after it stops");
213
300
  if (isLive(member)) return;
214
- 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);
215
302
  this.operations.add(launch);
216
303
  try { await launch; } finally { this.operations.delete(launch); }
217
304
  };
@@ -219,28 +306,47 @@ export class Coordinator {
219
306
  if (!args.id) throw new Error("A member ID is required");
220
307
  await resume(args.id); return `Resumed ${args.id}`;
221
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
+ }
222
316
  if (!args.text?.trim()) throw new Error("Write a request or feedback before sending");
223
- let recipient = "root", fallback = "";
317
+ const main = isMain(this.store.state.members[actor]) ? actor : this.self;
318
+ let recipient = main, fallback = "";
224
319
  let text = `User task request (review before changing tasks):\n${taskRequestGuidance}\n\nRequest:\n${args.text}`;
225
320
  if (args.action === "feedback") {
226
321
  const task = this.store.state.tasks[args.id ?? ""];
227
322
  if (!task) throw new Error("Unknown task ID");
228
- recipient = task.creator ?? "root";
323
+ recipient = task.creator && this.store.state.members[task.creator]?.role ? task.creator : main;
229
324
  text = `User feedback on task ${taskName(task)} [${task.id}] (${task.title}):\n${args.text}`;
230
325
  if (!isLive(this.store.state.members[recipient]) || this.store.state.members[recipient].status === "stopping") {
231
326
  try { await resume(recipient); }
232
- 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; }
233
328
  }
234
329
  }
235
330
  const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
236
331
  this.notify(recipient);
237
- 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}`;
238
333
  }
239
334
  private publicMember(member: Member) {
240
335
  const { token: _token, identity: _identity, ...view } = member;
241
336
  return view;
242
337
  }
243
- live(): Member[] { return Object.values(this.store.state.members).filter(member => member.id !== "root" && isLive(member)); }
338
+ sandboxStatus(): SandboxStatus {
339
+ if (!this.options.credentials) return { available: false, backend: "docker", reason: "This Pi root cannot broker provider credentials" };
340
+ // Cache success; recheck failures so a fixed host is noticed without restarting.
341
+ if (!this.sandbox || (!this.sandbox.status.available && Date.now() - this.sandbox.at > 30000)) {
342
+ let status: SandboxStatus;
343
+ try { status = sandboxStatus(this.image()); } catch (error) { status = { available: false, backend: "docker", reason: String(error) }; }
344
+ this.sandbox = { status, at: Date.now() };
345
+ }
346
+ return this.sandbox.status;
347
+ }
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)); }
244
350
  ack(id: string, through: number): void {
245
351
  const last = this.store.state.messages.at(-1)?.seq ?? 0;
246
352
  if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
@@ -256,7 +362,7 @@ export class Coordinator {
256
362
  const through = pending.at(-1)?.seq;
257
363
  if (!through) return;
258
364
  try {
259
- if (id === "root") this.options.notice(through, pending.slice(-5));
365
+ if (id === this.self) this.options.notice(through, pending.slice(-5));
260
366
  else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
261
367
  } catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
262
368
  }, 50));
@@ -267,7 +373,11 @@ export class Coordinator {
267
373
  if (!member || !isLive(member)) throw new Error("Member is not active");
268
374
  validate(operation, args);
269
375
  if (operation === "event") return this.recordEvent(actor, args);
270
- if (operation === "ui") return this.userAction(actor, args as UserAction);
376
+ if (operation === "ui") {
377
+ // User actions can resume any member as Main, so sandboxed members cannot send them.
378
+ if (member.access) throw new Error("Sandboxed members cannot send user team actions; use Main");
379
+ return this.userAction(actor, args as UserAction);
380
+ }
271
381
  if (operation === "task") return this.store.task(actor, args as TaskArgs);
272
382
  if (operation === "message") {
273
383
  const result = this.store.message(actor, args as MessageArgs);
@@ -278,12 +388,12 @@ export class Coordinator {
278
388
  }
279
389
  private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
280
390
  const state = this.store.state;
281
- if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
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)) };
282
392
  if (args.action === "limits") {
283
- 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");
284
394
  if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
285
395
  this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
286
- return { limit: this.store.state.limit, depth: this.store.state.depth };
396
+ return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
287
397
  }
288
398
  if (args.action === "launch" || args.action === "resume") {
289
399
  const launch = this.launch(actor, args);
@@ -293,8 +403,9 @@ export class Coordinator {
293
403
  const member = args.id ? state.members[args.id] : undefined;
294
404
  if (!member) throw new Error("Unknown member ID");
295
405
  if (args.action === "status") return this.publicMember(member);
296
- 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");
297
407
  if (args.action === "cleanup") {
408
+ if (state.members[actor].access) throw new Error("Sandboxed members cannot remove worktrees");
298
409
  if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
299
410
  if (!member.worktree) throw new Error("Member has no worktree");
300
411
  if (this.cleaning.has(member.id)) throw new Error("Member cleanup is already running");
@@ -308,7 +419,8 @@ export class Coordinator {
308
419
  this.operations.add(cleanup);
309
420
  try { return await cleanup; } finally { this.cleaning.delete(member.id); this.operations.delete(cleanup); }
310
421
  }
311
- await this.stopTree(member.id);
422
+ if (isMain(member)) await this.stopMain(member.id);
423
+ else await this.stopTree(member.id);
312
424
  return this.publicMember(this.store.state.members[member.id]);
313
425
  }
314
426
  private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
@@ -317,7 +429,8 @@ export class Coordinator {
317
429
  if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
318
430
  if (!args.model || !args.model.includes("/")) throw new Error("A provider/model is required (normally inherited from the requesting member)");
319
431
  const resumed = args.action === "resume" ? state.members[args.id ?? ""] : undefined;
320
- 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);
321
434
  if (resumed && this.cleaning.has(resumed.id)) throw new Error("Worktree cleanup is running; cannot resume this member");
322
435
  if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
323
436
  const depth = resumed ? resumed.depth : parent.depth + 1;
@@ -326,7 +439,22 @@ export class Coordinator {
326
439
  const id = resumed?.id ?? randomUUID().slice(0, 16);
327
440
  const cwd = realpathSync(resolve(args.cwd ?? resumed?.cwd ?? parent.cwd));
328
441
  if (!statSync(cwd).isDirectory()) throw new Error("Agent cwd must be a directory");
329
- 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 };
442
+ // An access request without a network policy keeps the requester's; only an unsandboxed requester's default is public.
443
+ const access = resumed ? resumed.access : args.access ? resolveAccess({ ...args.access, network: args.access.network ?? parent.access?.network }, cwd) : parent.access;
444
+ assertNarrower(parent.access, access);
445
+ if (access) {
446
+ if (!readable(access, cwd)) throw new Error("The member cwd must be inside its granted directories");
447
+ if (args.worktree) throw new Error("Worktrees cannot be combined with enforced access");
448
+ // Container memory caps must stay within host RAM in total; unsandboxed members are not limited.
449
+ if (this.live().filter(other => other.access && other.id !== resumed?.id).length >= maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
450
+ const status = this.sandboxStatus();
451
+ if (!status.available) throw new Error(`Enforced access is unavailable: ${status.reason}`);
452
+ }
453
+ // The provider endpoint bypasses the allowlist, so a restricted member keeps its provider and its children inherit it.
454
+ const provider = args.model.slice(0, args.model.indexOf("/"));
455
+ const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
456
+ if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
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 };
330
458
  // Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
331
459
  this.store.update(next => { next.members[id] = member; });
332
460
  this.publishStatus();
@@ -344,16 +472,24 @@ export class Coordinator {
344
472
  if (this.closed || this.store.state.members[id].status !== "starting") throw new Error("Launch was stopped before startup");
345
473
  const readiness = new Promise<void>((done, fail) => {
346
474
  this.ready.set(id, { resolve: done, reject: fail });
347
- timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), 20000);
475
+ // Enforced members start with an empty compile cache, so Pi recompiles TypeScript extensions (about 20 s here).
476
+ timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), access ? 90000 : 20000);
348
477
  });
349
478
  // Attach immediately: a spawn error must not leave a rejected readiness promise unobserved.
350
479
  void readiness.catch(() => {});
351
480
  const slash = args.model.indexOf("/");
352
- mkdirSync(join(this.store.dir, "sessions"), { recursive: true, mode: 0o700 });
353
- chmodSync(join(this.store.dir, "sessions"), 0o700);
354
- const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", join(this.store.dir, "sessions"), "-e", this.options.extension, "--provider", args.model.slice(0, slash), "--model", args.model.slice(slash + 1), "--thinking", args.thinking ?? "off"];
481
+ const modelId = args.model.slice(slash + 1);
482
+ // Each member gets its own transcript directory, so a sandboxed member cannot read or alter others.
483
+ const sessions = resumed?.sessionFile ? dirname(resumed.sessionFile) : this.memberSessionDir(id);
484
+ mkdirSync(sessions, { recursive: true, mode: 0o700 });
485
+ this.sessionDirs.set(id, sessions);
486
+ const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", sessions, "-e", this.options.extension, "--provider", provider, "--model", modelId, "--thinking", args.thinking ?? "off"];
355
487
  if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
356
- const spawnOptions = { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, HERDR_ENV: "0", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
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 }) };
490
+ const spawnOptions = access
491
+ ? await this.sandboxed(id, access, member.cwd, provider, modelId, rpcArgs, identity, sessions)
492
+ : { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, ...identity, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
357
493
  agent = this.options.interactive
358
494
  ? new TerminalAgent({ ...spawnOptions, id, dir: this.store.dir, request: (type, fields, timeout) => this.control(id, type, fields, timeout) })
359
495
  : new RpcAgent(spawnOptions);
@@ -371,15 +507,21 @@ export class Coordinator {
371
507
  });
372
508
  agent.once("exit", () => {
373
509
  this.agents.delete(id);
510
+ // Tracked so close() waits for container removal.
511
+ const release = this.releaseSandbox(id).catch(error => this.options.warning(String(error)));
512
+ this.operations.add(release); this.releasing.set(id, release);
513
+ void release.finally(() => { this.operations.delete(release); if (this.releasing.get(id) === release) this.releasing.delete(id); });
374
514
  if (this.closed) return;
375
515
  this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
376
516
  this.publishStatus();
377
- 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)));
378
519
  });
379
520
  if (agent instanceof TerminalAgent) await agent.start();
380
521
  member.pid = agent.process.pid;
381
522
  member.identity = member.pid ? processIdentity(member.pid) : undefined;
382
523
  this.store.update(next => { next.members[id].pid = member.pid; next.members[id].identity = member.identity; });
524
+ if (access) await this.releaseMember(id);
383
525
  try {
384
526
  await Promise.race([readiness, agent.exited.then(() => { throw new Error("Child exited before readiness"); })]);
385
527
  } finally { if (timer) clearTimeout(timer); this.ready.delete(id); }
@@ -393,11 +535,266 @@ export class Coordinator {
393
535
  } catch (error) {
394
536
  clearTimeout(timer); this.ready.delete(id);
395
537
  if (agent) await agent.stop();
538
+ // The exit handler may already own the container removal; wait for it so a failed launch leaves nothing running.
539
+ await this.releaseSandbox(id); await this.releasing.get(id);
396
540
  this.store.update(next => { next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
397
541
  this.publishStatus();
398
542
  throw error;
399
543
  }
400
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
+ }
668
+ private image() {
669
+ return memberImage(this.options.piVersion ?? JSON.parse(readFileSync(join(packageRoot(command("pi")), "package.json"), "utf8")).version);
670
+ }
671
+ /** Transcript root: Main's session directory, without any ac-teams nesting if Main itself resumed a member transcript. */
672
+ private memberSessionDir(id: string): string {
673
+ const root = resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
674
+ return join(root, "ac-teams", this.store.state.id, id);
675
+ }
676
+ private containerName(id: string): string { return `ac-teams-${this.store.state.id}-${id}`; }
677
+ /** Prepares a member container whose only outside channels are the coordinator socket and this member's egress proxy. */
678
+ private async sandboxed(id: string, access: Access, cwd: string, provider: string, model: string, args: string[], identity: Record<string, string>, sessionDir: string) {
679
+ const credentials = this.options.credentials, route = providerRoutes[provider];
680
+ if (!credentials || !route) throw new Error(`Enforced access supports only ${Object.keys(providerRoutes).join("/")} models with Main's Pi login`);
681
+ const first = await credentials(provider, model); // Fails closed for unsupported providers before anything starts.
682
+ // A resume reuses the member ID, so the previous run's cleanup must finish before its files are recreated.
683
+ await this.releasing.get(id);
684
+ const home = homedir(), agentDir = resolve(process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent"));
685
+ const dir = memberConfigDir(id);
686
+ rmSync(dir, { recursive: true, force: true });
687
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
688
+ const read = (name: string) => { try { return JSON.parse(readFileSync(join(agentDir, name), "utf8")) as Record<string, unknown>; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } };
689
+ const write = (name: string, value: unknown) => writeFileSync(join(dir, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
690
+ // WebSocket clients may ignore proxy settings, so pin the SSE transport.
691
+ write("settings.json", { ...read("settings.json"), transport: "sse" });
692
+ // Only the selected provider's current access token: no refresh token and no other providers. The parent refreshes it.
693
+ const auth = (credential: { token: string; expires: number }) => ({ [provider]: { type: "oauth", access: credential.token, refresh: "", expires: credential.expires } });
694
+ write("auth.json", auth(first));
695
+ // Only trust decisions for the member's cwd and its ancestors; the full file lists every trusted project.
696
+ write("trust.json", Object.fromEntries(Object.entries(read("trust.json")).filter(([path]) => cwd === path || cwd.startsWith(path.endsWith("/") ? path : path + "/"))));
697
+ // pi-web-access ignores proxy environment variables but honours its own proxy setting. The container has no DNS,
698
+ // so it must leave address checks to the proxy, which refuses private and host addresses itself.
699
+ const proxy = `http://127.0.0.1:${egressPort}`, search = read("web-search.json");
700
+ write("web-search.json", { ...search, proxy, ssrf: { ...(search.ssrf as object | undefined), trustEnvProxy: true } });
701
+ // The image's Pi runs members; a test command is mounted from its own package instead.
702
+ const custom = this.options.command ?? process.env.AC_TEAMS_PI;
703
+ const pi = custom ? command(custom) : "pi";
704
+ const env: Record<string, string> = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: home, TMPDIR: "/tmp", PI_CODING_AGENT_DIR: agentDir, PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", ...identity,
705
+ HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy, NO_PROXY: "localhost,127.0.0.1,::1", no_proxy: "localhost,127.0.0.1,::1", NODE_USE_ENV_PROXY: "1",
706
+ // billion-context's spawned proxy reads its own upstream setting rather than the inherited environment.
707
+ BILI_UPSTREAM_PROXY: proxy };
708
+ for (const key of ["USER", "LANG", "LC_ALL", "TERM", "COLORTERM"]) if (process.env[key]) env[key] = process.env[key]!;
709
+ const launch = join(dir, "launch.json");
710
+ // The bridge waits on this before running any member code; it opens only after the mounts are verified.
711
+ let release!: (go: boolean) => void;
712
+ const gate = new Promise<boolean>(resolve => { release = resolve; });
713
+ const egress = await Egress.open(join(this.store.dir, `e-${id}.sock`), access.network, { host: new URL(route.baseUrl).hostname, port: 443 }, gate);
714
+ // Rewrites in place (same inode), because the file is bind-mounted; the member never sees the refresh token.
715
+ const refresh = setInterval(() => void credentials(provider, model).then(next => {
716
+ const current = this.sandboxes.get(id);
717
+ if (current && next.token !== current.token) { writeFileSync(join(dir, "auth.json"), JSON.stringify(auth(next), null, 2) + "\n"); current.token = next.token; }
718
+ }, () => this.options.warning(`Member ${id}: provider credential refresh failed`)), this.options.refreshMs ?? 60000);
719
+ refresh.unref();
720
+ const entry = { egress, grants: [] as { path: string; fd: number; dev: number; ino: number }[], release, refresh, token: first.token };
721
+ this.sandboxes.set(id, entry);
722
+ // Registered first so releaseSandbox closes the proxy if this fails. The coordinator token travels in this private file, never in container configuration that docker inspect shows.
723
+ write("launch.json", { socket: egress.socket, port: egressPort, command: pi, args, env });
724
+ // Hold each grant open so the started container's mounts can be compared with exactly these directories.
725
+ for (const path of [...access.read, ...access.write]) {
726
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
727
+ entry.grants.push({ path, fd, ...(({ dev, ino }) => ({ dev, ino }))(fstatSync(fd)) });
728
+ if (readlinkSync(`/proc/self/fd/${fd}`) !== path) throw new Error(`Grant ${path} changed while opening`);
729
+ }
730
+ const plan: SandboxPlan = {
731
+ name: this.containerName(id), labels: { "ac-teams.team": this.store.state.id, "ac-teams.member": id, "ac-teams.store": this.store.dir },
732
+ image: this.image().tag, access, cwd, home, terminal: this.options.interactive === true, agentDir, configDir: dir, sessionDir,
733
+ coordinatorSocket: this.socketPath, egressSocket: egress.socket, launch,
734
+ runtime: [...new Set([packageRoot(this.options.extension), packageRoot(fileURLToPath(import.meta.url)), ...(custom ? [packageRoot(pi)] : [])])],
735
+ bridge: fileURLToPath(new URL("./sandbox-bridge.mjs", import.meta.url)),
736
+ };
737
+ this.plans.set(id, plan);
738
+ await docker(["rm", "-f", plan.name]).catch(() => {});
739
+ const created = (await docker(dockerCreateArgs(plan))).trim();
740
+ // Register first, then check. Grants are identity-checked at start, but runtime, transcript, and socket mounts are
741
+ // not, so none may sit strictly inside another live member's writable grant, and no writable grant may strictly
742
+ // contain another member's such mounts, across all teams.
743
+ const others = (await docker(["ps", "-aq", "--no-trunc", "--filter", "label=ac-teams.member", ...["created", "running", "paused", "restarting"].flatMap(state => ["--filter", `status=${state}`])])).trim().split("\n").filter(other => other && other !== created);
744
+ const sets = others.length ? (JSON.parse(await docker(["inspect", ...others])) as { Mounts?: { Type: string; Source: string; RW: boolean }[]; Config: { Labels?: Record<string, string> } }[])
745
+ .map(container => mountSet((container.Mounts ?? []).filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, rw: mount.RW })), JSON.parse(container.Config.Labels?.["ac-teams.grants"] ?? "[]"))) : [];
746
+ assertStableGrants(mountSet(sandboxMounts(plan).map(mount => ({ source: mount.source, rw: !mount.readonly })), [...access.read, ...access.write]), sets);
747
+ // Admission across every coordinator on this host: count after creating, so concurrent launches cannot both slip under the cap.
748
+ const admitted = (await docker(["ps", "-aq", "--filter", "label=ac-teams.member", "--filter", "status=created", "--filter", "status=running"])).trim().split("\n").filter(Boolean).length;
749
+ if (admitted > maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
750
+ await this.verifyContainer(id, false);
751
+ await this.options.beforeStart?.(id);
752
+ return { cwd, command: "docker", args: ["start", "--attach", "--interactive", plan.name], env: { ...process.env } };
753
+ }
754
+ /** Refuses to run a container whose effective configuration differs from the policy. */
755
+ private async verifyContainer(id: string, started: boolean): Promise<void> {
756
+ const plan = this.plans.get(id)!;
757
+ const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
758
+ const problems = containerProblems(inspect, plan, started);
759
+ if (problems.length) throw new Error(`Member container failed policy checks: ${problems.join("; ")}`);
760
+ }
761
+ /**
762
+ * After start, before any member code runs: each grant mounted in the container must be the directory held open
763
+ * before creation. Docker resolves bind sources at start, so this is what pins grants against symlink swaps.
764
+ */
765
+ private async releaseMember(id: string): Promise<void> {
766
+ const sandbox = this.sandboxes.get(id)!, plan = this.plans.get(id)!;
767
+ try {
768
+ let pid = 0;
769
+ for (const end = Date.now() + 30000; !pid; await new Promise(done => setTimeout(done, 100))) {
770
+ if (Date.now() > end) throw new Error("Member container did not start");
771
+ const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
772
+ if (inspect?.State?.Running) pid = inspect.State.Pid;
773
+ else if (inspect?.State?.Status === "exited") throw new Error("Member container exited before release");
774
+ }
775
+ await this.verifyContainer(id, true);
776
+ for (const grant of sandbox.grants) {
777
+ const mounted = lstatSync(`/proc/${pid}/root${grant.path}`);
778
+ if (mounted.dev !== grant.dev || mounted.ino !== grant.ino) throw new Error(`Grant ${grant.path} is not the directory that was checked`);
779
+ }
780
+ sandbox.release(true);
781
+ } catch (error) { sandbox.release(false); throw error; }
782
+ finally { for (const grant of sandbox.grants) closeSync(grant.fd); sandbox.grants = []; }
783
+ }
784
+ private async releaseSandbox(id: string): Promise<void> {
785
+ const sandbox = this.sandboxes.get(id), plan = this.plans.get(id);
786
+ this.sandboxes.delete(id); this.plans.delete(id);
787
+ if (sandbox) {
788
+ sandbox.release(false); clearInterval(sandbox.refresh);
789
+ // releaseMember may still hold this entry; emptying the list keeps it from closing reused descriptors.
790
+ for (const grant of sandbox.grants) closeSync(grant.fd);
791
+ sandbox.grants = [];
792
+ }
793
+ if (plan) await docker(["rm", "-f", plan.name]).catch(error => this.options.warning(`Cannot remove member container ${plan.name}: ${String(error)}`));
794
+ await sandbox?.egress.close();
795
+ // The member's files hold its coordinator token and provider access token.
796
+ rmSync(memberConfigDir(id), { recursive: true, force: true });
797
+ }
401
798
  private async stopDescendants(id: string): Promise<void> {
402
799
  const descendants = this.live().filter(member => isDescendant(this.store.state, member.id, id)).sort((a, b) => b.depth - a.depth);
403
800
  for (const member of descendants) await this.stopOne(member.id);
@@ -408,7 +805,8 @@ export class Coordinator {
408
805
  this.store.update(next => { next.members[id].status = "stopping"; delete next.members[id].needsInput; });
409
806
  this.publishStatus();
410
807
  const agent = this.agents.get(id);
411
- if (agent) await agent.stop();
808
+ // A stopped member's container and credential files are gone before stop returns.
809
+ if (agent) { await agent.stop(); await this.releasing.get(id); }
412
810
  else if (member.pid && processAlive(member.pid, member.identity)) throw new Error("No owned transport for live process; refusing to kill by PID");
413
811
  this.store.update(next => { next.members[id].status = "stopped"; });
414
812
  }
@@ -417,12 +815,20 @@ export class Coordinator {
417
815
  private async closeOnce(): Promise<void> {
418
816
  this.closed = true;
419
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
+ });
420
825
  for (const member of this.live().sort((a, b) => b.depth - a.depth)) await this.stopOne(member.id);
421
826
  // Finish recording worktree operations already in flight before the store closes.
422
827
  await Promise.allSettled([...this.operations]);
423
828
  for (const socket of this.connections) socket.destroy();
424
829
  if (this.server) await new Promise<void>(done => this.server!.close(() => done()));
425
830
  try { unlinkSync(this.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
831
+ for (const id of [...this.sandboxes.keys()]) await this.releaseSandbox(id);
426
832
  for (const pending of this.controls.values()) { clearTimeout(pending.timer); pending.reject(new Error("Team stopped")); }
427
833
  this.controls.clear();
428
834
  if (this.options.interactive) {
@@ -433,3 +839,31 @@ export class Coordinator {
433
839
  this.store.close();
434
840
  }
435
841
  }
842
+
843
+ const memberConfigDir = (id: string) => join(stateRoot(), "agents", id);
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
+
858
+ function command(name: string): string {
859
+ if (name.includes("/")) return realpathSync(name);
860
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
861
+ if (dir && existsSync(join(dir, name))) return realpathSync(join(dir, name));
862
+ }
863
+ throw new Error(`Cannot find ${name} on PATH`);
864
+ }
865
+
866
+ const execFileAsync = promisify(execFile);
867
+ async function docker(args: string[]): Promise<string> {
868
+ return (await execFileAsync("docker", args, { timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout;
869
+ }