ac-teams 0.0.11 → 0.0.13
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 +1 -1
- package/docs/usage.md +10 -3
- package/package.json +1 -1
- package/src/index.ts +14 -8
- package/src/panel.ts +89 -24
- package/src/render.ts +29 -19
- package/src/schemas.ts +1 -1
- package/src/store.ts +5 -2
- package/src/telemetry.ts +5 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Persistent agent teams for [Pi](https://github.com/earendil-works/pi): shared ta
|
|
|
7
7
|
Requires Linux, Node 22.19 or newer, and an authenticated `pi` executable on `PATH`. Interactive teams also require `tmux` on `PATH`. Tested with Pi 1.4.0 and 1.5.0.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
pi install npm:ac-teams
|
|
10
|
+
pi install npm:ac-teams
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Restart Pi after installation. Use Pi's `/trust` command to save approval for each project directory before launching members there; headless children cannot answer the initial trust prompt.
|
package/docs/usage.md
CHANGED
|
@@ -24,7 +24,10 @@ Context shows Pi's reported token estimate, window size, and percentage, for exa
|
|
|
24
24
|
- Ctrl+Shift+Left returns from a member to Main. Switching preserves the member's process, session, and draft. Returning does not stop it.
|
|
25
25
|
- `/teams` opens three tabs. Tab moves between Tasks, Teammates, and Activity; Esc closes the panel.
|
|
26
26
|
- **Tasks:** Arrow keys select nodes in the dependency graph. G toggles graph/list, and Enter shows full details. Green means Done, red Blocked, cyan Working, and neutral Ready; written labels remain visible. Large or unsupported graphs fall back to the list.
|
|
27
|
-
- **Teammates:** Enter opens a live member, or quietly resumes a stopped member's saved session before opening it. Root can open any member; member views can open their descendants.
|
|
27
|
+
- **Teammates:** Shows working, idle, stopped, and needs-input counts; interrupted sessions count as needing input. Enter opens a live member, or quietly resumes a stopped member's saved session before opening it. Right opens details; Esc returns to the roster. Model, context, and directory appear above session IDs and paths. Root can open any member; member views can open their descendants.
|
|
28
|
+
- Wider rosters also show Tasks (completed / currently assigned total) and the directory's final folder name. Reassigned tasks leave the previous owner's counts. Narrower views drop Directory first, then Tasks; full paths and counts remain in details.
|
|
29
|
+
- Press `/` to filter names (case-insensitive). Enter applies the filter; Esc while editing clears it. Backspace edits the query. Press `/` again to edit an applied filter.
|
|
30
|
+
- H hides or shows stopped members. Interrupted sessions remain visible for recovery. All members are visible by default; filters affect only this panel, not team state.
|
|
28
31
|
- **Activity:** Shows the latest 100 persistent team messages rather than every tool update. Use `team_message history` to read older messages.
|
|
29
32
|
- N composes a task request for Main. C on a task sends feedback to its creator. Enter sends; Shift+Enter adds a newline; Esc cancels without losing the draft. These messages do not directly edit the task graph. Feedback resumes a stopped creator when possible; otherwise, the receipt explicitly reports delivery to Main instead.
|
|
30
33
|
|
|
@@ -36,7 +39,7 @@ Each team tool call uses one line with a status dot, tool name, action, and resu
|
|
|
36
39
|
|
|
37
40
|
The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full request and response JSON; mouse-enabled Pi views also support clicking the row.
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
Tool summaries and team notification previews prefer teammate names over IDs. Message receipt IDs stay out of compact summaries. Ctrl+O expands the original request and response, including IDs. Unknown members fall back to their IDs. UI state colors do not replace the underlying progress, ownership, or dependency fields.
|
|
40
43
|
|
|
41
44
|
## Main sessions share one team
|
|
42
45
|
|
|
@@ -156,7 +159,11 @@ Example `team_message` arguments:
|
|
|
156
159
|
{"action":"send","recipients":["root"],"text":"Parser review complete. No edits; two malformed-input cases need tests."}
|
|
157
160
|
```
|
|
158
161
|
|
|
159
|
-
|
|
162
|
+
Use exact teammate names (such as `"oom-review"`) or member IDs in `recipients`; IDs remain valid. Omit `recipients` to notify all other live members.
|
|
163
|
+
|
|
164
|
+
Each recipient must resolve to a different live member. An unknown name, a stopped member, or two entries for the same member rejects the whole send.
|
|
165
|
+
|
|
166
|
+
Receipts and stored history retain resolved IDs. All team members can read the full history, including direct messages.
|
|
160
167
|
|
|
161
168
|
```json
|
|
162
169
|
{"action":"history","after":0,"limit":20}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { installTeamFooter, visitMember, showDisplayConfig } from "./terminal-ui
|
|
|
7
7
|
import { readTelemetry } from "./telemetry.ts";
|
|
8
8
|
import { resolveTeammate, teamsCompletions } from "./commands.ts";
|
|
9
9
|
import { showTeamsPanel } from "./panel.ts";
|
|
10
|
-
import { teamToolRenderers,
|
|
11
|
-
import { join } from "node:path";
|
|
10
|
+
import { teamToolRenderers, teamMessageRenderer, cleanText } from "./render.ts";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { execFile } from "node:child_process";
|
|
14
14
|
import { existsSync, readdirSync } from "node:fs";
|
|
@@ -48,7 +48,13 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
48
48
|
const teamTools = ["team_agent", "team_task", "team_message"];
|
|
49
49
|
const memberId = () => child ? environment[1]! : team?.member ?? "root";
|
|
50
50
|
const teamId = (ctx: ExtensionContext) => team?.team ?? ctx.sessionManager.getSessionId();
|
|
51
|
-
|
|
51
|
+
function memberLabels(): Record<string, string> {
|
|
52
|
+
try {
|
|
53
|
+
const members = coordinator?.store.state.members ?? readState(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!))).members;
|
|
54
|
+
return Object.fromEntries(Object.values(members).map(member => [member.id, memberName(member)]));
|
|
55
|
+
} catch { return { root: "Main" }; } // A transcript can render before its team is available.
|
|
56
|
+
}
|
|
57
|
+
pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
|
|
52
58
|
const warn = (message: string) => context?.ui.notify(message, "warning");
|
|
53
59
|
const notify = (through: number, messages: Message[] = []) => {
|
|
54
60
|
pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
|
|
@@ -273,13 +279,13 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
273
279
|
if (change) report(ctx, change);
|
|
274
280
|
});
|
|
275
281
|
function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
|
|
276
|
-
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use stable member
|
|
282
|
+
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use teammate names for messaging and stable IDs for member control and tasks; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
|
|
277
283
|
const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and include its ID in the member's brief. Use one shared task list for your own work and delegated work; do not create one task per teammate by default. Quick lookups and brief second opinions need no separate task unless their results need coordination or follow-up.";
|
|
278
284
|
pi.registerTool({
|
|
279
285
|
name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees. Create or reuse team_task entries for delegated deliverables; quick lookups need no task unless results need coordination or follow-up.",
|
|
280
286
|
promptGuidelines: [guidance, taskTrackingGuidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
|
|
281
287
|
parameters: AgentSchema,
|
|
282
|
-
...teamToolRenderers("team_agent"),
|
|
288
|
+
...teamToolRenderers("team_agent", memberLabels),
|
|
283
289
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
|
|
284
290
|
});
|
|
285
291
|
pi.registerTool({
|
|
@@ -294,7 +300,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
294
300
|
"team_task start refuses another owner's task or unfinished dependencies. Complete requires ownership, in-progress work, and acceptance=true. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents.",
|
|
295
301
|
],
|
|
296
302
|
parameters: TaskSchema,
|
|
297
|
-
...teamToolRenderers("team_task"),
|
|
303
|
+
...teamToolRenderers("team_task", memberLabels),
|
|
298
304
|
async execute(_id, args, _signal, _update, ctx) {
|
|
299
305
|
const data = await handle("task", args, ctx) as TaskView | { tasks: TaskView[]; total: number };
|
|
300
306
|
const compact = (task: TaskView) => ({ id: task.id, number: task.number, label: task.label, title: task.title, status: task.status, owner: task.owner, revision: task.revision, dependencies: task.dependencies, blockedBy: task.blockedBy });
|
|
@@ -303,10 +309,10 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
303
309
|
},
|
|
304
310
|
});
|
|
305
311
|
pi.registerTool({
|
|
306
|
-
name: "team_message", label: "Team message", description: "Send a persistent direct/team message or read cursor-paged history. Send returns a receipt; history and search return full messages. New messages queue a compact follow-up notification for each recipient.",
|
|
312
|
+
name: "team_message", label: "Team message", description: "Send a persistent direct/team message to teammate names or IDs, or read cursor-paged history. Send returns a receipt with resolved IDs; history and search return full messages. New messages queue a compact follow-up notification for each recipient.",
|
|
307
313
|
promptGuidelines: ["Do not poll team_message history while waiting for replies; notifications trigger a follow-up turn. On notification, read team_message history after your last read cursor and advance it through the returned messages. An empty result means no newer messages, not that a teammate has finished."],
|
|
308
314
|
parameters: MessageSchema,
|
|
309
|
-
...teamToolRenderers("team_message"),
|
|
315
|
+
...teamToolRenderers("team_message", memberLabels),
|
|
310
316
|
async execute(_id, args, _signal, _update, ctx) {
|
|
311
317
|
const result = await handle("message", args, ctx);
|
|
312
318
|
if (args.action === "send") {
|
package/src/panel.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Editor, indexedColor, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
4
|
+
import { contextSummary } from "./telemetry.ts";
|
|
4
5
|
import { buildTaskGraph, moveTaskSelection, taskStatus } from "./graph.ts";
|
|
5
6
|
import { cleanText as clean } from "./render.ts";
|
|
6
7
|
import type { PanelHost } from "./ui-types.ts";
|
|
@@ -18,15 +19,22 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
18
19
|
const destination = await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
|
|
19
20
|
let tab = 0, taskIndex = 0, memberIndex = 0, eventIndex = 0;
|
|
20
21
|
let graph = true, detail = false, scroll = 0, detailScroll = 0, columnScroll = 0;
|
|
21
|
-
let height = 30, bodyHeight = 21, receipt = "
|
|
22
|
+
let height = 30, bodyHeight = 21, receipt = "";
|
|
22
23
|
let compose: "new" | "feedback" | undefined;
|
|
23
24
|
let sending = false, opening = false, disposed = false;
|
|
24
25
|
let composeTask: TaskView | undefined;
|
|
25
26
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
27
|
let focused = true;
|
|
28
|
+
let memberQuery = "", searching = false, hideStopped = false;
|
|
27
29
|
const tabs = ["Tasks", "Teammates", "Activity"];
|
|
28
30
|
const selectedTask = () => tasks[taskIndex];
|
|
29
|
-
const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)]
|
|
31
|
+
const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)]
|
|
32
|
+
.filter(id => name(id).toLowerCase().includes(memberQuery.toLowerCase())
|
|
33
|
+
&& (!hideStopped || snapshot.members.find(member => member.id === id)?.status !== "stopped"));
|
|
34
|
+
const taskProgress = (id: string) => {
|
|
35
|
+
const assigned = tasks.filter(task => task.owner === id);
|
|
36
|
+
return `${assigned.filter(task => task.status === "done").length}/${assigned.length}`;
|
|
37
|
+
};
|
|
30
38
|
const refresh = () => { if (!disposed) tui.requestRender(); };
|
|
31
39
|
async function poll() {
|
|
32
40
|
try {
|
|
@@ -47,9 +55,12 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
47
55
|
const statusText = (task: TaskView, text: string) => taskStatus(task) === "Ready" ? fg("muted", text)
|
|
48
56
|
: theme.style(text, { fg: indexedColor(({ Done: 2, Blocked: 1, Working: 6, Ready: 7 })[taskStatus(task)]) });
|
|
49
57
|
const stateLabel = (task: TaskView) => statusText(task, taskStatus(task));
|
|
50
|
-
const pad = (text: string, width: number) =>
|
|
58
|
+
const pad = (text: string, width: number) => {
|
|
59
|
+
const clipped = truncateToWidth(text, width);
|
|
60
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
61
|
+
};
|
|
51
62
|
const heading = (text: string) => theme.bold(fg("text", text));
|
|
52
|
-
const label = (key: string, value: string) => fg("muted", key.padEnd(
|
|
63
|
+
const label = (key: string, value: string) => fg("muted", key.padEnd(9) + " ") + value;
|
|
53
64
|
const wrap = (lines: string[], width: number) => lines.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
54
65
|
const editor = new Editor(tui, {
|
|
55
66
|
borderColor: text => fg("accent", text),
|
|
@@ -185,13 +196,38 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
185
196
|
right = split ? taskDetails(rightWidth) : [];
|
|
186
197
|
if (graph && !graphData && tasks.length) left[0] = fg("muted", "Graph unavailable or too large; showing list.");
|
|
187
198
|
} else if (tab === 1) {
|
|
188
|
-
|
|
199
|
+
const ids = memberIds();
|
|
200
|
+
const sessionWidth = Math.min(17, Math.max(1, Math.floor(leftWidth / 2)));
|
|
201
|
+
const preferredNameWidth = Math.min(24, Math.max(18, ...ids.map(id => visibleWidth(name(id)))));
|
|
202
|
+
const tasksWidth = Math.max(5, ...ids.map(id => taskProgress(id).length));
|
|
203
|
+
const showTasks = leftWidth >= 2 + preferredNameWidth + 4 + sessionWidth + 4 + tasksWidth;
|
|
204
|
+
const directory = (id: string) => clean(basename(snapshot.members.find(member => member.id === id)?.cwd ?? (id === host.self ? ctx.cwd : ""))) || "—";
|
|
205
|
+
const directorySpace = showTasks ? leftWidth - (2 + preferredNameWidth + 4 + sessionWidth + 4 + tasksWidth + 4) : 0;
|
|
206
|
+
const showDirectory = directorySpace >= 10;
|
|
207
|
+
const directoryWidth = showDirectory ? Math.min(directorySpace, Math.max(10, ...ids.map(id => visibleWidth(directory(id))))) : 0;
|
|
208
|
+
let nameWidth = Math.max(1, Math.min(preferredNameWidth, leftWidth - sessionWidth - 6));
|
|
209
|
+
const gaps = 1 + Number(showTasks) + Number(showDirectory);
|
|
210
|
+
const spare = Math.max(0, leftWidth - (2 + nameWidth + sessionWidth + (showTasks ? tasksWidth : 0) + directoryWidth + 4 * gaps));
|
|
211
|
+
const nameExtra = Math.min(spare, Math.max(0, ...ids.map(id => visibleWidth(name(id)) - nameWidth)));
|
|
212
|
+
const gap = " ".repeat(4 + Math.floor((spare - nameExtra) / gaps));
|
|
213
|
+
nameWidth += nameExtra + (spare - nameExtra) % gaps;
|
|
214
|
+
left = [fg("muted", ` ${pad("Member", nameWidth)}${gap}${pad("Session", sessionWidth)}`
|
|
215
|
+
+ (showTasks ? `${gap}${pad("Tasks", tasksWidth)}` : "") + (showDirectory ? `${gap}Directory` : "")), ...ids.map((id, index) => {
|
|
189
216
|
const member = snapshot.members.find(member => member.id === id);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
217
|
+
const selected = index === memberIndex;
|
|
218
|
+
const stopped = member && ["stopped", "interrupted"].includes(member.status);
|
|
219
|
+
const description = member?.needsInput ? "Needs input" : id === "root" ? "Main conversation"
|
|
220
|
+
: clean(member?.status ?? "Unavailable").replace(/^./, char => char.toUpperCase());
|
|
221
|
+
const color = member?.needsInput || member?.status === "interrupted" ? "error"
|
|
222
|
+
: member?.status === "busy" ? "accent" : "muted";
|
|
223
|
+
const memberName = fg(selected ? "accent" : stopped ? "dim" : "text", pad(name(id), nameWidth));
|
|
224
|
+
return fg(selected ? "accent" : "dim", `${selected ? "●" : " "} `)
|
|
225
|
+
+ (selected ? theme.bold(memberName) : memberName) + gap + fg(color, pad(description, sessionWidth))
|
|
226
|
+
+ (showTasks ? fg("muted", `${gap}${pad(taskProgress(id), tasksWidth)}`) : "")
|
|
227
|
+
+ (showDirectory ? fg("muted", `${gap}${pad(directory(id), directoryWidth)}`) : "");
|
|
193
228
|
})];
|
|
194
|
-
|
|
229
|
+
if (!ids.length) left.push(fg("muted", "No matching teammates."));
|
|
230
|
+
selectedRow = memberIndex + 1;
|
|
195
231
|
right = split ? memberDetails(rightWidth) : [];
|
|
196
232
|
} else {
|
|
197
233
|
left = events.length ? [fg("muted", " Recent messages · newest first"), "", ...events.flatMap((event, index) => [
|
|
@@ -226,20 +262,23 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
226
262
|
function memberDetails(width: number) {
|
|
227
263
|
if (tab === 2) return eventDetails(width);
|
|
228
264
|
const id = memberIds()[memberIndex];
|
|
265
|
+
if (!id) return wrap([heading("No matching teammates"), "Change the name filter or show stopped members."], width);
|
|
229
266
|
const member = snapshot.members.find(member => member.id === id);
|
|
230
267
|
const assigned = tasks.filter(task => task.owner === id);
|
|
231
|
-
return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable"))),
|
|
232
|
-
label("
|
|
233
|
-
label("
|
|
234
|
-
label("
|
|
235
|
-
label("
|
|
268
|
+
return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable")).replace(/^./, char => char.toUpperCase())),
|
|
269
|
+
label("Model", clean(member?.modelName || member?.model?.slice(member.model.indexOf("/") + 1) || "Unknown")),
|
|
270
|
+
label("Context", clean(contextSummary(member?.contextUsage).replace(/^context unknown/, "Unknown"))),
|
|
271
|
+
label("Directory", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
|
|
272
|
+
label("Tasks", taskProgress(id)), fg("muted", "Completed / currently assigned"),
|
|
236
273
|
...(member?.error ? [label("Error", clean(member.error))] : []), "", heading("Assigned tasks"),
|
|
237
274
|
...assigned.map(task => `${clean(taskName(task))} · ${taskStatus(task)}`),
|
|
238
275
|
...(assigned.length ? [] : [fg("muted", "No assigned tasks.")]), "",
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
fg("muted", "
|
|
242
|
-
|
|
276
|
+
fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open session"),
|
|
277
|
+
...(!detail ? [fg("muted", "→ Details")] : []),
|
|
278
|
+
...(!host.child && member?.role === "member" ? [fg("accent", "P Promote to main session"), fg("muted", "Stops member; reopens transcript as a main session.")] : []),
|
|
279
|
+
"", heading("Session details"), label("ID", id),
|
|
280
|
+
label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
|
|
281
|
+
label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None"),
|
|
243
282
|
], width);
|
|
244
283
|
}
|
|
245
284
|
|
|
@@ -253,13 +292,24 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
253
292
|
const row = (text: string) => fg("dim", "│ ") + pad(text, inner) + fg("dim", " │");
|
|
254
293
|
const title = heading("Teams") + fg("muted", ` · ${name(host.self)} · ${snapshot.members.filter(member => member.id !== "root" && ["starting", "busy", "idle", "stopping"].includes(member.status)).length}/${snapshot.limit} live · depth ${snapshot.depth}`);
|
|
255
294
|
const tabLine = tabs.map((text, index) => index === tab ? theme.bold(fg("accent", `[${text}]`)) : fg("muted", ` ${text} `)).join(" ") + (tab === 0 && inner >= 78 ? fg("muted", ` ${graph ? "Graph · prerequisites above · G List" : "List · G Graph"}`) : "");
|
|
256
|
-
const
|
|
257
|
-
|
|
295
|
+
const filters = `${searching || memberQuery ? `/ Filter: ${memberQuery}${searching ? "▏" : ""}` : "/ Filter"} · H ${hideStopped ? "Stopped: hidden" : "Hide stopped"}`;
|
|
296
|
+
const help = searching ? `${filters} · Enter Apply · Esc Clear`
|
|
297
|
+
: compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
|
|
298
|
+
: detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back"
|
|
299
|
+
: tab === 1 ? "↑↓ Scroll · Enter Open session · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
|
|
258
300
|
: tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List · C Feedback · N New task · Tab Next · Esc Close`
|
|
259
|
-
: tab === 1 ?
|
|
301
|
+
: tab === 1 ? `${filters} · ↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close`
|
|
260
302
|
: "↑↓ Select · Enter Details · O Open related session · Tab Next · Esc Close";
|
|
261
|
-
const
|
|
262
|
-
const
|
|
303
|
+
const teammates = snapshot.members.filter(member => member.id !== "root");
|
|
304
|
+
const memberStates = teammates.map(member => member.needsInput || member.status === "interrupted" ? "needs input"
|
|
305
|
+
: ["starting", "busy", "stopping"].includes(member.status) ? "working"
|
|
306
|
+
: member.status === "stopped" ? "stopped" : "idle");
|
|
307
|
+
const summaryText = tab === 0
|
|
308
|
+
? `${tasks.length} tasks · ${["Working", "Ready", "Blocked", "Done"].map(state => `${tasks.filter(task => taskStatus(task) === state).length} ${state.toLowerCase()}`).join(" · ")}`
|
|
309
|
+
: tab === 1 ? `${teammates.length} teammate${teammates.length === 1 ? "" : "s"} · ${["working", "idle", "stopped", "needs input"].map(state => `${memberStates.filter(value => value === state).length} ${state}`).join(" · ")}`
|
|
310
|
+
: `${events.length} message${events.length === 1 ? "" : "s"} · newest first`;
|
|
311
|
+
const summary = wrapTextWithAnsi(fg("muted", summaryText), inner);
|
|
312
|
+
const receipts = receipt ? wrapTextWithAnsi(fg("muted", receipt), inner) : [];
|
|
263
313
|
const hints = wrapTextWithAnsi(fg("dim", help), inner);
|
|
264
314
|
bodyHeight = Math.max(1, height - 6 - summary.length - receipts.length - hints.length);
|
|
265
315
|
const content = body(inner);
|
|
@@ -281,6 +331,21 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
281
331
|
refresh(); return;
|
|
282
332
|
}
|
|
283
333
|
if (opening) return;
|
|
334
|
+
if (searching) {
|
|
335
|
+
if (matchesKey(data, "escape")) { memberQuery = ""; searching = false; }
|
|
336
|
+
else if (matchesKey(data, "enter")) searching = false;
|
|
337
|
+
else if (matchesKey(data, "backspace")) memberQuery = Array.from(memberQuery).slice(0, -1).join("");
|
|
338
|
+
else {
|
|
339
|
+
const text = data.replace(/^\x1b\[200~|\x1b\[201~$/g, "");
|
|
340
|
+
if (!/[\x00-\x1f\x7f-\x9f]/.test(text)) memberQuery += clean(text);
|
|
341
|
+
}
|
|
342
|
+
memberIndex = 0; scroll = 0; refresh(); return;
|
|
343
|
+
}
|
|
344
|
+
if (tab === 1 && !detail && (data === "/" || data.toLowerCase() === "h")) {
|
|
345
|
+
if (data === "/") searching = true;
|
|
346
|
+
else hideStopped = !hideStopped;
|
|
347
|
+
memberIndex = 0; scroll = 0; refresh(); return;
|
|
348
|
+
}
|
|
284
349
|
const direction = (["up", "down", "left", "right"] as const).find(key => matchesKey(data, key));
|
|
285
350
|
if (matchesKey(data, "escape")) { if (detail) { detail = false; detailScroll = 0; } else done(undefined); }
|
|
286
351
|
else if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { tab = (tab + (matchesKey(data, "shift+tab") ? 2 : 1)) % 3; detail = false; scroll = 0; detailScroll = 0; }
|
package/src/render.ts
CHANGED
|
@@ -46,26 +46,30 @@ function entry(title: string, value: unknown, expanded: boolean, theme: Theme, e
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
type MemberLabels = () => Readonly<Record<string, string>>;
|
|
50
|
+
const defaultLabels: MemberLabels = () => ({ root: "Main" });
|
|
51
|
+
const memberLabel = (id: unknown, labels: Readonly<Record<string, string>>) => inline(typeof id === "string" && Object.hasOwn(labels, id) ? labels[id] : id);
|
|
52
|
+
|
|
53
|
+
function preview(items: unknown[], count: string, theme: Theme, labels: Readonly<Record<string, string>>): string {
|
|
54
|
+
const entries = items.slice(0, 3).map(item => toolSummary(item, theme, labels)).filter(Boolean);
|
|
51
55
|
if (items.length > 3) entries.push(`+${items.length - 3} more`);
|
|
52
56
|
return [count, ...entries].join(" · ");
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
function toolSummary(data: unknown, theme: Theme): string {
|
|
56
|
-
if (Array.isArray(data)) return preview(data, `${data.length} messages`, theme);
|
|
59
|
+
function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string, string>>): string {
|
|
60
|
+
if (Array.isArray(data)) return preview(data, `${data.length} messages`, theme, labels);
|
|
57
61
|
if (data && typeof data === "object") {
|
|
58
62
|
const value = data as Record<string, unknown>;
|
|
59
|
-
if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme);
|
|
60
|
-
if (Array.isArray(value.tasks)) return preview(value.tasks, value.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme);
|
|
61
|
-
const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? value.id;
|
|
63
|
+
if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme, labels);
|
|
64
|
+
if (Array.isArray(value.tasks)) return preview(value.tasks, value.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme, labels);
|
|
65
|
+
const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
|
|
62
66
|
const status = value.status === undefined ? "" : inline(value.status);
|
|
63
67
|
const color = status === "done" ? "success"
|
|
64
68
|
: status === "interrupted" ? "error"
|
|
65
69
|
: status === "busy" || status === "in_progress" ? "accent"
|
|
66
70
|
: status === "todo" || status === "starting" || status === "stopping" ? "warning" : "muted";
|
|
67
71
|
const parts = [status ? theme.fg(color, theme.bold(`[${status}]`)) : "", label === undefined ? "" : inline(label)];
|
|
68
|
-
if (Array.isArray(value.recipients)) parts.push(
|
|
72
|
+
if (Array.isArray(value.recipients)) parts.push(`${value.from === undefined ? "to" : `${memberLabel(value.from, labels)} →`} ${value.recipients.map(id => memberLabel(id, labels)).join(", ")}`);
|
|
69
73
|
for (const key of ["seq", "limit", "depth"]) {
|
|
70
74
|
if (value[key] !== undefined) parts.push(`${key}: ${inline(value[key])}`);
|
|
71
75
|
}
|
|
@@ -74,13 +78,13 @@ function toolSummary(data: unknown, theme: Theme): string {
|
|
|
74
78
|
return data === undefined || data === null ? "" : inline(data);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
export function teamToolRenderers(name: string): ToolRenderers {
|
|
81
|
+
export function teamToolRenderers(name: string, getLabels: MemberLabels = defaultLabels): ToolRenderers {
|
|
78
82
|
return {
|
|
79
83
|
renderShell: "self",
|
|
80
84
|
renderCall(args, theme, context) {
|
|
81
85
|
if (!context.isPartial) return new Container();
|
|
82
86
|
const { action, ...fields } = (args ?? {}) as Record<string, unknown>;
|
|
83
|
-
const request = toolSummary(fields, theme);
|
|
87
|
+
const request = toolSummary(fields, theme, getLabels());
|
|
84
88
|
const text = `${theme.fg("warning", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → running${request ? ` · ${request}` : ""}`;
|
|
85
89
|
return new TruncatedText(text, context.outputPad, 0);
|
|
86
90
|
},
|
|
@@ -92,8 +96,9 @@ export function teamToolRenderers(name: string): ToolRenderers {
|
|
|
92
96
|
if (data === undefined) {
|
|
93
97
|
try { data = JSON.parse(text); } catch { data = text; }
|
|
94
98
|
}
|
|
95
|
-
const
|
|
96
|
-
const
|
|
99
|
+
const labels = getLabels();
|
|
100
|
+
const outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme, labels) || "done";
|
|
101
|
+
const request = toolSummary(fields, theme, labels);
|
|
97
102
|
const heading = `${theme.fg(context.isError ? "error" : "success", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → ${outcome}${request && !outcome.includes(request) ? ` · ${request}` : ""}`;
|
|
98
103
|
const requestJSON = cleanText(JSON.stringify(context.args, null, 2));
|
|
99
104
|
const responseJSON = cleanText(result.details === undefined ? text : JSON.stringify(result.details, null, 2));
|
|
@@ -122,10 +127,15 @@ export function teamToolRenderers(name: string): ToolRenderers {
|
|
|
122
127
|
};
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
130
|
+
export function teamMessageRenderer(getLabels: MemberLabels = defaultLabels): MessageRenderer {
|
|
131
|
+
return (message, { expanded }, theme) => {
|
|
132
|
+
const details = message.details as { through?: number; messages?: { from: string; text: string; recipients: string[] }[] } | undefined;
|
|
133
|
+
const recent = details?.messages?.at(-1);
|
|
134
|
+
const labels = getLabels();
|
|
135
|
+
const name = (id: string) => memberLabel(id, labels);
|
|
136
|
+
const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
|
|
137
|
+
return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const renderTeamMessage = teamMessageRenderer();
|
package/src/schemas.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const TaskSchema = Type.Object({
|
|
|
36
36
|
}, { additionalProperties: false });
|
|
37
37
|
export const MessageSchema = Type.Object({
|
|
38
38
|
action: Type.Union(["send", "history", "search"].map(v => Type.Literal(v))),
|
|
39
|
-
recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true })),
|
|
39
|
+
recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs. Each must resolve to a different live member. Omit to notify all other live members." })),
|
|
40
40
|
text: Type.Optional(text), query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
41
41
|
after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
42
42
|
}, { additionalProperties: false });
|
package/src/store.ts
CHANGED
|
@@ -360,8 +360,11 @@ export class Store {
|
|
|
360
360
|
if (args.action === "send") return this.update(state => {
|
|
361
361
|
const limit = enveloped ? maxMessageText : 16000;
|
|
362
362
|
requireValue(text(args.text, limit), `Message text must be nonempty and at most ${limit} characters`);
|
|
363
|
-
const
|
|
364
|
-
requireValue(
|
|
363
|
+
const members = Object.values(state.members);
|
|
364
|
+
requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique live member names or IDs");
|
|
365
|
+
const recipients = args.recipients === undefined ? members.filter(member => member.id !== actor && isLive(member)).map(member => member.id)
|
|
366
|
+
: args.recipients.map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
|
|
367
|
+
requireValue((args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every((recipient): recipient is string => recipient !== undefined && isLive(state.members[recipient])), "Recipients must be unique live member names or IDs");
|
|
365
368
|
const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
|
|
366
369
|
state.messages.push(message);
|
|
367
370
|
return structuredClone(message);
|
package/src/telemetry.ts
CHANGED
|
@@ -10,10 +10,13 @@ export function readTelemetry(ctx: ExtensionContext): Telemetry {
|
|
|
10
10
|
|
|
11
11
|
export function modelContext(member: Pick<Member, "model" | "modelName" | "contextUsage">): string {
|
|
12
12
|
const model = member.modelName || member.model?.slice(member.model.indexOf("/") + 1) || "Model unknown";
|
|
13
|
-
|
|
13
|
+
return `${model} · ${contextSummary(member.contextUsage)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function contextSummary(usage: Member["contextUsage"]): string {
|
|
14
17
|
const count = (value: number) => value >= 1000 ? `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(value);
|
|
15
18
|
const context = !usage ? "context unknown" : usage.tokens === null || usage.percent === null
|
|
16
19
|
? `context unknown / ${count(usage.contextWindow)}`
|
|
17
20
|
: `${count(usage.tokens)} / ${count(usage.contextWindow)} (${usage.percent.toFixed(1).replace(/\.0$/, "")}%)`;
|
|
18
|
-
return
|
|
21
|
+
return context;
|
|
19
22
|
}
|