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.
package/src/egress.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { createServer, request as httpRequest, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import { BlockList, connect, isIP, type Socket } from "node:net";
3
+ import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { chmodSync, unlinkSync } from "node:fs";
5
+ import { networkInterfaces } from "node:os";
6
+
7
+ /**
8
+ * Outbound policy for a sandboxed member. `allow` undefined means any public destination; otherwise only matching
9
+ * destinations. The member's model provider is always reachable so Pi can run; that endpoint still receives whatever
10
+ * the member sends it.
11
+ */
12
+ export interface NetworkPolicy { allow?: string[] }
13
+ export const egressPort = 3128;
14
+ // Each tunnel holds two descriptors in the parent (Main's Pi), so one member cannot exhaust them.
15
+ export const maxEgressConnections = 256;
16
+
17
+ // Entries: "host", "*.host" (subdomains only), each optionally ":port". Without a port, an entry allows 80 and 443.
18
+ const entryPattern = /^(\*\.)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?::(\d{1,5}))?$/;
19
+ function parseEntry(entry: string) {
20
+ const match = entryPattern.exec(entry.toLowerCase());
21
+ const port = match?.[3] === undefined ? undefined : Number(match[3]);
22
+ if (!match || (port !== undefined && (port < 1 || port > 65535))) throw new Error(`Invalid network allow entry: ${entry}`);
23
+ return { wildcard: !!match[1], host: match[2], ports: port === undefined ? [80, 443] : [port] };
24
+ }
25
+ export function validatePolicy(policy: NetworkPolicy): NetworkPolicy {
26
+ if (policy.allow === undefined) return {};
27
+ return { allow: [...new Set(policy.allow.map(entry => { parseEntry(entry); return entry.toLowerCase(); }))].sort() };
28
+ }
29
+ const covers = (entry: ReturnType<typeof parseEntry>, host: string, port: number) =>
30
+ entry.ports.includes(port) && (entry.wildcard ? host.endsWith("." + entry.host) : host === entry.host);
31
+ export function allowed(policy: NetworkPolicy, host: string, port: number): boolean {
32
+ return policy.allow === undefined || policy.allow.some(entry => covers(parseEntry(entry), host.toLowerCase(), port));
33
+ }
34
+ /** Throws unless the child's policy allows nothing the parent's does not. */
35
+ export function assertNetworkNarrower(parent: NetworkPolicy, child: NetworkPolicy): void {
36
+ if (parent.allow === undefined) return;
37
+ if (child.allow === undefined) throw new Error("A member with restricted network access cannot launch one with public access");
38
+ for (const text of child.allow) {
39
+ const entry = parseEntry(text);
40
+ const ok = parent.allow.some(parentText => {
41
+ const outer = parseEntry(parentText);
42
+ if (!entry.ports.every(port => outer.ports.includes(port))) return false;
43
+ if (outer.wildcard) return entry.host.endsWith("." + outer.host);
44
+ return !entry.wildcard && entry.host === outer.host;
45
+ });
46
+ if (!ok) throw new Error(`Network access to ${text} exceeds the requesting member's access`);
47
+ }
48
+ }
49
+
50
+ // Only global unicast is public. Everything else (host loopback, LAN, Docker bridges, tailscale CGNAT, link-local,
51
+ // documentation, multicast, translation prefixes) stays unreachable.
52
+ const blocked = new BlockList();
53
+ for (const [address, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24],
54
+ ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 3]] as const) blocked.addSubnet(address, prefix, "ipv4");
55
+ for (const [address, prefix] of [["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20]] as const) blocked.addSubnet(address, prefix, "ipv6");
56
+ export function isPublicAddress(address: string): boolean {
57
+ const family = isIP(address);
58
+ if (family === 4) return !blocked.check(address, "ipv4");
59
+ if (family !== 6) return false;
60
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
61
+ if (mapped) return isPublicAddress(mapped[1]);
62
+ // Global unicast is 2000::/3; this also excludes ::, ::1, IPv4-compatible, ULA fc00::/7, link-local, and multicast.
63
+ const first = parseInt(address.startsWith("::") ? "0" : address.split(":")[0] || "0", 16);
64
+ return first >= 0x2000 && first <= 0x3fff && !blocked.check(address, "ipv6");
65
+ }
66
+
67
+ /** The host's own addresses are refused even when public, so services listening on all interfaces stay unreachable. */
68
+ export function isHostAddress(address: string): boolean {
69
+ const normal = (value: string) => value.toLowerCase().replace(/^::ffff:(?=\d)/, "").replace(/%.*$/, "");
70
+ return Object.values(networkInterfaces()).flat().some(entry => entry && normal(entry.address) === normal(address));
71
+ }
72
+ const reachable = (address: string) => isPublicAddress(address) && !isHostAddress(address);
73
+
74
+ type Lookup = (host: string) => Promise<string[]>;
75
+ const systemLookup: Lookup = async host => (await dnsLookup(host, { all: true, verbatim: true })).map(entry => entry.address);
76
+
77
+ /** Splits an authority (host:port, [v6]:port) strictly; returns undefined for anything else. */
78
+ export function parseAuthority(authority: string, defaultPort?: number): { host: string; port: number } | undefined {
79
+ const match = /^(?:\[([0-9a-f:.]+)\]|([a-z0-9.-]+))(?::(\d{1,5}))?$/i.exec(authority);
80
+ if (!match) return;
81
+ const host = (match[1] ?? match[2]).toLowerCase(), port = match[3] === undefined ? defaultPort : Number(match[3]);
82
+ if (port === undefined || port < 1 || port > 65535 || (match[1] && isIP(match[1]) !== 6) || (!match[1] && isIP(host) === 6)) return;
83
+ return { host, port };
84
+ }
85
+
86
+ /** One member's outbound proxy on a private Unix socket: HTTP CONNECT and plain HTTP forwarding to public addresses only. */
87
+ export class Egress {
88
+ readonly socket: string;
89
+ private server: Server;
90
+ private sockets = new Set<Socket>();
91
+ private policy: NetworkPolicy;
92
+ private model: { host: string; port: number };
93
+ private lookup: Lookup;
94
+ private isPublic: (address: string) => boolean;
95
+ private start: Promise<boolean>;
96
+ private started = false;
97
+ private constructor(socket: string, policy: NetworkPolicy, model: { host: string; port: number }, start: Promise<boolean>, lookup: Lookup, isPublic: (address: string) => boolean) {
98
+ this.socket = socket; this.policy = policy; this.model = model; this.start = start; this.lookup = lookup; this.isPublic = isPublic;
99
+ this.server = createServer({ requestTimeout: 0, headersTimeout: 30000 }, (req, res) => void this.forward(req, res).catch(() => res.destroy()));
100
+ this.server.on("connect", (req, client: Socket, head: Buffer) => void this.tunnel(req, client, head).catch(() => client.destroy()));
101
+ this.server.on("connection", socket => { if (this.sockets.size >= maxEgressConnections) return socket.destroy(); this.sockets.add(socket); socket.once("close", () => this.sockets.delete(socket)); });
102
+ }
103
+ /** `start` resolves true once the member's mounts are verified; the bridge waits on it before running any member code. */
104
+ static async open(socket: string, policy: NetworkPolicy, model: { host: string; port: number }, start: Promise<boolean>, lookup = systemLookup, isPublic = reachable): Promise<Egress> {
105
+ if (Buffer.byteLength(socket) > 107) throw new Error(`Egress socket path is too long: ${socket}`);
106
+ const egress = new Egress(socket, policy, model, start, lookup, isPublic);
107
+ try { unlinkSync(socket); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
108
+ await new Promise<void>((done, fail) => {
109
+ egress.server.once("error", fail);
110
+ egress.server.listen(socket, () => { try { chmodSync(socket, 0o600); done(); } catch (error) { egress.server.close(); fail(error); } });
111
+ });
112
+ return egress;
113
+ }
114
+ close(): Promise<void> {
115
+ return new Promise(done => {
116
+ for (const socket of this.sockets) socket.destroy();
117
+ this.server.close(() => { try { unlinkSync(this.socket); } catch { /* Already removed. */ } done(); });
118
+ });
119
+ }
120
+ private permitted(host: string, port: number): boolean {
121
+ return (host === this.model.host && port === this.model.port) || allowed(this.policy, host, port);
122
+ }
123
+ /** Resolves and validates every address, then returns one to connect to, so DNS cannot change between check and connect. */
124
+ private async resolve(host: string): Promise<string> {
125
+ const addresses = isIP(host) ? [host] : await this.lookup(host);
126
+ if (!addresses.length || !addresses.every(address => this.isPublic(address))) throw new Error("Destination is not a public address");
127
+ return addresses[0];
128
+ }
129
+ private async tunnel(req: IncomingMessage, client: Socket, head: Buffer): Promise<void> {
130
+ const target = parseAuthority(req.url ?? "");
131
+ const refuse = (status: string) => { client.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); };
132
+ if (!target) return refuse("400 Bad Request");
133
+ if (!this.permitted(target.host, target.port)) return refuse("403 Forbidden");
134
+ let address: string;
135
+ try { address = await this.resolve(target.host); } catch { return refuse("403 Forbidden"); }
136
+ const upstream = connect({ host: address, port: target.port, timeout: 10000 });
137
+ let established = false;
138
+ upstream.once("timeout", () => upstream.destroy());
139
+ upstream.once("connect", () => {
140
+ established = true;
141
+ upstream.setTimeout(300000);
142
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
143
+ // Bytes the client sent right after CONNECT (e.g. a TLS ClientHello) arrive in `head`.
144
+ if (head.length) upstream.write(head);
145
+ upstream.pipe(client).pipe(upstream);
146
+ });
147
+ // Once tunnelled, the stream belongs to the client's protocol; an HTTP error response would corrupt it.
148
+ upstream.on("error", () => { if (established) client.destroy(); else if (!client.destroyed) refuse("502 Bad Gateway"); });
149
+ client.on("error", () => upstream.destroy());
150
+ client.once("close", () => upstream.destroy());
151
+ }
152
+ private async forward(req: IncomingMessage, res: ServerResponse): Promise<void> {
153
+ const fail = (status: number) => { if (!res.headersSent) { res.writeHead(status, { "content-type": "text/plain", connection: "close" }); res.end(); } else res.destroy(); };
154
+ // Start gate for the in-container bridge: answered once, after mount verification.
155
+ if (req.url === "http://ac-teams.invalid/start") {
156
+ if (this.started) return fail(409);
157
+ this.started = true;
158
+ return (await this.start.catch(() => false)) ? (res.writeHead(200), void res.end("go")) : fail(403);
159
+ }
160
+ let url: URL;
161
+ try { url = new URL(req.url ?? ""); } catch { return fail(400); }
162
+ const target = url.protocol === "http:" && !url.username && !url.password ? parseAuthority(url.host, 80) : undefined;
163
+ if (!target) return fail(400);
164
+ if (!this.permitted(target.host, target.port)) return fail(403);
165
+ let address: string;
166
+ try { address = await this.resolve(target.host); } catch { return fail(403); }
167
+ const headers: Record<string, string | string[]> = {};
168
+ for (const [name, value] of Object.entries(req.headers)) if (value !== undefined && !/^(proxy-|connection$|keep-alive$|te$|trailer$|upgrade$|host$)/i.test(name)) headers[name] = value;
169
+ headers.host = url.host;
170
+ const upstream = httpRequest({ host: address, port: target.port, method: req.method, path: url.pathname + url.search, headers, timeout: 300000 }, response => {
171
+ res.writeHead(response.statusCode ?? 502, response.headers);
172
+ response.pipe(res);
173
+ });
174
+ upstream.on("timeout", () => upstream.destroy());
175
+ upstream.on("error", () => fail(502));
176
+ res.once("close", () => { if (!res.writableFinished) upstream.destroy(); });
177
+ req.pipe(upstream);
178
+ }
179
+ }
180
+
181
+ /** Providers whose current-token auth ac-teams can hand to a member; the endpoint stays reachable under any policy. */
182
+ export const providerRoutes: Record<string, { api: string; baseUrl: string }> = {
183
+ anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
184
+ openai: { api: "openai-responses", baseUrl: "https://api.openai.com/v1" },
185
+ };
186
+ /** Resolves the provider's current access token in the trusted parent, which owns OAuth refresh. */
187
+ export type Credentials = (provider: string, model: string) => Promise<{ token: string; expires: number }>;
package/src/index.ts CHANGED
@@ -9,9 +9,18 @@ import { resolveTeammate, teamsCompletions } from "./commands.ts";
9
9
  import { showTeamsPanel } from "./panel.ts";
10
10
  import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
11
11
  import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { execFile } from "node:child_process";
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { promisify } from "node:util";
16
+ import { readState, teamsHome } from "./store.ts";
17
+ import { teamEntryType, type TeamEntry } from "./mains.ts";
18
+ import { isMain, memberName, taskName, type TeamState } from "./types.ts";
12
19
  import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
13
20
  import { Connection, isRecord } from "./connection.ts";
14
21
  import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
22
+ import { providerRoutes } from "./egress.ts";
23
+ import { VERSION, readStoredCredential } from "@earendil-works/pi-coding-agent";
15
24
 
16
25
  export default function teams(pi: ExtensionAPI) {
17
26
  pi.registerFlag("teams-limit", { description: "Explicit maximum live ac-teams members (default 10, idle members count)", type: "string" });
@@ -28,29 +37,134 @@ export default function teams(pi: ExtensionAPI) {
28
37
  let disposeUI: (() => void) | undefined;
29
38
  let telemetryTimer: NodeJS.Timeout | undefined;
30
39
  let inputCount = 0;
40
+ /** This main session's team, from its transcript's team entry; a session with no entry leads its own team as "root". */
41
+ let team: TeamEntry | undefined;
42
+ /** Why team tools are off in this session, if they are. */
43
+ let disabled: string | undefined;
44
+ let linking: Promise<void> | undefined;
45
+ let linkedSession: string | undefined;
31
46
  const drafts = new Map<string, string>();
32
47
  const display = { ...defaultDisplaySettings };
33
- const self = environment[1] ?? "root";
48
+ const teamTools = ["team_agent", "team_task", "team_message"];
49
+ const memberId = () => child ? environment[1]! : team?.member ?? "root";
50
+ const teamId = (ctx: ExtensionContext) => team?.team ?? ctx.sessionManager.getSessionId();
34
51
  pi.registerMessageRenderer("ac-teams", renderTeamMessage);
35
52
  const warn = (message: string) => context?.ui.notify(message, "warning");
36
53
  const notify = (through: number, messages: Message[] = []) => {
37
54
  pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
38
55
  if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
39
- else coordinator?.ack("root", through);
56
+ else coordinator?.ack(coordinator.self, through);
40
57
  };
41
- async function root(ctx: ExtensionContext): Promise<Coordinator> {
58
+ function disable(reason: string) {
59
+ disabled = reason;
60
+ pi.setActiveTools(pi.getActiveTools().filter(name => !teamTools.includes(name)));
61
+ }
62
+ function enable() {
63
+ disabled = undefined;
64
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...teamTools])]);
65
+ }
66
+ function remember(entry: TeamEntry) {
67
+ team = entry;
68
+ pi.appendEntry(teamEntryType, entry);
69
+ }
70
+ /** Runs a native command sent by the coordinator: members get these from their parent, main sessions from other mains. */
71
+ function onCommand(ctx: ExtensionContext, link: Connection) {
72
+ return (command: { id: string; command: string; args: Record<string, unknown> }) => {
73
+ void (async () => {
74
+ let data: unknown = {}, error: string | undefined;
75
+ try {
76
+ if (command.command === "get_state") data = { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider, name: ctx.model.name } : undefined, contextUsage: readTelemetry(ctx).contextUsage, isStreaming: !ctx.isIdle() };
77
+ else if (command.command === "prompt") {
78
+ if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
79
+ pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
80
+ } else if (command.command === "abort") ctx.abort();
81
+ else if (command.command !== "shutdown") throw new Error("Unsupported native command");
82
+ } catch (failure) { error = String(failure).slice(0, 2000); }
83
+ try { await link.request("control_result", { id: command.id, data, error }); }
84
+ catch { return; }
85
+ if (command.command === "shutdown" && !error) { exiting = true; ctx.shutdown(); }
86
+ })();
87
+ };
88
+ }
89
+ /** Connects to a running coordinator as a main-session client; false when none is listening. */
90
+ async function connectMain(ctx: ExtensionContext): Promise<boolean> {
91
+ const dir = join(teamsHome(), teamId(ctx)), socket = join(dir, "coordinator.sock");
92
+ if (!existsSync(socket)) return false;
93
+ const record = readState(dir).members[memberId()];
94
+ if (!isMain(record)) throw new Error("This session is no longer a main session of its team; use /teams join");
95
+ const link = new Connection(socket, record.id, record.token);
96
+ try { await link.open(); } catch { return false; }
97
+ link.on("notice", (through, messages) => notify(through, messages));
98
+ link.on("command", onCommand(ctx, link));
99
+ link.on("disconnect", () => {
100
+ if (connection !== link) return;
101
+ connection = undefined;
102
+ if (!exiting) void takeover(ctx);
103
+ });
104
+ try { await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() }); }
105
+ catch (error) { link.close(); throw error; }
106
+ connection = link; linkedSession = ctx.sessionManager.getSessionId();
107
+ if (!team) remember({ team: teamId(ctx), member: record.id });
108
+ return true;
109
+ }
110
+ /** The coordinator owner went away: one main session takes the owner lock, the rest reconnect to it. */
111
+ async function takeover(ctx: ExtensionContext) {
112
+ warn("The team coordinator stopped; taking over or reconnecting");
113
+ for (let attempt = 0; attempt < 40 && !exiting; attempt++) {
114
+ await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 250));
115
+ try {
116
+ await link(ctx);
117
+ warn(coordinator ? "This session now runs the team coordinator" : "Reconnected to the team coordinator");
118
+ return;
119
+ } catch { /* The new owner may still be starting. */ }
120
+ }
121
+ if (!exiting) warn("Could not reach or take over the team coordinator; team tools will retry on next use");
122
+ }
123
+ /** Makes this session an owner or client of its team's coordinator. */
124
+ async function link(ctx: ExtensionContext): Promise<void> {
125
+ if (child) { if (!connection) throw new Error("Team coordinator is not connected"); return; }
126
+ if (exiting) throw new Error("Team is stopping");
127
+ if (disabled) throw new Error(disabled);
128
+ if (connection || coordinator) return;
129
+ linking ??= (async () => {
130
+ if (await connectMain(ctx)) return;
131
+ try { await root(ctx); }
132
+ catch (error) {
133
+ // Another main session won the owner lock first; join it as a client.
134
+ if (String(error).includes("live owner lock") && await connectMain(ctx)) return;
135
+ throw error;
136
+ }
137
+ })().finally(() => { linking = undefined; });
138
+ return linking;
139
+ }
140
+ async function root(ctx: ExtensionContext, join?: { id: string; member: string }): Promise<Coordinator> {
42
141
  if (exiting) throw new Error("Team is stopping");
43
142
  context = ctx;
44
143
  opening ??= Coordinator.open({
45
- id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(),
144
+ id: join?.id ?? teamId(ctx), self: join?.member ?? memberId(), join: join !== undefined, sessionId: ctx.sessionManager.getSessionId(),
145
+ shutdown: () => { exiting = true; ctx.shutdown(); },
146
+ cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(), sessionDir: ctx.sessionManager.getSessionDir(),
46
147
  ...policy(ctx.cwd, pi.getFlag("teams-project-config") === true, { limit: pi.getFlag("teams-limit") as string | undefined, depth: pi.getFlag("teams-depth") as string | undefined }),
47
- extension: fileURLToPath(import.meta.url), notice: notify, warning: warn,
148
+ extension: fileURLToPath(import.meta.url), notice: notify, warning: warn, piVersion: VERSION,
48
149
  status: (kind, active) => pi.events.emit(`herdr:${kind}`, { active, source: "ac-teams" }),
150
+ credentials: async (provider, modelId) => {
151
+ // Only providers whose request shape was verified are brokered; anything else fails closed.
152
+ const registry = (context ?? ctx).modelRegistry, model = registry.find(provider, modelId), route = providerRoutes[provider];
153
+ if (!model || !route || model.api !== route.api || model.baseUrl !== route.baseUrl || !registry.isUsingOAuth(model)) throw new Error(`Enforced access supports only OAuth ${Object.keys(providerRoutes).join("/")} models at their default endpoints, not ${provider}/${modelId}`);
154
+ const auth = await registry.getApiKeyAndHeaders(model);
155
+ if (!auth.ok) throw new Error(auth.error);
156
+ if (!auth.apiKey || auth.baseUrl || Object.keys(auth.headers ?? {}).length || Object.keys(auth.env ?? {}).length) throw new Error(`Unsupported credential shape for ${provider}`);
157
+ // The member gets the token's real expiry; an unknown expiry is kept short so the parent's rewrite stays ahead of it.
158
+ const stored = readStoredCredential(provider) as { type?: string; access?: string; expires?: number } | undefined;
159
+ const expires = stored?.type === "oauth" && stored.access === auth.apiKey && typeof stored.expires === "number" ? stored.expires : Date.now() + 10 * 60000;
160
+ return { token: auth.apiKey, expires };
161
+ },
49
162
  interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
50
163
  }).then(value => {
51
- coordinator = value;
52
- if (ctx.isIdle?.() === false) value.recordEvent("root", { type: "agent_start" });
53
- if (inputCount > 0) value.recordEvent("root", { type: "blocked", active: true });
164
+ coordinator = value; linkedSession = ctx.sessionManager.getSessionId();
165
+ if (!team) remember({ team: value.store.state.id, member: value.self });
166
+ if (ctx.isIdle?.() === false) value.recordEvent(value.self, { type: "agent_start" });
167
+ if (inputCount > 0) value.recordEvent(value.self, { type: "blocked", active: true });
54
168
  return value;
55
169
  }, error => { opening = undefined; throw error; });
56
170
  return opening;
@@ -66,19 +180,18 @@ export default function teams(pi: ExtensionAPI) {
66
180
  }
67
181
  args = agent;
68
182
  }
69
- if (child) {
70
- if (!connection) throw new Error("Team coordinator is not connected");
71
- return connection.request(operation, args);
72
- }
183
+ await link(ctx);
184
+ // shortcut: client main sessions cannot approve a ceiling above 10, only the owner can; add a confirmation relay if needed.
185
+ if (connection) return connection.request(operation, args);
73
186
  let authorized = false;
74
187
  if (operation === "agent" && (args as AgentArgs).action === "limits" && ((args as AgentArgs).limit ?? 0) > 10) {
75
188
  if (!ctx.hasUI || !await ctx.ui.confirm("Raise team ceiling?", `Allow up to ${(args as AgentArgs).limit} live Pi members, including idle members? This increases possible model costs.`)) throw new Error("Ceiling increase was not explicitly approved");
76
189
  authorized = true;
77
190
  }
78
- return (await root(ctx)).handle("root", operation, args, authorized);
191
+ return coordinator!.handle(coordinator!.self, operation, args, authorized);
79
192
  }
80
193
  async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
81
- if (!child && !opening) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
194
+ if (!child && !opening && !connection) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
82
195
  const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
83
196
  const tasks: TaskView[] = [];
84
197
  let total = 0;
@@ -93,30 +206,35 @@ export default function teams(pi: ExtensionAPI) {
93
206
  function host(ctx: ExtensionContext): PanelHost {
94
207
  const userAction = async (args: UserAction): Promise<string> => {
95
208
  const value = { ...args, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel() };
96
- if (child) {
97
- if (!connection) throw new Error("Team coordinator is not connected");
98
- return await connection.request("ui", value) as string;
99
- }
100
- return await (await root(ctx)).handle("root", "ui", value) as string;
209
+ await link(ctx);
210
+ if (connection) return await connection.request("ui", value) as string;
211
+ return await coordinator!.handle(coordinator!.self, "ui", value) as string;
101
212
  };
102
213
  const api: PanelHost = {
103
- self, drafts, snapshot: () => snapshot(ctx),
214
+ self: memberId(), child, drafts, snapshot: () => snapshot(ctx),
104
215
  visit: async id => {
105
- if (id === self) return;
106
- const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join((await root(ctx)).store.dir, "t.sock");
216
+ if (id === memberId()) return;
217
+ if (!child) await link(ctx);
218
+ const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join(teamsHome(), teamId(ctx), "t.sock");
107
219
  if (!socket) throw new Error("This member has no live terminal transport");
108
220
  await visitMember(ctx, api, socket, id);
109
221
  },
110
222
  requestTask: text => userAction({ action: "task_request", text }),
111
223
  feedback: (task, text) => userAction({ action: "feedback", id: task.id, text }),
112
224
  resume: async id => { await userAction({ action: "resume", id }); },
225
+ promote: async id => {
226
+ const member = (await snapshot(ctx)).members.find(member => member.id === id);
227
+ const confirmed = member?.access ? await ctx.ui.confirm("Promote a sandboxed member?", `${memberName(member)} will leave its sandbox and run as a main session with your full access.`) : false;
228
+ if (member?.access && !confirmed) return;
229
+ ctx.ui.notify(await userAction({ action: "promote", id, confirmed }), "info");
230
+ },
113
231
  };
114
232
  return api;
115
233
  }
116
234
  function report(ctx: ExtensionContext, event: { type: string; text?: string; active?: boolean }) {
117
235
  if (exiting) return;
118
- if (child && ctx.mode === "tui") void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
119
- else if (!child) coordinator?.recordEvent("root", event);
236
+ if (connection && (!child || ctx.mode === "tui")) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
237
+ else coordinator?.recordEvent(coordinator.self, event);
120
238
  }
121
239
  function startTelemetry(ctx: ExtensionContext) {
122
240
  clearInterval(telemetryTimer);
@@ -127,8 +245,8 @@ export default function teams(pi: ExtensionAPI) {
127
245
  try {
128
246
  const event = readTelemetry(ctx), encoded = JSON.stringify(event);
129
247
  if (encoded === previous) return;
130
- if (child) await connection?.request("event", event);
131
- else coordinator?.recordEvent("root", event);
248
+ if (connection) await connection.request("event", event);
249
+ else coordinator?.recordEvent(coordinator.self, event);
132
250
  previous = encoded;
133
251
  } catch (error) { if (!exiting) warn(cleanText(String(error))); }
134
252
  finally { pending = false; }
@@ -143,8 +261,8 @@ export default function teams(pi: ExtensionAPI) {
143
261
  inputCount = Math.max(0, inputCount + (data.active ? 1 : -1));
144
262
  if (context) {
145
263
  const event = { type: "blocked", active: inputCount > 0 };
146
- if (child) void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
147
- else coordinator?.recordEvent("root", event);
264
+ if (connection) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
265
+ else coordinator?.recordEvent(coordinator.self, event);
148
266
  }
149
267
  });
150
268
  pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
@@ -182,34 +300,104 @@ export default function teams(pi: ExtensionAPI) {
182
300
  disposeUI?.(); disposeUI = undefined;
183
301
  connection?.close(); connection = undefined;
184
302
  if (opening) await (await opening).close();
185
- coordinator = undefined; opening = undefined;
303
+ coordinator = undefined; opening = undefined; linkedSession = undefined;
304
+ }
305
+ /** A resumed main session reconnects to its team, or offers to run the coordinator and resume the team's members. */
306
+ async function rejoin(ctx: ExtensionContext) {
307
+ try {
308
+ if (await connectMain(ctx)) return;
309
+ const dir = join(teamsHome(), teamId(ctx));
310
+ if (!existsSync(join(dir, "state.json"))) return;
311
+ const state = readState(dir);
312
+ if (!isMain(state.members[memberId()])) { disable("This session is no longer a main session of its team; use /teams join"); return; }
313
+ const others = Object.values(state.members).filter(member => member.id !== memberId() && member.role);
314
+ if (!ctx.hasUI || !others.length) return;
315
+ if (await ctx.ui.confirm("Run the team coordinator?", `This is a main session of a team with ${others.length} other member(s), and no coordinator is running. Run it here and resume the members that were working?`)) await link(ctx);
316
+ else disable("You declined to run the team coordinator; team tools are off in this session. Resume the session again to take over.");
317
+ } catch (error) { warn(cleanText(String(error))); }
318
+ }
319
+ async function repository(cwd: string): Promise<string> {
320
+ try { return (await promisify(execFile)("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 })).stdout.trim(); }
321
+ catch { return cwd; }
322
+ }
323
+ /** /teams join: adds this session to another team in the same project as a main session. */
324
+ async function joinTeam(ctx: ExtensionContext) {
325
+ if (child) throw new Error("A member cannot join another team");
326
+ if (!ctx.hasUI) throw new Error("/teams join requires an interactive session");
327
+ const sessionId = ctx.sessionManager.getSessionId(), home = teamsHome();
328
+ const current = existsSync(join(home, teamId(ctx), "state.json")) ? readState(join(home, teamId(ctx))) : undefined;
329
+ if (current && Object.values(current.members).some(member => member.id !== memberId() && member.role)) throw new Error("This session is already in a team with other members; use /teams leave first");
330
+ const repo = await repository(ctx.cwd), teams: TeamState[] = [];
331
+ for (const name of existsSync(home) ? readdirSync(home) : []) {
332
+ try {
333
+ const state = readState(join(home, name)), main = Object.values(state.members).find(isMain);
334
+ if (state.id !== current?.id && main && await repository(main.cwd) === repo) teams.push(state);
335
+ } catch { /* Unreadable or foreign team folders are not offered. */ }
336
+ }
337
+ if (!teams.length) { ctx.ui.notify("No other teams in this project", "info"); return; }
338
+ const describe = (state: TeamState) => {
339
+ const members = Object.values(state.members);
340
+ return `${members.filter(isMain).map(memberName).join(", ")} · ${members.filter(member => member.role === "member").length} member(s) · ${Object.values(state.tasks).filter(task => task.status !== "done").length} open task(s) · ${state.id.slice(0, 8)}`;
341
+ };
342
+ const labels = teams.map(describe), choice = await ctx.ui.select("Join which team?", labels);
343
+ const chosen = teams[labels.indexOf(choice ?? "")];
344
+ if (!chosen) return;
345
+ if (Object.values(chosen.members).some(member => member.sessionId === sessionId)) throw new Error("This session is already in that team");
346
+ await close(); exiting = false; enable();
347
+ const dir = join(home, chosen.id), probe = new Connection(join(dir, "coordinator.sock"), "join", chosen.key);
348
+ let running = false;
349
+ try { await probe.open(); running = true; } catch { /* No coordinator: this session runs it. */ }
350
+ if (running) {
351
+ try {
352
+ const added = await probe.request("add_main", { sessionId, sessionFile: ctx.sessionManager.getSessionFile(), cwd: ctx.cwd }) as { id: string };
353
+ remember({ team: chosen.id, member: added.id });
354
+ } finally { probe.close(); }
355
+ if (!await connectMain(ctx)) throw new Error("The team coordinator stopped while joining; resume this session to retry");
356
+ } else {
357
+ const member = randomUUID().slice(0, 16);
358
+ await root(ctx, { id: chosen.id, member });
359
+ remember({ team: chosen.id, member });
360
+ }
361
+ const state = readState(dir), name = (id?: string) => id ? memberName(state.members[id] ?? { id }) : "unowned";
362
+ const open = Object.values(state.tasks).filter(task => task.status !== "done");
363
+ const recent = state.messages.slice(-5);
364
+ pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
365
+ `Joined team ${state.id} as ${name(team!.member)}, a main session.`,
366
+ open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
367
+ recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
368
+ ].join("\n\n") }, { triggerTurn: false });
369
+ }
370
+ /** /teams leave: hands this session's work back and drops its main-session role. */
371
+ async function leaveTeam(ctx: ExtensionContext) {
372
+ if (child) throw new Error("A member cannot leave its team; its parent stops it");
373
+ await link(ctx);
374
+ const left = { team: teamId(ctx), member: memberId(), left: true };
375
+ const result = connection ? await connection.request("ui", { action: "leave" }) : await coordinator!.handle(coordinator!.self, "ui", { action: "leave" });
376
+ pi.appendEntry(teamEntryType, left);
377
+ team = undefined;
378
+ await close(); exiting = false;
379
+ disable("This session left its team; use /teams join to join one");
380
+ ctx.ui.notify(String(result), "info");
186
381
  }
187
382
  pi.on("session_start", async (_event, ctx) => {
188
- if (!child && coordinator && coordinator.store.state.id !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
383
+ if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
189
384
  context = ctx; exiting = false; inputCount = 0;
190
385
  if (child) {
191
386
  if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
192
- connection = new Connection(environment[0]!, environment[1]!, environment[2]!);
193
- connection.on("notice", (through, messages) => notify(through, messages));
194
- connection.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
195
- connection.on("command", (command: { id: string; command: string; args: Record<string, unknown> }) => {
196
- void (async () => {
197
- let data: unknown = {}, error: string | undefined;
198
- try {
199
- if (command.command === "get_state") data = { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider, name: ctx.model.name } : undefined, contextUsage: readTelemetry(ctx).contextUsage, isStreaming: !ctx.isIdle() };
200
- else if (command.command === "prompt") {
201
- if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
202
- pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
203
- } else if (command.command === "abort") ctx.abort();
204
- else if (command.command !== "shutdown") throw new Error("Unsupported native command");
205
- } catch (failure) { error = String(failure).slice(0, 2000); }
206
- try { await connection?.request("control_result", { id: command.id, data, error }); }
207
- catch { return; }
208
- if (command.command === "shutdown" && !error) ctx.shutdown();
209
- })();
210
- });
211
- await connection.open();
212
- await connection.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
387
+ const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
388
+ connection = link;
389
+ link.on("notice", (through, messages) => notify(through, messages));
390
+ link.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
391
+ link.on("command", onCommand(ctx, link));
392
+ await link.open();
393
+ await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
394
+ } else if (!linkedSession) {
395
+ const entry = ctx.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === teamEntryType).at(-1);
396
+ const data = entry?.type === "custom" ? entry.data as TeamEntry : undefined;
397
+ team = data && !data.left ? { team: data.team, member: data.member } : undefined;
398
+ disabled = undefined;
399
+ if (data?.left) disable("This session left its team; use /teams join to join one");
400
+ else if (team) await rejoin(ctx);
213
401
  }
214
402
  startTelemetry(ctx);
215
403
  if (!warned) {
@@ -227,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
227
415
  });
228
416
  pi.on("session_shutdown", close);
229
417
  const switchGuard = () => {
230
- if (child || coordinator?.live().length) {
418
+ if (child || coordinator?.live().length || connection) {
231
419
  warn("Stop team members before switching/forking/traversing sessions (team_agent stop, or /teams stop)");
232
420
  return { cancel: true as const };
233
421
  }
@@ -248,12 +436,12 @@ export default function teams(pi: ExtensionAPI) {
248
436
  handler: async ctx => { if (ctx.mode === "tui") await host(ctx).visit("root"); },
249
437
  });
250
438
  pi.registerCommand("teams", {
251
- description: "Team panel; /teams teammate <name-or-id>, config, back, stop, or status",
439
+ description: "Team panel; /teams teammate <name-or-id>, config, back, stop, status, join, or leave",
252
440
  async getArgumentCompletions(prefix) {
253
441
  if (!prefix.includes(" ")) return teamsCompletions(prefix, []);
254
442
  if (!context) return null;
255
443
  try {
256
- const members = child || opening ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
444
+ const members = child || opening || connection ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
257
445
  return teamsCompletions(prefix, members);
258
446
  } catch { return null; }
259
447
  },
@@ -265,13 +453,20 @@ export default function teams(pi: ExtensionAPI) {
265
453
  catch (error) { ctx.ui.notify(cleanText(String(error)), "error"); }
266
454
  return;
267
455
  }
456
+ if (args.trim() === "join" || args.trim() === "leave") {
457
+ try { await (args.trim() === "join" ? joinTeam(ctx) : leaveTeam(ctx)); }
458
+ catch (error) { ctx.ui.notify(cleanText(String(error instanceof Error ? error.message : error)), "error"); }
459
+ return;
460
+ }
268
461
  if (args.trim() === "stop") {
269
462
  if (child) throw new Error("A member can only stop its descendants using team_agent");
270
- const team = await root(ctx);
271
- for (const member of [...team.live()].sort((a, b) => b.depth - a.depth)) await team.stopTree(member.id);
463
+ await link(ctx);
464
+ // Main sessions keep running; /teams stop stops members only.
465
+ const { members } = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members">;
466
+ for (const member of members.filter(member => member.role === "member" && !member.parent && ["starting", "busy", "idle"].includes(member.status))) await handle("agent", { action: "stop", id: member.id }, ctx);
272
467
  }
273
468
  if (ctx.mode === "tui" && args.trim() !== "stop" && args.trim() !== "status") {
274
- if (!child) await root(ctx);
469
+ if (!child) await link(ctx);
275
470
  const api = host(ctx);
276
471
  if (args.trim() === "back") await api.visit("root");
277
472
  else if (/^(teammate|open)\s+/.test(args.trim())) {
@@ -282,7 +477,7 @@ export default function teams(pi: ExtensionAPI) {
282
477
  else ctx.ui.notify(`Unknown teammate: ${cleanText(value)}. Use autocomplete or /teams panel.`, "error");
283
478
  }
284
479
  else if (!args.trim() || args.trim() === "panel") await showTeamsPanel(ctx, api);
285
- else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status]", "info");
480
+ else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status|join|leave]", "info");
286
481
  } else {
287
482
  const status = await handle("agent", { action: "list" }, ctx);
288
483
  ctx.ui.notify(JSON.stringify(status, null, 2), "info");