ac-teams 0.0.1 → 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/README.md +28 -183
- package/docs/development.md +89 -0
- package/docs/implementation-plan.md +4 -4
- package/docs/operations.md +101 -0
- package/docs/usage.md +128 -0
- package/package.json +5 -2
- package/src/commands.ts +21 -0
- package/src/config.ts +29 -2
- package/src/connection.ts +4 -1
- package/src/coordinator.ts +156 -30
- package/src/graph.ts +79 -0
- package/src/index.ts +155 -13
- package/src/panel.ts +309 -0
- package/src/render.ts +71 -0
- package/src/schemas.ts +27 -1
- package/src/store.ts +45 -14
- 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 +41 -3
- package/src/ui-types.ts +19 -0
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
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
4
5
|
import { isRecord } from "./connection.ts";
|
|
5
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
|
+
|
|
6
33
|
export function policy(cwd: string, trusted: boolean, flags: { limit?: string; depth?: string }) {
|
|
7
34
|
let config: Record<string, unknown> = { limit: 10, depth: 2 };
|
|
8
35
|
const files = [join(homedir(), ".acstack", "ac-teams", "config.json")];
|
package/src/connection.ts
CHANGED
|
@@ -47,7 +47,10 @@ export class Connection extends EventEmitter {
|
|
|
47
47
|
this.socket = socket;
|
|
48
48
|
readLines(socket, value => {
|
|
49
49
|
if (!isRecord(value)) throw new Error("Invalid coordinator response");
|
|
50
|
-
if (value.type === "notice" && Number.isSafeInteger(value.through)) { this.emit("notice", value.through); return; }
|
|
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
|
+
}
|
|
51
54
|
if (typeof value.id !== "string") return;
|
|
52
55
|
const pending = this.pending.get(value.id);
|
|
53
56
|
if (!pending) return;
|
package/src/coordinator.ts
CHANGED
|
@@ -4,22 +4,41 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { Store, processAlive, processIdentity } from "./store.ts";
|
|
6
6
|
import { RpcAgent } from "./rpc-agent.ts";
|
|
7
|
+
import { TerminalAgent, tmux } from "./terminal-agent.ts";
|
|
7
8
|
import { authentic, isRecord, readLines, send } from "./connection.ts";
|
|
8
9
|
import { validate } from "./schemas.ts";
|
|
9
10
|
import { createWorktree, removeWorktree } from "./worktrees.ts";
|
|
10
|
-
import { isDescendant, isLive, type AgentArgs, type Member, type MessageArgs, type TaskArgs, type TeamState } from "./types.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";
|
|
11
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
|
+
}
|
|
12
29
|
export interface CoordinatorOptions {
|
|
13
30
|
id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
|
|
14
31
|
extension: string; home?: string; command?: string;
|
|
15
|
-
|
|
32
|
+
interactive?: boolean; model?: string; thinking?: string;
|
|
33
|
+
notice: (through: number, messages: Message[]) => void;
|
|
16
34
|
warning: (message: string) => void;
|
|
17
35
|
}
|
|
18
36
|
export class Coordinator {
|
|
19
37
|
readonly store: Store;
|
|
20
38
|
readonly socketPath: string;
|
|
21
39
|
private server?: Server;
|
|
22
|
-
private agents = new Map<string, RpcAgent>();
|
|
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 }>();
|
|
23
42
|
private sockets = new Map<string, Socket>();
|
|
24
43
|
private connections = new Set<Socket>();
|
|
25
44
|
private cleaning = new Set<string>();
|
|
@@ -32,8 +51,8 @@ export class Coordinator {
|
|
|
32
51
|
private options: CoordinatorOptions;
|
|
33
52
|
private constructor(options: CoordinatorOptions) {
|
|
34
53
|
this.options = options;
|
|
35
|
-
const root: Member = { id: "root", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile };
|
|
36
|
-
const initial: TeamState = { version:
|
|
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: [] };
|
|
37
56
|
this.store = new Store(initial, options.home);
|
|
38
57
|
this.socketPath = join(this.store.dir, "coordinator.sock");
|
|
39
58
|
}
|
|
@@ -47,6 +66,16 @@ export class Coordinator {
|
|
|
47
66
|
}
|
|
48
67
|
coordinator.store.update(state => {
|
|
49
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;
|
|
50
79
|
for (const member of Object.values(state.members)) if (member.id !== "root" && isLive(member)) member.status = "interrupted";
|
|
51
80
|
});
|
|
52
81
|
try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
@@ -65,11 +94,19 @@ export class Coordinator {
|
|
|
65
94
|
if (this.closed) { socket.destroy(); return; }
|
|
66
95
|
this.connections.add(socket);
|
|
67
96
|
let bound: string | undefined;
|
|
68
|
-
socket.on("close", () => {
|
|
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
|
+
});
|
|
69
106
|
readLines(socket, value => {
|
|
70
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; }
|
|
71
108
|
const member = this.store.state.members[value.member];
|
|
72
|
-
if (this.closed || !member || !isLive(member) || !authentic(value.token, member.token) || member.id === "root" || (bound && bound !== member.id)) { socket.destroy(); return; }
|
|
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; }
|
|
73
110
|
bound = member.id;
|
|
74
111
|
const operation = value.operation;
|
|
75
112
|
const run = async () => {
|
|
@@ -89,6 +126,15 @@ export class Coordinator {
|
|
|
89
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 };
|
|
90
127
|
}
|
|
91
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
|
+
}
|
|
92
138
|
if (operation === "ack") {
|
|
93
139
|
if (!isRecord(value.args) || !Number.isSafeInteger(value.args.through)) throw new Error("Invalid notification acknowledgement");
|
|
94
140
|
this.ack(member.id, Number(value.args.through)); return {};
|
|
@@ -101,6 +147,73 @@ export class Coordinator {
|
|
|
101
147
|
});
|
|
102
148
|
});
|
|
103
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
|
+
}
|
|
104
217
|
private publicMember(member: Member) {
|
|
105
218
|
const { token: _token, identity: _identity, ...view } = member;
|
|
106
219
|
return view;
|
|
@@ -121,8 +234,8 @@ export class Coordinator {
|
|
|
121
234
|
const through = pending.at(-1)?.seq;
|
|
122
235
|
if (!through) return;
|
|
123
236
|
try {
|
|
124
|
-
if (id === "root") this.options.notice(through);
|
|
125
|
-
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through }); }
|
|
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) }); }
|
|
126
239
|
} catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
|
|
127
240
|
}, 50));
|
|
128
241
|
}
|
|
@@ -131,6 +244,8 @@ export class Coordinator {
|
|
|
131
244
|
const member = this.store.state.members[actor];
|
|
132
245
|
if (!member || !isLive(member)) throw new Error("Member is not active");
|
|
133
246
|
validate(operation, args);
|
|
247
|
+
if (operation === "event") return this.recordEvent(actor, args);
|
|
248
|
+
if (operation === "ui") return this.userAction(actor, args as UserAction);
|
|
134
249
|
if (operation === "task") return this.store.task(actor, args as TaskArgs);
|
|
135
250
|
if (operation === "message") {
|
|
136
251
|
const result = this.store.message(actor, args as MessageArgs);
|
|
@@ -141,7 +256,7 @@ export class Coordinator {
|
|
|
141
256
|
}
|
|
142
257
|
private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
|
|
143
258
|
const state = this.store.state;
|
|
144
|
-
if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, members: Object.values(state.members).map(member => this.publicMember(member)) };
|
|
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)) };
|
|
145
260
|
if (args.action === "limits") {
|
|
146
261
|
if (actor !== "root") throw new Error("Only the root can change team limits");
|
|
147
262
|
if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
|
|
@@ -174,7 +289,7 @@ export class Coordinator {
|
|
|
174
289
|
await this.stopTree(member.id);
|
|
175
290
|
return this.publicMember(this.store.state.members[member.id]);
|
|
176
291
|
}
|
|
177
|
-
private async launch(actor: string, args: AgentArgs): Promise<unknown> {
|
|
292
|
+
private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
|
|
178
293
|
const state = this.store.state;
|
|
179
294
|
const parent = state.members[actor];
|
|
180
295
|
if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
|
|
@@ -185,13 +300,18 @@ export class Coordinator {
|
|
|
185
300
|
if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
|
|
186
301
|
const depth = resumed ? resumed.depth : parent.depth + 1;
|
|
187
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");
|
|
188
304
|
const id = resumed?.id ?? randomUUID().slice(0, 16);
|
|
189
305
|
const cwd = realpathSync(resolve(args.cwd ?? resumed?.cwd ?? parent.cwd));
|
|
190
306
|
if (!statSync(cwd).isDirectory()) throw new Error("Agent cwd must be a directory");
|
|
191
|
-
const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined } : { id, parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0 };
|
|
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 };
|
|
192
308
|
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
|
|
193
309
|
this.store.update(next => { next.members[id] = member; });
|
|
194
|
-
|
|
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;
|
|
195
315
|
try {
|
|
196
316
|
if (args.worktree && !resumed) {
|
|
197
317
|
member.worktree = await createWorktree(cwd, state.id, id, args.base, args.target);
|
|
@@ -199,7 +319,6 @@ export class Coordinator {
|
|
|
199
319
|
this.store.update(next => { next.members[id].worktree = member.worktree; next.members[id].cwd = member.cwd; });
|
|
200
320
|
}
|
|
201
321
|
if (this.closed || this.store.state.members[id].status !== "starting") throw new Error("Launch was stopped before startup");
|
|
202
|
-
let timer: NodeJS.Timeout | undefined;
|
|
203
322
|
const readiness = new Promise<void>((done, fail) => {
|
|
204
323
|
this.ready.set(id, { resolve: done, reject: fail });
|
|
205
324
|
timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), 20000);
|
|
@@ -209,25 +328,22 @@ export class Coordinator {
|
|
|
209
328
|
const slash = args.model.indexOf("/");
|
|
210
329
|
mkdirSync(join(this.store.dir, "sessions"), { recursive: true, mode: 0o700 });
|
|
211
330
|
chmodSync(join(this.store.dir, "sessions"), 0o700);
|
|
212
|
-
const rpcArgs = ["--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"];
|
|
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"];
|
|
213
332
|
if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
|
|
214
|
-
|
|
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);
|
|
215
337
|
this.agents.set(id, agent);
|
|
216
338
|
agent.on("failure", error => this.options.warning(`Member ${id}: ${(error as Error).message}`));
|
|
217
339
|
agent.on("forced", signal => this.options.warning(`Member ${id} required ${signal}`));
|
|
218
340
|
agent.on("event", event => {
|
|
219
341
|
if (this.closed) return;
|
|
220
|
-
if (event.type === "agent_start" || event.type === "agent_settled") this.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (event.type === "message_end" && isRecord(event.message) && event.message.role === "assistant" && event.message.stopReason === "error") {
|
|
226
|
-
const error = typeof event.message.errorMessage === "string" ? event.message.errorMessage.slice(0, 2000) : "Model request failed";
|
|
227
|
-
this.store.update(next => { next.members[id].error = error; });
|
|
228
|
-
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : "root";
|
|
229
|
-
this.store.message(id, { action: "send", recipients: [recipient], text: `Member ${id} model error: ${error}` });
|
|
230
|
-
this.notify(recipient);
|
|
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);
|
|
231
347
|
}
|
|
232
348
|
});
|
|
233
349
|
agent.once("exit", () => {
|
|
@@ -236,18 +352,21 @@ export class Coordinator {
|
|
|
236
352
|
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; });
|
|
237
353
|
void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
|
|
238
354
|
});
|
|
355
|
+
if (agent instanceof TerminalAgent) await agent.start();
|
|
239
356
|
member.pid = agent.process.pid;
|
|
240
357
|
member.identity = member.pid ? processIdentity(member.pid) : undefined;
|
|
241
|
-
this.store.update(next => { next.members[id] = member; });
|
|
358
|
+
this.store.update(next => { next.members[id].pid = member.pid; next.members[id].identity = member.identity; });
|
|
242
359
|
try {
|
|
243
360
|
await Promise.race([readiness, agent.exited.then(() => { throw new Error("Child exited before readiness"); })]);
|
|
244
361
|
} finally { if (timer) clearTimeout(timer); this.ready.delete(id); }
|
|
245
362
|
const rpcState = await agent.request("get_state");
|
|
246
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");
|
|
247
|
-
|
|
248
|
-
|
|
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" });
|
|
249
367
|
return this.publicMember(this.store.state.members[id]);
|
|
250
368
|
} catch (error) {
|
|
369
|
+
clearTimeout(timer); this.ready.delete(id);
|
|
251
370
|
if (agent) await agent.stop();
|
|
252
371
|
this.store.update(next => { next.members[id].status = "interrupted"; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
|
|
253
372
|
throw error;
|
|
@@ -277,6 +396,13 @@ export class Coordinator {
|
|
|
277
396
|
for (const socket of this.connections) socket.destroy();
|
|
278
397
|
if (this.server) await new Promise<void>(done => this.server!.close(() => done()));
|
|
279
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
|
+
}
|
|
280
406
|
this.store.close();
|
|
281
407
|
}
|
|
282
408
|
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { render, type MermaidArt } from "grok-mermaid";
|
|
2
|
+
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { taskName, type TaskView } from "./types.ts";
|
|
6
|
+
import { cleanText } from "./render.ts";
|
|
7
|
+
|
|
8
|
+
export function taskStatus(task: TaskView): "Done" | "Blocked" | "Working" | "Ready" {
|
|
9
|
+
return task.status === "done" ? "Done" : task.blockedBy.length ? "Blocked" : task.status === "in_progress" ? "Working" : "Ready";
|
|
10
|
+
}
|
|
11
|
+
export type Bounds = { left: number; right: number; top: number; bottom: number };
|
|
12
|
+
export type Direction = "up" | "down" | "left" | "right";
|
|
13
|
+
export type TaskGraph = { art: MermaidArt; bounds: Map<string, Bounds> };
|
|
14
|
+
|
|
15
|
+
const label = (text: string) => stripVTControlCharacters(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
|
|
16
|
+
.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
17
|
+
const cell = (art: MermaidArt, y: number, x: number) => sliceByColumn(art.plain[y] ?? "", x, 1);
|
|
18
|
+
|
|
19
|
+
export function buildTaskGraph(tasks: TaskView[]): TaskGraph | null {
|
|
20
|
+
if (!tasks.length || tasks.length > 100 || tasks.reduce((size, task) => size + task.dependencies.length, 0) > 300) return null;
|
|
21
|
+
const ids = new Set(tasks.map(task => task.id));
|
|
22
|
+
if (ids.size !== tasks.length || new Set(tasks.map(task => task.number)).size !== tasks.length || tasks.some(task => task.id.length > 128 || !Number.isSafeInteger(task.number) || task.number < 1 || task.dependencies.some(id => !ids.has(id)))) return null;
|
|
23
|
+
const source = ["flowchart TD", ...tasks.map((task, index) => `n${index}["○ ${label(cleanText(taskName(task)))} · ${taskStatus(task)}"]`),
|
|
24
|
+
...tasks.flatMap((task, index) => task.dependencies.map(id => `n${tasks.findIndex(parent => parent.id === id)} --> n${index}`)),
|
|
25
|
+
].join("\n");
|
|
26
|
+
let art: MermaidArt | null;
|
|
27
|
+
try { art = render(source); } catch { return null; }
|
|
28
|
+
if (!art || art.warnings.length || art.width > 2000 || art.plain.length > 2000) return null;
|
|
29
|
+
const bounds = new Map<string, Bounds>();
|
|
30
|
+
// shortcut: grok-mermaid exposes spans, not node bounds; map rectangular node
|
|
31
|
+
// headers and validate every box. Fall back to the list if its output changes.
|
|
32
|
+
const rendered = art;
|
|
33
|
+
rendered.styled.forEach((spans, y) => {
|
|
34
|
+
const borders: number[] = [];
|
|
35
|
+
let column = 0;
|
|
36
|
+
for (const span of spans) {
|
|
37
|
+
if (span.cls === "border") for (const char of span.text) {
|
|
38
|
+
if (char === "│") borders.push(column);
|
|
39
|
+
column += visibleWidth(char);
|
|
40
|
+
} else column += visibleWidth(span.text);
|
|
41
|
+
}
|
|
42
|
+
for (let i = 0; i + 1 < borders.length; i += 2) {
|
|
43
|
+
const left = borders[i], right = borders[i + 1];
|
|
44
|
+
if (cell(rendered, y - 1, left) !== "┌" || cell(rendered, y - 1, right) !== "┐") continue;
|
|
45
|
+
const header = sliceByColumn(rendered.plain[y], left + 1, right - left - 1).trimStart();
|
|
46
|
+
const task = tasks.find(task => header.startsWith(`○ #${task.number} `));
|
|
47
|
+
if (!task || bounds.has(task.id)) continue;
|
|
48
|
+
for (let bottom = y + 1; bottom < rendered.plain.length; bottom++) {
|
|
49
|
+
if (cell(rendered, bottom, left) === "└" && cell(rendered, bottom, right) === "┘") {
|
|
50
|
+
if (!sliceByColumn(rendered.plain[bottom - 1], left + 1, right - left - 1).trimEnd().endsWith(taskStatus(task))) break;
|
|
51
|
+
bounds.set(task.id, { left, right, top: y - 1, bottom });
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (cell(rendered, bottom, left) !== "│" || cell(rendered, bottom, right) !== "│") break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return bounds.size === tasks.length ? { art, bounds } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function moveTaskSelection(bounds: Map<string, Bounds>, id: string, direction: Direction): string {
|
|
62
|
+
const origin = bounds.get(id);
|
|
63
|
+
if (!origin) return id;
|
|
64
|
+
const horizontal = direction === "left" || direction === "right";
|
|
65
|
+
const centerX = (box: Bounds) => (box.left + box.right) / 2;
|
|
66
|
+
const centerY = (box: Bounds) => (box.top + box.bottom) / 2;
|
|
67
|
+
const candidates = [...bounds].filter(([otherId, box]) => {
|
|
68
|
+
if (otherId === id) return false;
|
|
69
|
+
if (horizontal) {
|
|
70
|
+
if (box.top >= origin.bottom || box.bottom <= origin.top) return false;
|
|
71
|
+
return direction === "left" ? box.right < origin.left : box.left > origin.right;
|
|
72
|
+
}
|
|
73
|
+
return direction === "up" ? box.bottom < origin.top : box.top > origin.bottom;
|
|
74
|
+
});
|
|
75
|
+
const distance = (box: Bounds) => horizontal ? Math.abs(centerX(box) - centerX(origin)) : Math.abs(centerY(box) - centerY(origin));
|
|
76
|
+
const offset = (box: Bounds) => horizontal ? Math.abs(centerY(box) - centerY(origin)) : Math.abs(centerX(box) - centerX(origin));
|
|
77
|
+
candidates.sort(([, a], [, b]) => distance(a) - distance(b) || offset(a) - offset(b));
|
|
78
|
+
return candidates[0]?.[0] ?? id;
|
|
79
|
+
}
|