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.
@@ -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;
@@ -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
- notice: (through: number) => void;
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: 2, id: options.id, root: "root", limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
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", () => { this.connections.delete(socket); if (bound && this.sockets.get(bound) === socket) this.sockets.delete(bound); });
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
- let agent: RpcAgent | undefined;
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
- agent = new RpcAgent({ 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 } });
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.store.update(next => {
221
- const current = next.members[id];
222
- if (current.status !== "stopping") current.status = event.type === "agent_start" ? "busy" : "idle";
223
- if (event.type === "agent_start") delete current.error;
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
- this.store.update(next => { if (next.members[id].status === "starting") next.members[id].status = "busy"; });
248
- await agent.request("prompt", { message: args.brief, streamingBehavior: "followUp" });
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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
+ }