ac-teams 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +52 -2
- package/docs/development.md +89 -0
- package/docs/implementation-plan.md +197 -0
- package/docs/operations.md +101 -0
- package/docs/usage.md +128 -0
- package/package.json +27 -4
- package/src/commands.ts +21 -0
- package/src/config.ts +49 -0
- package/src/connection.ts +85 -0
- package/src/coordinator.ts +408 -0
- package/src/graph.ts +79 -0
- package/src/index.ts +273 -0
- package/src/panel.ts +309 -0
- package/src/render.ts +71 -0
- package/src/rpc-agent.ts +155 -0
- package/src/schemas.ts +58 -0
- package/src/store.ts +349 -0
- package/src/task-summary.ts +34 -0
- package/src/telemetry.ts +19 -0
- package/src/terminal-agent.ts +110 -0
- package/src/terminal-ui.ts +162 -0
- package/src/types.ts +135 -0
- package/src/ui-types.ts +19 -0
- package/src/worktrees.ts +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ac-teams",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Small, persistent teams for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/ac8318740/ac-teams.git" },
|
|
8
|
+
"files": ["src", "README.md", "LICENSE", "docs"],
|
|
9
|
+
"os": ["linux"],
|
|
10
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
11
|
+
"scripts": {
|
|
12
|
+
"check": "tsc --noEmit",
|
|
13
|
+
"test": "node --experimental-strip-types --test test/*.test.ts"
|
|
14
|
+
},
|
|
15
|
+
"engines": { "node": ">=22.19.0" },
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
18
|
+
"typebox": "^1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"typebox": "^1.0.0",
|
|
24
|
+
"typescript": "^5.9.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"grok-mermaid": "0.2.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import { memberName, type Member } from "./types.ts";
|
|
3
|
+
import { cleanText } from "./render.ts";
|
|
4
|
+
|
|
5
|
+
type NamedMember = Pick<Member, "id" | "name" | "status">;
|
|
6
|
+
const actions = ["panel", "teammate", "config", "back", "stop", "status"];
|
|
7
|
+
|
|
8
|
+
export function resolveTeammate(members: NamedMember[], value: string): string | undefined {
|
|
9
|
+
const key = value === "main" ? "root" : value;
|
|
10
|
+
return members.find(member => member.id === key)?.id ?? members.find(member => member.name === key)?.id;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function teamsCompletions(prefix: string, members: NamedMember[]): AutocompleteItem[] | null {
|
|
14
|
+
if (!prefix.includes(" ")) return actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
15
|
+
const match = /^(teammate|open)\s+(\S*)$/.exec(prefix);
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
return members.filter(member => (member.id === "root" ? "main" : memberName(member)).startsWith(match[2]) || member.id.startsWith(match[2])).map(member => {
|
|
18
|
+
const name = member.id === "root" ? "main" : memberName(member);
|
|
19
|
+
return { value: `${match[1]} ${name}`, label: cleanText(name), description: `${member.status} · ${member.id}` };
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { isRecord } from "./connection.ts";
|
|
6
|
+
|
|
7
|
+
export interface DisplaySettings { tasks: boolean; agents: boolean }
|
|
8
|
+
export const defaultDisplaySettings: DisplaySettings = { tasks: true, agents: true };
|
|
9
|
+
const displayPath = () => join(homedir(), ".acstack", "ac-teams", "display.json");
|
|
10
|
+
|
|
11
|
+
export function readDisplaySettings(): DisplaySettings {
|
|
12
|
+
let value: unknown;
|
|
13
|
+
try { value = JSON.parse(readFileSync(displayPath(), "utf8")); }
|
|
14
|
+
catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ...defaultDisplaySettings };
|
|
16
|
+
throw new Error(`Cannot read teams display settings: ${String(error)}`);
|
|
17
|
+
}
|
|
18
|
+
if (!isRecord(value) || Object.keys(value).some(key => !["tasks", "agents"].includes(key) || typeof value[key] !== "boolean")) throw new Error("Invalid teams display settings");
|
|
19
|
+
return { ...defaultDisplaySettings, ...value };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveDisplaySettings(value: DisplaySettings): void {
|
|
23
|
+
readDisplaySettings(); // Do not replace an unreadable or malformed preferences file.
|
|
24
|
+
if (typeof value.tasks !== "boolean" || typeof value.agents !== "boolean") throw new Error("Invalid teams display settings");
|
|
25
|
+
const path = displayPath(), temporary = `${path}.${randomUUID()}.tmp`;
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(temporary, JSON.stringify({ tasks: value.tasks, agents: value.agents }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
29
|
+
renameSync(temporary, path);
|
|
30
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function policy(cwd: string, trusted: boolean, flags: { limit?: string; depth?: string }) {
|
|
34
|
+
let config: Record<string, unknown> = { limit: 10, depth: 2 };
|
|
35
|
+
const files = [join(homedir(), ".acstack", "ac-teams", "config.json")];
|
|
36
|
+
if (trusted) files.push(join(cwd, ".pi", "ac-teams.json"));
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
let value: unknown;
|
|
39
|
+
try { value = JSON.parse(readFileSync(file, "utf8")); }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error(`Cannot read team policy ${file}: ${String(error)}`); }
|
|
41
|
+
if (!isRecord(value) || Object.keys(value).some(key => !["limit", "depth"].includes(key))) throw new Error(`Invalid team policy: ${file}`);
|
|
42
|
+
config = { ...config, ...value };
|
|
43
|
+
}
|
|
44
|
+
const limit = flags.limit === undefined ? config.limit : Number(flags.limit);
|
|
45
|
+
const depth = flags.depth === undefined ? config.depth : Number(flags.depth);
|
|
46
|
+
if (!Number.isSafeInteger(limit) || Number(limit) < 1 || Number(limit) > 100 || !Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) > 10) throw new Error("Team limit must be 1..100 and depth 0..10");
|
|
47
|
+
if (Number(limit) > 10 && flags.limit === undefined) throw new Error("A ceiling above 10 requires the explicit --teams-limit startup flag");
|
|
48
|
+
return { limit: Number(limit), depth: Number(depth) };
|
|
49
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { connect, type Socket } from "node:net";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import type { Operation } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface Request { id: string; member: string; token: string; operation: Operation; args: unknown }
|
|
7
|
+
export function authentic(token: string, expected: string): boolean {
|
|
8
|
+
const a = Buffer.from(token), b = Buffer.from(expected);
|
|
9
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
10
|
+
}
|
|
11
|
+
// A 100-item page can approach 10 MiB after JSON escaping; requests remain limited to 128 KiB.
|
|
12
|
+
const responseLimit = 16 * 1024 * 1024;
|
|
13
|
+
export function send(socket: Socket, value: Record<string, unknown>): void {
|
|
14
|
+
const line = JSON.stringify(value) + "\n";
|
|
15
|
+
if (socket.destroyed || socket.writableLength + Buffer.byteLength(line) > responseLimit) { socket.destroy(); return; }
|
|
16
|
+
socket.write(line);
|
|
17
|
+
}
|
|
18
|
+
export function readLines(socket: Socket, receive: (value: unknown) => void, limit = 128 * 1024): void {
|
|
19
|
+
let buffer = "";
|
|
20
|
+
socket.setEncoding("utf8");
|
|
21
|
+
socket.on("data", (chunk: string) => {
|
|
22
|
+
buffer += chunk;
|
|
23
|
+
let end: number;
|
|
24
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
25
|
+
const line = buffer.slice(0, end);
|
|
26
|
+
buffer = buffer.slice(end + 1);
|
|
27
|
+
if (Buffer.byteLength(line) > limit) { socket.destroy(); return; }
|
|
28
|
+
try { receive(JSON.parse(line)); }
|
|
29
|
+
catch { socket.destroy(); return; }
|
|
30
|
+
}
|
|
31
|
+
if (Buffer.byteLength(buffer) > limit) socket.destroy();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
export class Connection extends EventEmitter {
|
|
38
|
+
private socket?: Socket;
|
|
39
|
+
private pending = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
|
40
|
+
private closed = false;
|
|
41
|
+
private path: string;
|
|
42
|
+
private member: string;
|
|
43
|
+
private token: string;
|
|
44
|
+
constructor(path: string, member: string, token: string) { super(); this.path = path; this.member = member; this.token = token; }
|
|
45
|
+
async open(): Promise<void> {
|
|
46
|
+
const socket = connect(this.path);
|
|
47
|
+
this.socket = socket;
|
|
48
|
+
readLines(socket, value => {
|
|
49
|
+
if (!isRecord(value)) throw new Error("Invalid coordinator response");
|
|
50
|
+
if (value.type === "notice" && Number.isSafeInteger(value.through)) { this.emit("notice", value.through, Array.isArray(value.messages) ? value.messages : []); return; }
|
|
51
|
+
if (value.type === "command" && typeof value.id === "string" && typeof value.command === "string" && isRecord(value.args)) {
|
|
52
|
+
this.emit("command", { id: value.id, command: value.command, args: value.args }); return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value.id !== "string") return;
|
|
55
|
+
const pending = this.pending.get(value.id);
|
|
56
|
+
if (!pending) return;
|
|
57
|
+
clearTimeout(pending.timer);
|
|
58
|
+
this.pending.delete(value.id);
|
|
59
|
+
if (typeof value.error === "string") pending.reject(new Error(value.error));
|
|
60
|
+
else pending.resolve(value.data);
|
|
61
|
+
}, responseLimit);
|
|
62
|
+
socket.on("error", () => {});
|
|
63
|
+
socket.once("close", () => {
|
|
64
|
+
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("Coordinator disconnected")); }
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
if (!this.closed) this.emit("disconnect");
|
|
67
|
+
});
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Coordinator connection timed out")); }, 10000);
|
|
70
|
+
socket.once("connect", () => { clearTimeout(timer); resolve(); });
|
|
71
|
+
socket.once("error", error => { clearTimeout(timer); reject(error); });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
request(operation: Operation, args: unknown): Promise<unknown> {
|
|
75
|
+
const socket = this.socket;
|
|
76
|
+
if (!socket || socket.destroyed) return Promise.reject(new Error("Coordinator is unavailable"));
|
|
77
|
+
const id = randomUUID();
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error("Coordinator request timed out; inspect state before retrying mutations")); }, 120000);
|
|
80
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
81
|
+
send(socket, { id, member: this.member, token: this.token, operation, args });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
close(): void { this.closed = true; this.socket?.destroy(); }
|
|
85
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
2
|
+
import { chmodSync, mkdirSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { Store, processAlive, processIdentity } from "./store.ts";
|
|
6
|
+
import { RpcAgent } from "./rpc-agent.ts";
|
|
7
|
+
import { TerminalAgent, tmux } from "./terminal-agent.ts";
|
|
8
|
+
import { authentic, isRecord, readLines, send } from "./connection.ts";
|
|
9
|
+
import { validate } from "./schemas.ts";
|
|
10
|
+
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";
|
|
12
|
+
|
|
13
|
+
export const taskRequestGuidance = [
|
|
14
|
+
"Treat user task requests as proposals to assess, not commands to create tasks.",
|
|
15
|
+
"Review the agreed scope and current task list (page as needed); read relevant descriptions, acceptance criteria, statuses, owners, and dependencies.",
|
|
16
|
+
"If an existing task already covers the request, cite its number/name and explain why; do not add a duplicate.",
|
|
17
|
+
"If a coherent extension is the smallest complete solution, update the existing task only when its state and ownership allow it; otherwise coordinate with its owner.",
|
|
18
|
+
"Create a task only for genuinely distinct, in-scope work. Ask for clarification when ambiguity or a scope conflict prevents a sound decision.",
|
|
19
|
+
"Respect completion checks and add only real dependencies. Explain the decision and any actual task changes to the user.",
|
|
20
|
+
].join(" ");
|
|
21
|
+
|
|
22
|
+
export function assistantEvent(message: unknown): { type: string; text: string } | undefined {
|
|
23
|
+
if (!isRecord(message) || message.role !== "assistant") return;
|
|
24
|
+
if (message.stopReason === "error") return { type: "model_error", text: String(message.errorMessage || "Model request failed").slice(0, 2000) };
|
|
25
|
+
if (message.stopReason !== "stop" || !Array.isArray(message.content)) return;
|
|
26
|
+
const text = message.content.filter(part => isRecord(part) && part.type === "text" && typeof part.text === "string").map(part => part.text).join(" ").trim().split("\n")[0].slice(0, 500);
|
|
27
|
+
return text ? { type: "summary", text } : undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface CoordinatorOptions {
|
|
30
|
+
id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
|
|
31
|
+
extension: string; home?: string; command?: string;
|
|
32
|
+
interactive?: boolean; model?: string; thinking?: string;
|
|
33
|
+
notice: (through: number, messages: Message[]) => void;
|
|
34
|
+
warning: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
export class Coordinator {
|
|
37
|
+
readonly store: Store;
|
|
38
|
+
readonly socketPath: string;
|
|
39
|
+
private server?: Server;
|
|
40
|
+
private agents = new Map<string, RpcAgent | TerminalAgent>();
|
|
41
|
+
private controls = new Map<string, { member: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
|
42
|
+
private sockets = new Map<string, Socket>();
|
|
43
|
+
private connections = new Set<Socket>();
|
|
44
|
+
private cleaning = new Set<string>();
|
|
45
|
+
private ready = new Map<string, { resolve: () => void; reject: (error: Error) => void }>();
|
|
46
|
+
private notices = new Map<string, NodeJS.Timeout>();
|
|
47
|
+
private operations = new Set<Promise<unknown>>();
|
|
48
|
+
private closed = false;
|
|
49
|
+
private closePromise?: Promise<void>;
|
|
50
|
+
|
|
51
|
+
private options: CoordinatorOptions;
|
|
52
|
+
private constructor(options: CoordinatorOptions) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
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 };
|
|
55
|
+
const initial: TeamState = { version: 3, id: options.id, root: "root", limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
|
|
56
|
+
this.store = new Store(initial, options.home);
|
|
57
|
+
this.socketPath = join(this.store.dir, "coordinator.sock");
|
|
58
|
+
}
|
|
59
|
+
static async open(options: CoordinatorOptions): Promise<Coordinator> {
|
|
60
|
+
const coordinator = new Coordinator(options);
|
|
61
|
+
try {
|
|
62
|
+
for (const member of Object.values(coordinator.store.state.members)) {
|
|
63
|
+
if (member.id !== "root" && isLive(member) && member.pid && processAlive(member.pid, member.identity)) {
|
|
64
|
+
throw new Error(`Previous member ${member.id} is still live or uncertain; stop it explicitly before recovery`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
coordinator.store.update(state => {
|
|
68
|
+
state.limit = options.limit; state.depth = options.depth;
|
|
69
|
+
const names = new Set(Object.values(state.members).map(member => member.name));
|
|
70
|
+
let number = 0;
|
|
71
|
+
for (const member of Object.values(state.members)) if (member.id !== "root" && !member.name) {
|
|
72
|
+
do { number++; } while (names.has(`teammate-${number}`) || Object.hasOwn(state.members, `teammate-${number}`));
|
|
73
|
+
member.name = `teammate-${number}`; names.add(member.name);
|
|
74
|
+
}
|
|
75
|
+
state.members.root.status = "idle";
|
|
76
|
+
state.members.root.sessionFile = options.sessionFile;
|
|
77
|
+
state.members.root.model = options.model;
|
|
78
|
+
state.members.root.thinking = options.thinking;
|
|
79
|
+
for (const member of Object.values(state.members)) if (member.id !== "root" && isLive(member)) member.status = "interrupted";
|
|
80
|
+
});
|
|
81
|
+
try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
82
|
+
coordinator.server = createServer(socket => coordinator.accept(socket));
|
|
83
|
+
await new Promise<void>((done, fail) => {
|
|
84
|
+
coordinator.server!.once("error", fail);
|
|
85
|
+
coordinator.server!.listen(coordinator.socketPath, () => { chmodSync(coordinator.socketPath, 0o600); done(); });
|
|
86
|
+
});
|
|
87
|
+
coordinator.server.on("error", error => options.warning(error.message));
|
|
88
|
+
coordinator.notify("root");
|
|
89
|
+
return coordinator;
|
|
90
|
+
} catch (error) { coordinator.server?.close(); coordinator.store.close(); throw error; }
|
|
91
|
+
}
|
|
92
|
+
private accept(socket: Socket): void {
|
|
93
|
+
socket.on("error", () => {});
|
|
94
|
+
if (this.closed) { socket.destroy(); return; }
|
|
95
|
+
this.connections.add(socket);
|
|
96
|
+
let bound: string | undefined;
|
|
97
|
+
socket.on("close", () => {
|
|
98
|
+
this.connections.delete(socket);
|
|
99
|
+
if (bound && this.sockets.get(bound) === socket) {
|
|
100
|
+
this.sockets.delete(bound);
|
|
101
|
+
for (const [id, pending] of this.controls) if (pending.member === bound) {
|
|
102
|
+
clearTimeout(pending.timer); this.controls.delete(id); pending.reject(new Error("Native member disconnected"));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
readLines(socket, value => {
|
|
107
|
+
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; }
|
|
108
|
+
const member = this.store.state.members[value.member];
|
|
109
|
+
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; }
|
|
110
|
+
bound = member.id;
|
|
111
|
+
const operation = value.operation;
|
|
112
|
+
const run = async () => {
|
|
113
|
+
if (operation === "join") {
|
|
114
|
+
const previous = this.sockets.get(member.id);
|
|
115
|
+
if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
|
|
116
|
+
const args = value.args;
|
|
117
|
+
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");
|
|
118
|
+
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.");
|
|
119
|
+
this.store.update(state => {
|
|
120
|
+
state.members[member.id].sessionId = args.sessionId as string;
|
|
121
|
+
state.members[member.id].sessionFile = args.sessionFile as string;
|
|
122
|
+
});
|
|
123
|
+
this.sockets.set(member.id, socket);
|
|
124
|
+
this.ready.get(member.id)?.resolve();
|
|
125
|
+
this.notify(member.id);
|
|
126
|
+
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 };
|
|
127
|
+
}
|
|
128
|
+
if (this.sockets.get(member.id) !== socket) throw new Error("Join before making team requests");
|
|
129
|
+
if (operation === "control_result") {
|
|
130
|
+
if (!isRecord(value.args) || typeof value.args.id !== "string") throw new Error("Invalid native command response");
|
|
131
|
+
const pending = this.controls.get(value.args.id);
|
|
132
|
+
if (!pending || pending.member !== member.id) throw new Error("Unknown native command response");
|
|
133
|
+
clearTimeout(pending.timer); this.controls.delete(value.args.id);
|
|
134
|
+
if (typeof value.args.error === "string") pending.reject(new Error(value.args.error.slice(0, 2000)));
|
|
135
|
+
else pending.resolve(value.args.data);
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
if (operation === "ack") {
|
|
139
|
+
if (!isRecord(value.args) || !Number.isSafeInteger(value.args.through)) throw new Error("Invalid notification acknowledgement");
|
|
140
|
+
this.ack(member.id, Number(value.args.through)); return {};
|
|
141
|
+
}
|
|
142
|
+
return this.handle(member.id, operation, value.args);
|
|
143
|
+
};
|
|
144
|
+
void run().then(data => send(socket, { id: value.id, data }), error => {
|
|
145
|
+
if (operation === "join") this.ready.get(member.id)?.reject(error instanceof Error ? error : new Error("Member readiness failed"));
|
|
146
|
+
send(socket, { id: value.id, error: error instanceof Error ? error.message : "Team request failed" });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
private control(member: string, command: string, args: Record<string, unknown> = {}, timeout = 10000): Promise<unknown> {
|
|
151
|
+
const socket = this.sockets.get(member);
|
|
152
|
+
if (!socket || socket.destroyed) return Promise.reject(new Error("Native member is not connected"));
|
|
153
|
+
const id = randomUUID();
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const timer = setTimeout(() => { this.controls.delete(id); reject(new Error(`Native ${command} timed out`)); }, timeout);
|
|
156
|
+
this.controls.set(id, { member, resolve, reject, timer });
|
|
157
|
+
send(socket, { type: "command", id, command, args });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
recordEvent(actor: string, value: unknown): void {
|
|
161
|
+
validate("event", value);
|
|
162
|
+
if (this.closed) return;
|
|
163
|
+
const event = value as { type: string; text?: string; model?: string; modelName?: string; contextUsage?: ContextUsage | null };
|
|
164
|
+
const member = this.store.state.members[actor];
|
|
165
|
+
if (!member || !isLive(member) || (member.status === "stopping" && event.type !== "model_error")) return;
|
|
166
|
+
this.store.update(next => {
|
|
167
|
+
const current = next.members[actor];
|
|
168
|
+
if (event.type === "telemetry") {
|
|
169
|
+
current.model = event.model ?? current.model;
|
|
170
|
+
current.modelName = event.modelName;
|
|
171
|
+
current.contextUsage = event.contextUsage ?? undefined;
|
|
172
|
+
}
|
|
173
|
+
else if (event.type === "agent_start") { current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
|
|
174
|
+
else if (event.type === "agent_settled") { current.status = "idle"; current.activity = current.error ? "Model request failed" : current.summary || "Available for follow-up"; }
|
|
175
|
+
else if (event.type === "activity") current.activity = event.text;
|
|
176
|
+
else if (event.type === "summary") current.summary = event.text?.slice(0, 500);
|
|
177
|
+
else if (event.type === "model_error") current.error = event.text || "Model request failed";
|
|
178
|
+
});
|
|
179
|
+
if (event.type === "model_error" && actor !== "root" && member.status !== "stopping") {
|
|
180
|
+
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : "root";
|
|
181
|
+
this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
|
|
182
|
+
this.notify(recipient);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
private async userAction(actor: string, args: UserAction): Promise<string> {
|
|
186
|
+
if (!this.options.interactive) throw new Error("User team actions require an interactive root");
|
|
187
|
+
const resume = async (id: string) => {
|
|
188
|
+
const member = this.store.state.members[id];
|
|
189
|
+
if (!member) throw new Error("Unknown member ID");
|
|
190
|
+
if (member.status === "stopping") throw new Error("Member is stopping; retry after it stops");
|
|
191
|
+
if (isLive(member)) return;
|
|
192
|
+
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);
|
|
193
|
+
this.operations.add(launch);
|
|
194
|
+
try { await launch; } finally { this.operations.delete(launch); }
|
|
195
|
+
};
|
|
196
|
+
if (args.action === "resume") {
|
|
197
|
+
if (!args.id) throw new Error("A member ID is required");
|
|
198
|
+
await resume(args.id); return `Resumed ${args.id}`;
|
|
199
|
+
}
|
|
200
|
+
if (!args.text?.trim()) throw new Error("Write a request or feedback before sending");
|
|
201
|
+
let recipient = "root", fallback = "";
|
|
202
|
+
let text = `User task request (review before changing tasks):\n${taskRequestGuidance}\n\nRequest:\n${args.text}`;
|
|
203
|
+
if (args.action === "feedback") {
|
|
204
|
+
const task = this.store.state.tasks[args.id ?? ""];
|
|
205
|
+
if (!task) throw new Error("Unknown task ID");
|
|
206
|
+
recipient = task.creator ?? "root";
|
|
207
|
+
text = `User feedback on task ${taskName(task)} [${task.id}] (${task.title}):\n${args.text}`;
|
|
208
|
+
if (!isLive(this.store.state.members[recipient]) || this.store.state.members[recipient].status === "stopping") {
|
|
209
|
+
try { await resume(recipient); }
|
|
210
|
+
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to Main instead. `; recipient = "root"; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
|
|
214
|
+
this.notify(recipient);
|
|
215
|
+
return `${fallback}Sent to ${recipient === "root" ? "Main" : recipient} · message ${message.seq}`;
|
|
216
|
+
}
|
|
217
|
+
private publicMember(member: Member) {
|
|
218
|
+
const { token: _token, identity: _identity, ...view } = member;
|
|
219
|
+
return view;
|
|
220
|
+
}
|
|
221
|
+
live(): Member[] { return Object.values(this.store.state.members).filter(member => member.id !== "root" && isLive(member)); }
|
|
222
|
+
ack(id: string, through: number): void {
|
|
223
|
+
const last = this.store.state.messages.at(-1)?.seq ?? 0;
|
|
224
|
+
if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
|
|
225
|
+
this.store.update(state => { state.members[id].notified = Math.max(state.members[id].notified, through); });
|
|
226
|
+
}
|
|
227
|
+
private notify(id: string): void {
|
|
228
|
+
if (this.closed || this.notices.has(id)) return;
|
|
229
|
+
this.notices.set(id, setTimeout(() => {
|
|
230
|
+
this.notices.delete(id);
|
|
231
|
+
const member = this.store.state.members[id];
|
|
232
|
+
if (!member || !isLive(member)) return;
|
|
233
|
+
const pending = this.store.state.messages.filter(message => message.seq > member.notified && message.recipients.includes(id));
|
|
234
|
+
const through = pending.at(-1)?.seq;
|
|
235
|
+
if (!through) return;
|
|
236
|
+
try {
|
|
237
|
+
if (id === "root") this.options.notice(through, pending.slice(-5));
|
|
238
|
+
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
|
|
239
|
+
} catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
|
|
240
|
+
}, 50));
|
|
241
|
+
}
|
|
242
|
+
async handle(actor: string, operation: string, args: unknown, authorizedLimit = false): Promise<unknown> {
|
|
243
|
+
if (this.closed) throw new Error("Team is stopping");
|
|
244
|
+
const member = this.store.state.members[actor];
|
|
245
|
+
if (!member || !isLive(member)) throw new Error("Member is not active");
|
|
246
|
+
validate(operation, args);
|
|
247
|
+
if (operation === "event") return this.recordEvent(actor, args);
|
|
248
|
+
if (operation === "ui") return this.userAction(actor, args as UserAction);
|
|
249
|
+
if (operation === "task") return this.store.task(actor, args as TaskArgs);
|
|
250
|
+
if (operation === "message") {
|
|
251
|
+
const result = this.store.message(actor, args as MessageArgs);
|
|
252
|
+
if ((args as MessageArgs).action === "send") for (const id of Object.keys(this.store.state.members)) this.notify(id);
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
return this.agent(actor, args as AgentArgs, authorizedLimit);
|
|
256
|
+
}
|
|
257
|
+
private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
|
|
258
|
+
const state = this.store.state;
|
|
259
|
+
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)) };
|
|
260
|
+
if (args.action === "limits") {
|
|
261
|
+
if (actor !== "root") throw new Error("Only the root can change team limits");
|
|
262
|
+
if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
|
|
263
|
+
this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
|
|
264
|
+
return { limit: this.store.state.limit, depth: this.store.state.depth };
|
|
265
|
+
}
|
|
266
|
+
if (args.action === "launch" || args.action === "resume") {
|
|
267
|
+
const launch = this.launch(actor, args);
|
|
268
|
+
this.operations.add(launch);
|
|
269
|
+
try { return await launch; } finally { this.operations.delete(launch); }
|
|
270
|
+
}
|
|
271
|
+
const member = args.id ? state.members[args.id] : undefined;
|
|
272
|
+
if (!member) throw new Error("Unknown member ID");
|
|
273
|
+
if (args.action === "status") return this.publicMember(member);
|
|
274
|
+
if (member.id === "root" || !isDescendant(state, member.id, actor)) throw new Error("Only a member's ancestor can stop or clean it up");
|
|
275
|
+
if (args.action === "cleanup") {
|
|
276
|
+
if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
|
|
277
|
+
if (!member.worktree) throw new Error("Member has no worktree");
|
|
278
|
+
if (this.cleaning.has(member.id)) throw new Error("Member cleanup is already running");
|
|
279
|
+
this.cleaning.add(member.id);
|
|
280
|
+
const worktree = member.worktree;
|
|
281
|
+
const cleanup = (async () => {
|
|
282
|
+
await removeWorktree(worktree);
|
|
283
|
+
this.store.update(next => { delete next.members[member.id].worktree; });
|
|
284
|
+
return { removed: true, branchRetained: worktree.branch };
|
|
285
|
+
})();
|
|
286
|
+
this.operations.add(cleanup);
|
|
287
|
+
try { return await cleanup; } finally { this.cleaning.delete(member.id); this.operations.delete(cleanup); }
|
|
288
|
+
}
|
|
289
|
+
await this.stopTree(member.id);
|
|
290
|
+
return this.publicMember(this.store.state.members[member.id]);
|
|
291
|
+
}
|
|
292
|
+
private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
|
|
293
|
+
const state = this.store.state;
|
|
294
|
+
const parent = state.members[actor];
|
|
295
|
+
if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
|
|
296
|
+
if (!args.model || !args.model.includes("/")) throw new Error("A provider/model is required (normally inherited from the requesting member)");
|
|
297
|
+
const resumed = args.action === "resume" ? state.members[args.id ?? ""] : undefined;
|
|
298
|
+
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");
|
|
299
|
+
if (resumed && this.cleaning.has(resumed.id)) throw new Error("Worktree cleanup is running; cannot resume this member");
|
|
300
|
+
if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
|
|
301
|
+
const depth = resumed ? resumed.depth : parent.depth + 1;
|
|
302
|
+
if (depth > state.depth || this.live().length >= state.limit) throw new Error("Team depth or live-member ceiling reached (idle members count)");
|
|
303
|
+
if (!resumed && (args.name === "main" || args.name === "root" || Object.values(state.members).some(member => member.name === args.name || member.id === args.name))) throw new Error("Teammate name is already in use or reserved");
|
|
304
|
+
const id = resumed?.id ?? randomUUID().slice(0, 16);
|
|
305
|
+
const cwd = realpathSync(resolve(args.cwd ?? resumed?.cwd ?? parent.cwd));
|
|
306
|
+
if (!statSync(cwd).isDirectory()) throw new Error("Agent cwd must be a directory");
|
|
307
|
+
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 };
|
|
308
|
+
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
|
|
309
|
+
this.store.update(next => { next.members[id] = member; });
|
|
310
|
+
member.model = args.model; member.thinking = args.thinking ?? "off";
|
|
311
|
+
member.terminal = this.options.interactive === true;
|
|
312
|
+
this.store.update(next => { next.members[id] = member; });
|
|
313
|
+
let agent: RpcAgent | TerminalAgent | undefined;
|
|
314
|
+
let timer: NodeJS.Timeout | undefined;
|
|
315
|
+
try {
|
|
316
|
+
if (args.worktree && !resumed) {
|
|
317
|
+
member.worktree = await createWorktree(cwd, state.id, id, args.base, args.target);
|
|
318
|
+
member.cwd = member.worktree.path;
|
|
319
|
+
this.store.update(next => { next.members[id].worktree = member.worktree; next.members[id].cwd = member.cwd; });
|
|
320
|
+
}
|
|
321
|
+
if (this.closed || this.store.state.members[id].status !== "starting") throw new Error("Launch was stopped before startup");
|
|
322
|
+
const readiness = new Promise<void>((done, fail) => {
|
|
323
|
+
this.ready.set(id, { resolve: done, reject: fail });
|
|
324
|
+
timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), 20000);
|
|
325
|
+
});
|
|
326
|
+
// Attach immediately: a spawn error must not leave a rejected readiness promise unobserved.
|
|
327
|
+
void readiness.catch(() => {});
|
|
328
|
+
const slash = args.model.indexOf("/");
|
|
329
|
+
mkdirSync(join(this.store.dir, "sessions"), { recursive: true, mode: 0o700 });
|
|
330
|
+
chmodSync(join(this.store.dir, "sessions"), 0o700);
|
|
331
|
+
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"];
|
|
332
|
+
if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
|
|
333
|
+
const spawnOptions = { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
|
|
334
|
+
agent = this.options.interactive
|
|
335
|
+
? new TerminalAgent({ ...spawnOptions, id, dir: this.store.dir, request: (type, fields, timeout) => this.control(id, type, fields, timeout) })
|
|
336
|
+
: new RpcAgent(spawnOptions);
|
|
337
|
+
this.agents.set(id, agent);
|
|
338
|
+
agent.on("failure", error => this.options.warning(`Member ${id}: ${(error as Error).message}`));
|
|
339
|
+
agent.on("forced", signal => this.options.warning(`Member ${id} required ${signal}`));
|
|
340
|
+
agent.on("event", event => {
|
|
341
|
+
if (this.closed) return;
|
|
342
|
+
if (event.type === "agent_start" || event.type === "agent_settled") this.recordEvent(id, { type: event.type });
|
|
343
|
+
if (event.type === "tool_execution_start") this.recordEvent(id, { type: "activity", text: `Running ${String(event.toolName ?? "tool").slice(0, 100)}` });
|
|
344
|
+
if (event.type === "message_end") {
|
|
345
|
+
const change = assistantEvent(event.message);
|
|
346
|
+
if (change) this.recordEvent(id, change);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
agent.once("exit", () => {
|
|
350
|
+
this.agents.delete(id);
|
|
351
|
+
if (this.closed) return;
|
|
352
|
+
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; });
|
|
353
|
+
void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
|
|
354
|
+
});
|
|
355
|
+
if (agent instanceof TerminalAgent) await agent.start();
|
|
356
|
+
member.pid = agent.process.pid;
|
|
357
|
+
member.identity = member.pid ? processIdentity(member.pid) : undefined;
|
|
358
|
+
this.store.update(next => { next.members[id].pid = member.pid; next.members[id].identity = member.identity; });
|
|
359
|
+
try {
|
|
360
|
+
await Promise.race([readiness, agent.exited.then(() => { throw new Error("Child exited before readiness"); })]);
|
|
361
|
+
} finally { if (timer) clearTimeout(timer); this.ready.delete(id); }
|
|
362
|
+
const rpcState = await agent.request("get_state");
|
|
363
|
+
if (!isRecord(rpcState) || !isRecord(rpcState.model) || rpcState.model.provider !== args.model.slice(0, slash) || rpcState.model.id !== args.model.slice(slash + 1)) throw new Error("Pi did not select the requested model");
|
|
364
|
+
if (rpcState.model.name !== undefined || rpcState.contextUsage !== undefined) this.recordEvent(id, { type: "telemetry", model: args.model, modelName: rpcState.model.name, contextUsage: rpcState.contextUsage ?? null });
|
|
365
|
+
this.store.update(next => { if (next.members[id].status === "starting") next.members[id].status = prompt ? "busy" : "idle"; });
|
|
366
|
+
if (prompt) await agent.request("prompt", { message: args.brief, streamingBehavior: "followUp" });
|
|
367
|
+
return this.publicMember(this.store.state.members[id]);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
clearTimeout(timer); this.ready.delete(id);
|
|
370
|
+
if (agent) await agent.stop();
|
|
371
|
+
this.store.update(next => { next.members[id].status = "interrupted"; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
private async stopDescendants(id: string): Promise<void> {
|
|
376
|
+
const descendants = this.live().filter(member => isDescendant(this.store.state, member.id, id)).sort((a, b) => b.depth - a.depth);
|
|
377
|
+
for (const member of descendants) await this.stopOne(member.id);
|
|
378
|
+
}
|
|
379
|
+
private async stopOne(id: string): Promise<void> {
|
|
380
|
+
const member = this.store.state.members[id];
|
|
381
|
+
if (!isLive(member)) return;
|
|
382
|
+
this.store.update(next => { next.members[id].status = "stopping"; });
|
|
383
|
+
const agent = this.agents.get(id);
|
|
384
|
+
if (agent) await agent.stop();
|
|
385
|
+
else if (member.pid && processAlive(member.pid, member.identity)) throw new Error("No owned transport for live process; refusing to kill by PID");
|
|
386
|
+
this.store.update(next => { next.members[id].status = "stopped"; });
|
|
387
|
+
}
|
|
388
|
+
async stopTree(id: string): Promise<void> { await this.stopDescendants(id); await this.stopOne(id); }
|
|
389
|
+
close(): Promise<void> { this.closePromise ??= this.closeOnce(); return this.closePromise; }
|
|
390
|
+
private async closeOnce(): Promise<void> {
|
|
391
|
+
this.closed = true;
|
|
392
|
+
for (const timer of this.notices.values()) clearTimeout(timer);
|
|
393
|
+
for (const member of this.live().sort((a, b) => b.depth - a.depth)) await this.stopOne(member.id);
|
|
394
|
+
// Finish recording worktree operations already in flight before the store closes.
|
|
395
|
+
await Promise.allSettled([...this.operations]);
|
|
396
|
+
for (const socket of this.connections) socket.destroy();
|
|
397
|
+
if (this.server) await new Promise<void>(done => this.server!.close(() => done()));
|
|
398
|
+
try { unlinkSync(this.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
399
|
+
for (const pending of this.controls.values()) { clearTimeout(pending.timer); pending.reject(new Error("Team stopped")); }
|
|
400
|
+
this.controls.clear();
|
|
401
|
+
if (this.options.interactive) {
|
|
402
|
+
const socket = join(this.store.dir, "t.sock");
|
|
403
|
+
await tmux(socket, ["kill-server"]).catch(() => {});
|
|
404
|
+
try { unlinkSync(socket); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
405
|
+
}
|
|
406
|
+
this.store.close();
|
|
407
|
+
}
|
|
408
|
+
}
|