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/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 +130 -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 +174 -0
- package/src/types.ts +41 -3
- package/src/ui-types.ts +19 -0
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 {
|
|
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) &&
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -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:
|
|
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
|
|
86
|
-
|
|
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 {
|