ac-teams 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schemas.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
+ import { taskLabelMaxLength, taskLabelPattern, memberNameMaxLength, memberNamePattern } from "./types.ts";
3
4
 
4
5
  const id = Type.String({ minLength: 1, maxLength: 128 });
5
6
  const text = Type.String({ minLength: 1, maxLength: 16000 });
6
7
  export const AgentSchema = Type.Object({
7
8
  action: Type.Union(["launch", "list", "status", "stop", "resume", "limits", "cleanup"].map(v => Type.Literal(v))),
9
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: memberNameMaxLength, pattern: memberNamePattern.source, description: "Required on launch: a unique descriptive one- or two-word lowercase name, e.g. parser or code-review. Retained on resume." })),
8
10
  id: Type.Optional(id), brief: Type.Optional(text), cwd: Type.Optional(Type.String({ minLength: 1, maxLength: 4096 })),
9
11
  model: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
10
12
  thinking: Type.Optional(Type.Union(["off", "minimal", "low", "medium", "high", "xhigh"].map(v => Type.Literal(v)))),
@@ -17,6 +19,7 @@ export const TaskSchema = Type.Object({
17
19
  offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
18
20
  action: Type.Union(["create", "list", "read", "edit", "claim", "start", "release", "complete", "reopen"].map(v => Type.Literal(v))),
19
21
  id: Type.Optional(id), title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
22
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: taskLabelMaxLength, pattern: taskLabelPattern.source, description: "A concise 1–3-word task label. Defaults to title only when title satisfies this limit." })),
20
23
  description: Type.Optional(Type.String({ maxLength: 16000 })),
21
24
  dependencies: Type.Optional(Type.Array(id, { maxItems: 100, uniqueItems: true })),
22
25
  }, { additionalProperties: false });
@@ -26,7 +29,30 @@ export const MessageSchema = Type.Object({
26
29
  text: Type.Optional(text), query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
27
30
  after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
28
31
  }, { additionalProperties: false });
32
+ const UserActionSchema = Type.Object({
33
+ action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume")]),
34
+ id: Type.Optional(id), text: Type.Optional(text),
35
+ model: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
36
+ thinking: AgentSchema.properties.thinking,
37
+ }, { additionalProperties: false });
38
+ const ContextUsageSchema = Type.Object({
39
+ tokens: Type.Union([Type.Null(), Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })]),
40
+ contextWindow: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
41
+ percent: Type.Union([Type.Null(), Type.Number({ minimum: 0 })]),
42
+ }, { additionalProperties: false });
43
+ const EventSchema = Type.Object({
44
+ type: Type.Union(["agent_start", "agent_settled", "activity", "summary", "model_error", "telemetry"].map(v => Type.Literal(v))),
45
+ text: Type.Optional(Type.String({ maxLength: 2000 })),
46
+ model: Type.Optional(Type.String({ maxLength: 256 })),
47
+ modelName: Type.Optional(Type.String({ maxLength: 256 })),
48
+ contextUsage: Type.Optional(Type.Union([Type.Null(), ContextUsageSchema])),
49
+ }, { additionalProperties: false });
29
50
  export function validate(operation: string, value: unknown): void {
30
- const schema = operation === "agent" ? AgentSchema : operation === "task" ? TaskSchema : operation === "message" ? MessageSchema : undefined;
51
+ const schema = operation === "agent" ? AgentSchema : operation === "task" ? TaskSchema : operation === "message" ? MessageSchema : operation === "ui" ? UserActionSchema : operation === "event" ? EventSchema : undefined;
31
52
  if (!schema || !Value.Check(schema, value)) throw new Error(`Invalid ${operation} request`);
53
+ if (operation === "agent") {
54
+ const args = value as { action: string; name?: string };
55
+ if (args.action === "launch" && !args.name) throw new Error("A descriptive teammate name is required on launch");
56
+ if (args.action !== "launch" && args.name !== undefined) throw new Error("A teammate name can only be set on launch");
57
+ }
32
58
  }
package/src/store.ts CHANGED
@@ -2,9 +2,12 @@ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdi
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { isDescendant, isLive } from "./types.ts";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { isDescendant, isLive, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
6
7
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
7
8
 
9
+ // UI envelopes add task context after the public 16,000-character input limit.
10
+ const maxMessageText = 17000;
8
11
  function code(error: unknown): string | undefined { return (error as NodeJS.ErrnoException)?.code; }
9
12
  function requireValue(ok: unknown, message: string): asserts ok { if (!ok) throw new Error(message); }
10
13
  function text(value: unknown, max = 16000): value is string { return typeof value === "string" && value.trim().length > 0 && value.length <= max; }
@@ -49,28 +52,44 @@ function taskView(state: TeamState, task: Task): TaskView {
49
52
  }
50
53
 
51
54
  function migrate(state: unknown): boolean {
52
- if (!object(state) || state.version !== 1) return false;
55
+ if (!object(state) || (state.version !== 1 && state.version !== 2)) return false;
53
56
  requireValue(object(state.tasks), "Invalid legacy state tasks");
57
+ let number = 0;
54
58
  for (const task of Object.values(state.tasks)) {
55
- requireValue(object(task) && typeof task.status === "string" && ["pending", "claimed", "completed"].includes(task.status), "Invalid legacy task status");
56
- requireValue(task.status === "pending" ? task.owner === undefined : typeof task.owner === "string", "Invalid legacy task owner");
57
- if (task.status === "pending") task.status = "todo";
58
- else if (task.status === "claimed") task.status = "in_progress";
59
- else task.status = "done";
59
+ requireValue(object(task) && text(task.title, 500), "Invalid legacy task");
60
+ if (state.version === 1) {
61
+ requireValue(typeof task.status === "string" && ["pending", "claimed", "completed"].includes(task.status), "Invalid legacy task status");
62
+ requireValue(task.status === "pending" ? task.owner === undefined : typeof task.owner === "string", "Invalid legacy task owner");
63
+ if (task.status === "pending") task.status = "todo";
64
+ else if (task.status === "claimed") task.status = "in_progress";
65
+ else task.status = "done";
66
+ }
67
+ task.number = ++number;
68
+ task.label = stripVTControlCharacters(task.title).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ").slice(0, taskLabelMaxLength).trim() || "Task";
60
69
  }
61
- state.version = 2;
70
+ state.version = 3;
62
71
  return true;
63
72
  }
64
73
 
65
74
  function validate(state: unknown): asserts state is TeamState {
66
75
  requireValue(object(state), "Invalid state snapshot");
67
76
  id(state.id);
68
- requireValue(state.version === 2 && text(state.root) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
77
+ requireValue(state.version === 3 && text(state.root) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
69
78
  const members = state.members;
79
+ const names = new Set<string>(["main", "root", ...Object.keys(members)]);
70
80
  for (const [key, member] of Object.entries(members)) {
71
81
  requireValue(object(member) && member.id === key && text(key) && integer(member.depth) && text(member.token) && text(member.cwd) && ["starting", "busy", "idle", "stopping", "stopped", "interrupted"].includes(String(member.status)) && integer(member.notified), "Invalid state member");
82
+ if (member.name !== undefined) {
83
+ requireValue(text(member.name, memberNameMaxLength) && memberNamePattern.test(member.name) && !names.has(member.name), "Invalid or duplicate teammate name");
84
+ names.add(member.name);
85
+ }
72
86
  requireValue(member.parent === undefined || (text(member.parent) && Object.hasOwn(members, member.parent)), "Invalid member parent");
73
- for (const field of ["identity", "sessionId", "sessionFile", "error"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
87
+ for (const field of ["identity", "sessionId", "sessionFile", "error", "model", "modelName", "thinking", "activity", "summary"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
88
+ if (member.contextUsage !== undefined) {
89
+ const usage = member.contextUsage;
90
+ requireValue(object(usage) && integer(usage.contextWindow, 1) && (usage.tokens === null || integer(usage.tokens)) && (usage.percent === null || typeof usage.percent === "number" && Number.isFinite(usage.percent) && usage.percent >= 0), "Invalid member context usage");
91
+ }
92
+ requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
74
93
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
75
94
  requireValue(member.worktree === undefined || validateWorktree(member.worktree), "Invalid member worktree");
76
95
  const seen = new Set<string>([key]);
@@ -83,10 +102,15 @@ function validate(state: unknown): asserts state is TeamState {
83
102
  requireValue(Object.hasOwn(members, state.root), "Invalid root member");
84
103
  const tasks = state.tasks;
85
104
  const edges: Record<string, string[]> = {};
105
+ const taskNumbers = new Set<number>();
86
106
  for (const [key, task] of Object.entries(tasks)) {
87
107
  requireValue(object(task) && task.id === key && text(key) && integer(task.revision, 1) && text(task.title, 500) && text(task.description) && typeof task.status === "string" && ["todo", "in_progress", "done"].includes(task.status) && Array.isArray(task.dependencies), "Invalid state task");
108
+ requireValue(integer(task.number, 1) && !taskNumbers.has(task.number) && text(task.label, taskLabelMaxLength) && taskLabelPattern.test(task.label), "Invalid task number or label");
109
+ taskNumbers.add(task.number);
110
+ requireValue(task.completedAt === undefined || integer(task.completedAt), "Invalid task completion time");
88
111
  requireValue(task.dependencies.every(dep => typeof dep === "string" && dep !== key && Object.hasOwn(tasks, dep)) && new Set(task.dependencies).size === task.dependencies.length, "Invalid task dependencies");
89
112
  requireValue(task.owner === undefined || (typeof task.owner === "string" && Object.hasOwn(members, task.owner)), "Invalid task owner");
113
+ requireValue(task.creator === undefined || (typeof task.creator === "string" && Object.hasOwn(members, task.creator)), "Invalid task creator");
90
114
  requireValue(task.status !== "in_progress" || task.owner !== undefined, "In-progress task requires an owner");
91
115
  requireValue(task.status === "todo" || task.dependencies.every(dep => (tasks[dep] as Record<string, unknown>).status === "done"), "Active or done task has unfinished dependencies");
92
116
  edges[key] = task.dependencies;
@@ -104,7 +128,7 @@ function validate(state: unknown): asserts state is TeamState {
104
128
  let last = 0;
105
129
  const messageIds = new Set<string>();
106
130
  for (const message of state.messages) {
107
- requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.text) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
131
+ requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.text, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
108
132
  last = message.seq; messageIds.add(message.id);
109
133
  }
110
134
  }
@@ -233,7 +257,10 @@ export class Store {
233
257
  return this.update(state => {
234
258
  if (args.action === "create") {
235
259
  requireValue(text(args.title, 500) && text(args.description), "Task requires a title (max 500) and description (max 16000)");
236
- const task: Task = { id: randomUUID(), revision: 1, title: args.title, description: args.description, dependencies: args.dependencies === undefined ? [] : structuredClone(args.dependencies), status: "todo" };
260
+ const label = args.label ?? args.title;
261
+ requireValue(text(label, taskLabelMaxLength) && taskLabelPattern.test(label), "Task requires a 1–3-word label (max 60 characters); a concise title can serve as the label");
262
+ const number = Object.values(state.tasks).reduce((max, task) => Math.max(max, task.number), 0) + 1;
263
+ const task: Task = { id: randomUUID(), number, label, revision: 1, creator: actor, title: args.title, description: args.description, dependencies: args.dependencies === undefined ? [] : structuredClone(args.dependencies), status: "todo" };
237
264
  state.tasks[task.id] = task;
238
265
  return taskView(state, task);
239
266
  }
@@ -244,6 +271,7 @@ export class Store {
244
271
  requireValue(task.status === "todo", "Only to-do tasks can be edited");
245
272
  requireValue(!task.owner || task.owner === actor, "Only the owner can edit a reserved task");
246
273
  if (args.title !== undefined) task.title = args.title;
274
+ if (args.label !== undefined) task.label = args.label;
247
275
  if (args.description !== undefined) task.description = args.description;
248
276
  if (args.dependencies !== undefined) task.dependencies = structuredClone(args.dependencies);
249
277
  } else if (args.action === "claim") {
@@ -261,6 +289,7 @@ export class Store {
261
289
  requireValue(task.status === "in_progress", "Only in-progress tasks can be completed; start it first");
262
290
  requireValue(args.acceptance === true, "Completion requires acceptance=true after checking acceptance criteria");
263
291
  task.status = "done";
292
+ task.completedAt = Date.now();
264
293
  } else if (args.action === "release") {
265
294
  requireValue(task.owner, "Task has no owner to release");
266
295
  const interrupted = ["interrupted", "stopped"].includes(state.members[task.owner].status);
@@ -273,16 +302,18 @@ export class Store {
273
302
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
274
303
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
275
304
  task.status = "todo";
305
+ delete task.completedAt;
276
306
  } else throw new Error("Unknown task action");
277
307
  task.revision++;
278
308
  return taskView(state, task);
279
309
  });
280
310
  }
281
311
 
282
- message(actor: string, args: MessageArgs): Message | Message[] {
312
+ message(actor: string, args: MessageArgs, enveloped = false): Message | Message[] {
283
313
  requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
284
314
  if (args.action === "send") return this.update(state => {
285
- requireValue(text(args.text), "Message text must be nonempty and at most 16000 characters");
315
+ const limit = enveloped ? maxMessageText : 16000;
316
+ requireValue(text(args.text, limit), `Message text must be nonempty and at most ${limit} characters`);
286
317
  const recipients = args.recipients === undefined ? Object.values(state.members).filter(member => member.id !== actor && isLive(member)).map(member => member.id) : args.recipients;
287
318
  requireValue(Array.isArray(recipients) && (args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(state.members, recipient) && isLive(state.members[recipient])), "Recipients must be unique live member IDs");
288
319
  const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
@@ -0,0 +1,34 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { cleanText } from "./render.ts";
4
+ import { taskName, type TaskView } from "./types.ts";
5
+
6
+ export function summaryTasks(tasks: TaskView[]): TaskView[] {
7
+ const dependents = new Map<string, number>();
8
+ for (const task of tasks) {
9
+ if (task.status !== "done") for (const id of task.blockedBy) dependents.set(id, (dependents.get(id) ?? 0) + 1);
10
+ }
11
+ const rank = (task: TaskView) => task.status === "in_progress" ? 0
12
+ : task.status === "done" ? 3 : task.blockedBy.length ? 2 : dependents.has(task.id) ? 1 : 4;
13
+ return tasks.filter(task => rank(task) < 4).sort((a, b) => rank(a) - rank(b)
14
+ || (a.status === "done" && b.status === "done" ? (b.completedAt ?? -1) - (a.completedAt ?? -1) || b.number - a.number
15
+ : (dependents.get(b.id) ?? 0) - (dependents.get(a.id) ?? 0) || a.number - b.number)).slice(0, 5);
16
+ }
17
+
18
+ export function renderTaskSummary(tasks: TaskView[], theme: Theme, width: number): string[] {
19
+ if (!tasks.length) return [];
20
+ const byId = new Map(tasks.map(task => [task.id, task]));
21
+ const visible = summaryTasks(tasks);
22
+ const rows = visible.map((task, index) => {
23
+ const status = task.status === "done" ? "done" : task.status === "in_progress" ? "working" : task.blockedBy.length ? "blocked" : "ready";
24
+ const color = status === "done" ? "success" : status === "working" ? "accent" : status === "blocked" ? "error" : "muted";
25
+ const marker = status === "done" ? "✓" : status === "working" ? "●" : status === "blocked" ? "⊘" : "○";
26
+ const blockers = task.blockedBy.length ? ` by ${task.blockedBy.map(id => `#${byId.get(id)?.number ?? "?"}`).join(", ")}` : "";
27
+ const name = cleanText(taskName(task)).replaceAll("\n", " ");
28
+ return theme.fg("dim", index === visible.length - 1 ? "└─ " : "├─ ")
29
+ + theme.fg(color, marker) + " " + theme.fg(status === "done" ? "dim" : "text", name)
30
+ + theme.fg(color, ` · ${status}${blockers}`);
31
+ });
32
+ return [theme.fg("muted", `Tasks ${tasks.filter(task => task.status === "done").length}/${tasks.length} done`), ...rows]
33
+ .map(row => truncateToWidth(row, width));
34
+ }
@@ -0,0 +1,19 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Member, Telemetry } from "./types.ts";
3
+
4
+ export function readTelemetry(ctx: ExtensionContext): Telemetry {
5
+ const usage = ctx.getContextUsage();
6
+ return { type: "telemetry", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
7
+ modelName: ctx.model?.name ?? ctx.model?.id,
8
+ contextUsage: usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent } : null };
9
+ }
10
+
11
+ export function modelContext(member: Pick<Member, "model" | "modelName" | "contextUsage">): string {
12
+ const model = member.modelName || member.model?.slice(member.model.indexOf("/") + 1) || "Model unknown";
13
+ const usage = member.contextUsage;
14
+ const count = (value: number) => value >= 1000 ? `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(value);
15
+ const context = !usage ? "context unknown" : usage.tokens === null || usage.percent === null
16
+ ? `context unknown / ${count(usage.contextWindow)}`
17
+ : `${count(usage.tokens)} / ${count(usage.contextWindow)} (${usage.percent.toFixed(1).replace(/\.0$/, "")}%)`;
18
+ return `${model} · ${context}`;
19
+ }
@@ -0,0 +1,110 @@
1
+ import { execFile } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { randomUUID } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { unlinkSync, writeFileSync } from "node:fs";
6
+ import { promisify } from "node:util";
7
+ import { processAlive, processIdentity } from "./store.ts";
8
+
9
+ const exec = promisify(execFile);
10
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
11
+ export async function tmux(socket: string, args: string[], timeout = 5000): Promise<string> {
12
+ const env = { ...process.env };
13
+ delete env.TMUX; delete env.TMUX_PANE;
14
+ const result = await exec("tmux", ["-S", socket, ...args], { env, timeout, maxBuffer: 65536 });
15
+ return result.stdout.trim();
16
+ }
17
+ interface Options {
18
+ id: string; dir: string; cwd: string; args: string[]; env: NodeJS.ProcessEnv; command?: string;
19
+ request: (type: string, fields?: Record<string, unknown>, timeout?: number) => Promise<unknown>;
20
+ }
21
+ /** One native Pi process in a team-private tmux server; never recreates its transcript. */
22
+ export class TerminalAgent extends EventEmitter {
23
+ readonly process: { pid?: number } = {};
24
+ readonly socket: string;
25
+ readonly exited: Promise<void>;
26
+ private resolveExit!: () => void;
27
+ private identity?: string;
28
+ private timer?: NodeJS.Timeout;
29
+ private closed = false;
30
+ private stopPromise?: Promise<void>;
31
+ private files: string[] = [];
32
+ private options: Options;
33
+ constructor(options: Options) {
34
+ super();
35
+ this.options = options;
36
+ this.socket = join(options.dir, "t.sock");
37
+ this.exited = new Promise(resolve => { this.resolveExit = resolve; });
38
+ }
39
+ async start(): Promise<void> {
40
+ const base = join(this.options.dir, `.terminal-${randomUUID()}`);
41
+ const config = base + ".conf", script = base + ".sh";
42
+ try {
43
+ // Credentials travel in a private file, never in tmux's command-line arguments.
44
+ writeFileSync(config, "set -g status off\nset -g prefix None\nset -s extended-keys always\nbind-key -n C-S-Left detach-client\n", { flag: "wx", mode: 0o600 });
45
+ this.files.push(config);
46
+ const exports = Object.entries(this.options.env)
47
+ .filter(([key, value]) => key.startsWith("AC_TEAMS_") && value !== undefined)
48
+ .map(([key, value]) => `export ${key}=${quote(value!)}`);
49
+ const command = this.options.command ?? process.env.AC_TEAMS_PI ?? "pi";
50
+ writeFileSync(script, [...exports,
51
+ "exec " + [command, ...this.options.args].map(quote).join(" "), ""].join("\n"), { flag: "wx", mode: 0o600 });
52
+ this.files.push(script);
53
+ const pid = Number(await tmux(this.socket, ["-f", config, "new-session", "-d", "-s", this.options.id,
54
+ "-c", this.options.cwd, "-x", String(process.stdout.columns || 120), "-y", String(process.stdout.rows || 40),
55
+ "-P", "-F", "#{pane_pid}", "--", "/bin/sh", script]));
56
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("tmux did not return a native member PID");
57
+ this.process.pid = pid;
58
+ this.identity = processIdentity(pid);
59
+ if (!this.identity) throw new Error("Native Pi exited during terminal startup");
60
+ this.timer = setInterval(() => {
61
+ try { if (!processAlive(pid, this.identity)) void this.finish(); }
62
+ catch (error) { clearInterval(this.timer); this.emit("failure", error); }
63
+ }, 250);
64
+ } catch (error) {
65
+ await tmux(this.socket, ["kill-session", "-t", this.options.id]).catch(() => {});
66
+ await this.finish();
67
+ throw error;
68
+ }
69
+ }
70
+ request(type: string, fields: Record<string, unknown> = {}, timeout = 10000): Promise<unknown> {
71
+ return this.options.request(type, fields, timeout);
72
+ }
73
+ private async finish(): Promise<void> {
74
+ if (this.closed) return;
75
+ this.closed = true;
76
+ clearInterval(this.timer);
77
+ await tmux(this.socket, ["kill-session", "-t", this.options.id]).catch(() => {});
78
+ for (const file of this.files) {
79
+ try { unlinkSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.emit("failure", error); }
80
+ }
81
+ this.files = [];
82
+ this.emit("exit");
83
+ this.resolveExit();
84
+ }
85
+ private async wait(ms: number): Promise<boolean> {
86
+ let timer: NodeJS.Timeout | undefined;
87
+ const result = await Promise.race([this.exited.then(() => true), new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), ms); })]);
88
+ clearTimeout(timer);
89
+ return result;
90
+ }
91
+ private signal(signal: NodeJS.Signals): void {
92
+ const pid = this.process.pid;
93
+ if (!pid || !this.identity) throw new Error("Cannot verify native Pi ownership; refusing to signal");
94
+ if (processAlive(pid, this.identity)) process.kill(pid, signal);
95
+ }
96
+ stop(): Promise<void> { this.stopPromise ??= this.stopOnce(); return this.stopPromise; }
97
+ private async stopOnce(): Promise<void> {
98
+ if (this.closed) { await this.exited; return; }
99
+ await this.request("abort", {}, 1000).catch(() => {});
100
+ await this.request("shutdown", {}, 1000).catch(() => {});
101
+ if (await this.wait(4000)) return;
102
+ this.emit("forced", "SIGHUP");
103
+ await tmux(this.socket, ["kill-session", "-t", this.options.id]).catch(() => {});
104
+ if (await this.wait(1000)) return;
105
+ this.emit("forced", "SIGTERM"); this.signal("SIGTERM");
106
+ if (await this.wait(2000)) return;
107
+ this.emit("forced", "SIGKILL"); this.signal("SIGKILL");
108
+ if (!(await this.wait(2000))) throw new Error("Owned native Pi did not exit after SIGKILL");
109
+ }
110
+ }
@@ -0,0 +1,174 @@
1
+ import { spawn, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { CustomEditor, getSettingsListTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { matchesKey, renderFakeCursor, truncateToWidth, visibleWidth, SettingsList, type EditorComponent, type Focusable } from "@earendil-works/pi-tui";
5
+ import { cleanText } from "./render.ts";
6
+ import { isLive, taskName, memberName } from "./types.ts";
7
+ import { saveDisplaySettings, type DisplaySettings } from "./config.ts";
8
+ import { modelContext, readTelemetry } from "./telemetry.ts";
9
+ import { renderTaskSummary } from "./task-summary.ts";
10
+ import type { PanelHost } from "./ui-types.ts";
11
+
12
+ const exec = promisify(execFile);
13
+ export async function visitMember(ctx: ExtensionContext, host: PanelHost, socket: string, id: string): Promise<void> {
14
+ if (ctx.mode !== "tui") throw new Error("Live sessions require an interactive terminal");
15
+ if (id === host.self) return;
16
+ const member = (await host.snapshot()).members.find(member => member.id === id);
17
+ if (!member || !isLive(member)) throw new Error(`Member ${id} is stopped or unavailable; resume it from /teams`);
18
+ if (id !== "root" && !member.terminal) throw new Error("This member has no live terminal (it was launched headlessly)");
19
+ if (host.self !== "root") {
20
+ await exec("tmux", ["-S", socket, ...(id === "root" ? ["detach-client", "-s", host.self] : ["switch-client", "-t", id])], { timeout: 5000, maxBuffer: 65536 });
21
+ return;
22
+ }
23
+ const code = await ctx.ui.custom<number>((tui, _theme, _keys, done) => {
24
+ tui.stop();
25
+ process.stdout.write("\x1b[2J\x1b[H");
26
+ const env = { ...process.env }; delete env.TMUX; delete env.TMUX_PANE;
27
+ const child = spawn("tmux", ["-S", socket, "attach-session", "-t", id], { stdio: "inherit", env });
28
+ let restored = false;
29
+ const restore = (code: number) => {
30
+ if (restored) return;
31
+ restored = true; tui.start(); tui.requestRender(true); done(code);
32
+ };
33
+ child.once("error", () => restore(1));
34
+ child.once("exit", code => restore(code ?? 1));
35
+ return { render: () => [], invalidate() {}, dispose() { if (!restored) child.kill(); } };
36
+ });
37
+ if (code !== 0) throw new Error("Could not attach the live terminal; Main was restored");
38
+ }
39
+
40
+ export async function showDisplayConfig(ctx: ExtensionContext, settings: DisplaySettings): Promise<void> {
41
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
42
+ const list = new SettingsList([
43
+ { id: "tasks", label: "Task summary", currentValue: settings.tasks ? "On" : "Off", values: ["On", "Off"] },
44
+ { id: "agents", label: "Agent selector", currentValue: settings.agents ? "On" : "Off", values: ["On", "Off"] },
45
+ ], 4, getSettingsListTheme(), (id, value) => {
46
+ if (id !== "tasks" && id !== "agents") return;
47
+ try {
48
+ const next = { ...settings, [id]: value === "On" };
49
+ saveDisplaySettings(next);
50
+ Object.assign(settings, next);
51
+ } catch (error) {
52
+ list.updateValue(id, settings[id] ? "On" : "Off");
53
+ ctx.ui.notify(cleanText(String(error)), "error");
54
+ }
55
+ }, () => done());
56
+ return {
57
+ render(width) {
58
+ const inner = Math.max(1, width - 4);
59
+ const rows = [theme.fg("accent", theme.bold("Teams display · global")), theme.fg("dim", "Saves immediately for this and future sessions."), "", ...list.render(inner)];
60
+ return ["╭" + "─".repeat(Math.max(0, width - 2)) + "╮", ...rows.map(line => {
61
+ const text = truncateToWidth(line, inner);
62
+ return "│ " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " │";
63
+ }), "╰" + "─".repeat(Math.max(0, width - 2)) + "╯"].map(line => truncateToWidth(line, width));
64
+ },
65
+ invalidate() { list.invalidate(); },
66
+ handleInput(input) { list.handleInput(input === " " ? "\r" : input); tui.requestRender(); },
67
+ };
68
+ }, { overlay: true, overlayOptions: { width: 64, minWidth: 30, anchor: "center" } });
69
+ }
70
+
71
+ export function installTeamFooter(_pi: ExtensionAPI, ctx: ExtensionContext, host: PanelHost, display: DisplaySettings): () => void {
72
+ let members: Awaited<ReturnType<PanelHost["snapshot"]>>["members"] = [];
73
+ let tasks: Awaited<ReturnType<PanelHost["snapshot"]>>["tasks"] = [];
74
+ let selected: string | undefined, start = 0;
75
+ let editor: (EditorComponent & Partial<Focusable> & { isShowingAutocomplete?(): boolean }) | undefined;
76
+ let redraw = () => {};
77
+ let disposed = false;
78
+ let timer: NodeJS.Timeout | undefined;
79
+ const ids = () => ["root", ...members.filter(member => member.id !== "root").map(member => member.id)];
80
+ const slots = () => members.filter(member => member.id !== "root").length > 4 ? 3 : 4;
81
+ const reveal = () => {
82
+ if (selected === undefined || selected === "root") return;
83
+ const index = ids().indexOf(selected) - 1;
84
+ if (index < start) start = index;
85
+ if (index >= start + slots()) start = index - slots() + 1;
86
+ start = Math.max(0, start);
87
+ };
88
+ async function refresh() {
89
+ try {
90
+ if (!display.tasks && !display.agents) {
91
+ ctx.ui.setStatus("ac-teams", undefined);
92
+ redraw();
93
+ return;
94
+ }
95
+ const next = await host.snapshot();
96
+ if (disposed) return;
97
+ members = next.members; tasks = next.tasks;
98
+ const count = ids().length;
99
+ ctx.ui.setStatus("ac-teams", `Teams ${[
100
+ ...(display.agents ? [`${count} ${count === 1 ? "agent" : "agents"}`, `${members.filter(member => member.status === "busy").length} working`] : []),
101
+ ...(display.tasks ? [`${tasks.filter(task => task.status === "done").length}/${tasks.length} tasks done`] : []),
102
+ ].join(" · ")}`);
103
+ if (selected && !ids().includes(selected)) { selected = undefined; if (editor) editor.focused = true; }
104
+ start = Math.min(start, Math.max(0, ids().length - 1 - slots()));
105
+ reveal(); redraw();
106
+ } catch { /* The next refresh retries; explicit commands report connection errors. */ }
107
+ finally { if (!disposed) timer = setTimeout(() => void refresh(), 1000); }
108
+ }
109
+ void refresh();
110
+ ctx.ui.setWidget("ac-teams-tasks", (_tui, theme) => ({
111
+ render: width => display.tasks ? renderTaskSummary(tasks, theme, width) : [],
112
+ invalidate() {},
113
+ }), { placement: "aboveEditor" });
114
+ ctx.ui.setWidget("ac-teams-agents", (tui, theme) => {
115
+ redraw = () => tui.requestRender();
116
+ return {
117
+ render(width) {
118
+ if (!display.agents && selected !== undefined) { selected = undefined; if (editor) editor.focused = true; }
119
+ const row = (id: string) => {
120
+ const member = members.find(member => member.id === id);
121
+ const assigned = tasks.find(task => task.owner === id && task.status === "in_progress") ?? tasks.find(task => task.owner === id && task.status !== "done");
122
+ const status = member?.status === "busy" ? "working" : member?.status ?? "idle";
123
+ const metrics = id === host.self ? readTelemetry(ctx) : member ?? {};
124
+ const title = id === "root" ? "main" : memberName(member ?? { id });
125
+ const description = `${title} ${id === "root" ? "" : status + " · "}${modelContext(metrics)}${assigned ? ` · ${taskName(assigned)}` : ""}${member?.error || member?.activity ? ` · ${member.error || member.activity}` : ""}`;
126
+ return theme.fg(selected === id || host.self === id ? "accent" : "muted", `${selected === id ? ">" : " "} ${host.self === id ? "●" : "○"} ${cleanText(description).replaceAll("\n", " ")}`);
127
+ };
128
+ const peers = ids().slice(1);
129
+ return (display.agents ? [row("root"), ...peers.slice(start, start + slots()).map(row),
130
+ ...(peers.length > 4 ? [theme.fg("dim", ` ↑ ${start} above · ↓ ${Math.max(0, peers.length - start - slots())} below`)] : [])] : []
131
+ ).map(line => truncateToWidth(line, width));
132
+ },
133
+ invalidate() {},
134
+ };
135
+ }, { placement: "belowEditor" });
136
+ const previousEditor = ctx.ui.getEditorComponent();
137
+ const teamEditor: NonNullable<typeof previousEditor> = (tui, theme, keys) => {
138
+ const current: NonNullable<typeof editor> = previousEditor?.(tui, theme, keys) ?? new CustomEditor(tui, theme, keys);
139
+ // Decorate the instance so custom editor methods and Pi's app-control wiring survive.
140
+ const render = current.render.bind(current), handleInput = current.handleInput.bind(current);
141
+ current.render = (width: number) => {
142
+ const lines = render(width);
143
+ return disposed || selected === undefined ? lines : lines.map(line => line.replace(renderFakeCursor(" "), " "));
144
+ };
145
+ current.handleInput = (input: string) => {
146
+ if (disposed) { handleInput(input); return; }
147
+ if (!display.agents) { selected = undefined; current.focused = true; handleInput(input); return; }
148
+ if (selected !== undefined) {
149
+ const destinations = ids(), index = destinations.indexOf(selected);
150
+ if (matchesKey(input, "down")) selected = destinations[Math.min(destinations.length - 1, index + 1)];
151
+ else if (matchesKey(input, "up")) selected = index <= 0 ? undefined : destinations[index - 1];
152
+ else if (matchesKey(input, "escape")) selected = undefined;
153
+ else if (matchesKey(input, "enter")) {
154
+ const id = selected; selected = undefined;
155
+ void host.visit(id).catch(error => ctx.ui.notify(cleanText(String(error)), "error"));
156
+ } else { selected = undefined; handleInput(input); }
157
+ current.focused = selected === undefined; reveal(); redraw(); return;
158
+ }
159
+ if (matchesKey(input, "down") && !current.getText() && !current.isShowingAutocomplete?.()) {
160
+ selected = "root"; start = 0; current.focused = false; redraw(); return;
161
+ }
162
+ handleInput(input);
163
+ };
164
+ editor = current;
165
+ return current;
166
+ };
167
+ ctx.ui.setEditorComponent(teamEditor);
168
+ return () => {
169
+ disposed = true; clearTimeout(timer);
170
+ ctx.ui.setWidget("ac-teams-tasks", undefined); ctx.ui.setWidget("ac-teams-agents", undefined);
171
+ ctx.ui.setStatus("ac-teams", undefined);
172
+ if (ctx.ui.getEditorComponent() === teamEditor) ctx.ui.setEditorComponent(previousEditor);
173
+ };
174
+ }
package/src/types.ts CHANGED
@@ -5,8 +5,20 @@ export interface Worktree {
5
5
  base: string;
6
6
  target?: string;
7
7
  }
8
+ export interface ContextUsage {
9
+ tokens: number | null;
10
+ contextWindow: number;
11
+ percent: number | null;
12
+ }
13
+ export interface Telemetry {
14
+ type: "telemetry";
15
+ model?: string;
16
+ modelName?: string;
17
+ contextUsage: ContextUsage | null;
18
+ }
8
19
  export interface Member {
9
20
  id: string;
21
+ name?: string;
10
22
  parent?: string;
11
23
  depth: number;
12
24
  token: string;
@@ -19,15 +31,32 @@ export interface Member {
19
31
  notified: number;
20
32
  worktree?: Worktree;
21
33
  error?: string;
34
+ model?: string;
35
+ modelName?: string;
36
+ contextUsage?: ContextUsage;
37
+ thinking?: string;
38
+ activity?: string;
39
+ summary?: string;
40
+ terminal?: boolean;
22
41
  }
42
+ export const memberNameMaxLength = 40;
43
+ export const memberNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)?(?![\s\S])/;
44
+ export function memberName(member: Pick<Member, "id" | "name">): string { return member.id === "root" ? "Main" : member.name ?? member.id; }
45
+ export const taskLabelMaxLength = 60;
46
+ export const taskLabelPattern = /^[^\s\x00-\x1f\x7f-\x9f]+(?: [^\s\x00-\x1f\x7f-\x9f]+){0,2}(?![\s\S])/;
47
+ export function taskName(task: Pick<Task, "number" | "label">): string { return `#${task.number} ${task.label}`; }
23
48
  export interface Task {
24
49
  id: string;
50
+ number: number;
51
+ label: string;
25
52
  revision: number;
26
53
  title: string;
27
54
  description: string;
28
55
  dependencies: string[];
29
56
  status: "todo" | "in_progress" | "done";
57
+ completedAt?: number;
30
58
  owner?: string;
59
+ creator?: string;
31
60
  }
32
61
  export interface TaskView extends Task {
33
62
  blockedBy: string[];
@@ -41,7 +70,7 @@ export interface Message {
41
70
  time: string;
42
71
  }
43
72
  export interface TeamState {
44
- version: 2;
73
+ version: 3;
45
74
  id: string;
46
75
  root: string;
47
76
  limit: number;
@@ -58,6 +87,7 @@ export interface TaskArgs {
58
87
  limit?: number;
59
88
  id?: string;
60
89
  title?: string;
90
+ label?: string;
61
91
  description?: string;
62
92
  dependencies?: string[];
63
93
  }
@@ -72,6 +102,7 @@ export interface MessageArgs {
72
102
  export interface AgentArgs {
73
103
  action: "launch" | "list" | "status" | "stop" | "resume" | "limits" | "cleanup";
74
104
  id?: string;
105
+ name?: string;
75
106
  brief?: string;
76
107
  cwd?: string;
77
108
  model?: string;
@@ -82,8 +113,15 @@ export interface AgentArgs {
82
113
  limit?: number;
83
114
  depth?: number;
84
115
  }
85
- export type Operation = "agent" | "task" | "message" | "join" | "ack";
86
- export function isLive(member: Member): boolean {
116
+ export interface UserAction {
117
+ action: "task_request" | "feedback" | "resume";
118
+ id?: string;
119
+ text?: string;
120
+ model?: string;
121
+ thinking?: string;
122
+ }
123
+ export type Operation = "agent" | "task" | "message" | "join" | "ack" | "event" | "control_result" | "ui";
124
+ export function isLive(member: Pick<Member, "status">): boolean {
87
125
  return ["starting", "busy", "idle", "stopping"].includes(member.status);
88
126
  }
89
127
  export function isDescendant(state: TeamState, id: string, ancestor: string): boolean {