ac-teams 0.0.10 → 0.0.12
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 +3 -1
- package/docs/usage.md +29 -9
- package/package.json +1 -1
- package/src/index.ts +17 -4
- package/src/panel.ts +89 -24
- package/src/render.ts +1 -1
- package/src/schemas.ts +8 -4
- package/src/store.ts +26 -9
- package/src/task-summary.ts +2 -2
- package/src/telemetry.ts +5 -2
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Restart Pi after installation. Use Pi's `/trust` command to save approval for ea
|
|
|
14
14
|
|
|
15
15
|
## Use it
|
|
16
16
|
|
|
17
|
+
Use `team_task` for substantial solo work as well as team deliverables. Agents receive guidance to plan multi-step work, start tasks before working, and complete them after verification. Simple tasks need no list.
|
|
18
|
+
|
|
17
19
|
Your Pi session is the root; its child agents are members. Ask Pi to delegate a bounded assignment, for example:
|
|
18
20
|
|
|
19
21
|
> Launch a member to review src/parser.ts without editing, and have it send findings back to me.
|
|
@@ -21,7 +23,7 @@ Your Pi session is the root; its child agents are members. Ask Pi to delegate a
|
|
|
21
23
|
| Tool | Purpose |
|
|
22
24
|
| --- | --- |
|
|
23
25
|
| `team_agent` | Launch, inspect, stop, and resume members; manage limits and worktrees |
|
|
24
|
-
| `team_task` |
|
|
26
|
+
| `team_task` | Plan solo or team work, create tasks in batches, and track ownership and dependencies |
|
|
25
27
|
| `team_message` | Send notifications and read or search team history |
|
|
26
28
|
|
|
27
29
|
`/teams` or `/teams panel` opens Tasks, Teammates, and Activity. `/teams teammate <name-or-id>` opens a teammate directly; subcommands and teammate names autocomplete. Use the task graph or list, send feedback, and open a member's full live Pi session.
|
package/docs/usage.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Use ac-teams
|
|
2
2
|
|
|
3
|
-
ac-teams provides three tools
|
|
3
|
+
ac-teams provides three tools for planning and tracking solo and delegated work. Your Pi session is a main session; the agents it launches are members. The first main session opens coordination state on the first team request. See [main sessions](#main-sessions-share-one-team) for adding more.
|
|
4
4
|
|
|
5
5
|
| Tool | Actions | Main rule |
|
|
6
6
|
| --- | --- | --- |
|
|
@@ -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
|
|
|
@@ -55,10 +58,11 @@ The task summary sits above the input editor; the agent selector sits below the
|
|
|
55
58
|
|
|
56
59
|
1. In-progress work
|
|
57
60
|
2. Ready prerequisites blocking other tasks, with the most dependents first
|
|
58
|
-
3.
|
|
59
|
-
4.
|
|
61
|
+
3. Other ready work
|
|
62
|
+
4. Blocked work, marked `⊘` with prerequisite numbers such as `blocked by #3`
|
|
63
|
+
5. Recently completed work, newest first
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
The summary shows at most five tasks; `/teams` retains the full list. Completion time determines recent order for newly completed tasks. Older completed tasks without timestamps fall back to task-number order.
|
|
62
66
|
|
|
63
67
|
ac-teams leaves your existing footer intact and publishes a compact summary under the `ac-teams` extension-status key. Custom footers must support extension statuses to show it. In Zentui, use `/zentui extensions` to choose a placement for `ac-teams` if statuses are hidden by default. Agent navigation remains available in its own row even when the footer hides statuses.
|
|
64
68
|
|
|
@@ -66,6 +70,22 @@ Use `/teams config` to toggle **Task summary** and **Agent selector** independen
|
|
|
66
70
|
|
|
67
71
|
Hiding a display does not stop agents, change tasks, or disable `/teams`. With the agent selector hidden, ordinary editor Down behavior returns. These global display preferences do not change the team's resource ceilings.
|
|
68
72
|
|
|
73
|
+
## Plan solo and team work
|
|
74
|
+
|
|
75
|
+
The tool guidance asks agents to use `team_task` for substantial work with three or more steps, explicit task lists, and coordinated deliverables, including solo research, design, implementation, and verification. Single trivial tasks and conversation need no list. This is guidance, not automatic task creation or enforcement.
|
|
76
|
+
|
|
77
|
+
Create independent tasks in one call:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{"action":"create","tasks":[{"title":"Review parser behavior","description":"Acceptance: identify malformed-input failures with reproduction steps."},{"title":"Review display ordering","description":"Acceptance: compare implementation and display tests, and report mismatches."}]}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A batch creates 1–100 tasks atomically: invalid input leaves no tasks created. Use either `tasks` or top-level task fields, not both. Existing single-task creation remains supported. A title is enough for a simple task; description defaults to title and label defaults to its first three words, capped at 60 characters. Supply explicit acceptance criteria for substantial or delegated work. Dependencies must reference existing task UUIDs, so create prerequisites before dependent tasks.
|
|
84
|
+
|
|
85
|
+
Start a task before working and complete it immediately after checking the result. One agent normally works on one task at a time; teammates can work in parallel. Never complete partial work or work with unresolved failures.
|
|
86
|
+
|
|
87
|
+
Task list and mutation responses give the model compact summaries, including UUIDs, revisions, owners, and dependencies. `read` returns full descriptions and acceptance criteria. Expanded tool responses retain full records for people to inspect.
|
|
88
|
+
|
|
69
89
|
## Give members bounded assignments
|
|
70
90
|
|
|
71
91
|
Before delegating a deliverable, create or reuse a shared task and include its ID in the member's brief. Track independently owned outcomes, not every agent, tool call, or procedural step. For example:
|
|
@@ -101,11 +121,11 @@ Children can delegate through the same coordinator, subject to depth and capacit
|
|
|
101
121
|
|
|
102
122
|
## Track progress separately from ownership
|
|
103
123
|
|
|
104
|
-
|
|
124
|
+
Start ready work directly; `start` claims an unowned task and begins it atomically. Use `claim` only to reserve work in advance. Complete work after checking acceptance criteria or report blockers. Starting requires every dependency to be done.
|
|
105
125
|
|
|
106
126
|
Each task has a stable team-local `number` and a 1–3-word `label` (at most 60 characters). The graph and list show `#3 Parser review`; details retain the full title, description, and UUID.
|
|
107
127
|
|
|
108
|
-
Numbers follow creation order. Editing, filtering, or reopening tasks never changes their numbers.
|
|
128
|
+
Numbers follow creation order. Editing, filtering, or reopening tasks never changes their numbers. An omitted label is derived from the title's first three words, capped at 60 characters. An explicit label must satisfy the same limits.
|
|
109
129
|
|
|
110
130
|
Interim and final goals are ordinary tasks. Use dependencies for real prerequisites, not to make the diagram connected. Independent tasks remain disconnected until they genuinely contribute to a dependent task.
|
|
111
131
|
|
|
@@ -120,12 +140,12 @@ Use the returned task ID with these actions:
|
|
|
120
140
|
| Action | Effect |
|
|
121
141
|
| --- | --- |
|
|
122
142
|
| `claim` | Reserve an unowned to-do task, even if blocked. Progress stays `todo`. |
|
|
123
|
-
| `start` |
|
|
143
|
+
| `start` | Claim unowned work and move `todo` to `in_progress` atomically, only when every dependency is `done`. An existing owner must be the caller. |
|
|
124
144
|
| `complete` | The owner moves `in_progress` to `done`, with `acceptance: true`. Ownership remains. |
|
|
125
145
|
| `release` | Clear ownership. In-progress work returns to `todo`; done work stays `done`. |
|
|
126
146
|
| `reopen` | Explicitly move `done` to `todo`, retaining any owner. Refuse if a dependent is in progress or done. |
|
|
127
147
|
|
|
128
|
-
Only the owner can
|
|
148
|
+
Starting never steals another owner's work. Only the owner can complete or release its work. An ancestor may explicitly release a stopped or interrupted descendant's ownership. The owner or root can reopen an owned task; any member can reopen an unowned one. Done tasks cannot be claimed until reopened.
|
|
129
149
|
|
|
130
150
|
Include `dependencies` on creation or `edit`. Any member can edit unowned to-do work; only the owner can edit reserved to-do work. In-progress and done tasks cannot be edited. The coordinator rejects unknown dependencies and cycles. An optional `revision` rejects stale mutations.
|
|
131
151
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -274,7 +274,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
274
274
|
});
|
|
275
275
|
function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
|
|
276
276
|
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use stable member/task IDs; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
|
|
277
|
-
const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and include its ID in the member's brief.
|
|
277
|
+
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
278
|
pi.registerTool({
|
|
279
279
|
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
280
|
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."],
|
|
@@ -283,11 +283,24 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
283
283
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
|
|
284
284
|
});
|
|
285
285
|
pi.registerTool({
|
|
286
|
-
name: "team_task", label: "Team task", description: "
|
|
287
|
-
|
|
286
|
+
name: "team_task", label: "Team task", description: "Plan and track multi-step solo and team work in one shared list. Create one task or a batch with tasks:[{title,...}], list compact summaries, read full details, edit, claim a reservation, start, complete, release, or reopen. Start atomically claims unowned ready work. Progress is todo/in_progress/done; blockedBy is derived from unfinished dependencies. Mutations support revision checks.",
|
|
287
|
+
promptSnippet: "Plan and track multi-step work, alone or with teammates",
|
|
288
|
+
promptGuidelines: [
|
|
289
|
+
"Use team_task before substantial work with 3+ steps, when the user gives an explicit task list, or when coordinating deliverables. This includes your own research, design, implementation, and verification; no teammate is required. Skip single trivial tasks, conversation, and tool-by-tool bookkeeping.",
|
|
290
|
+
"Create the initial plan in one team_task call with tasks:[{title,description},...]. Titles are required; labels and descriptions default from titles. Include acceptance criteria for substantial or delegated work. Dependencies must reference existing task UUIDs; create prerequisites before dependent tasks. Reuse relevant existing tasks rather than duplicating them.",
|
|
291
|
+
"Call team_task start BEFORE beginning a task; it claims unowned ready work atomically. Use claim only to reserve work in advance, including blocked work. Complete each task IMMEDIATELY after checking its result with acceptance=true; never complete partial work or work with unresolved failures. Report blockers. Each agent should normally work on one task at a time; teammates can work in parallel.",
|
|
292
|
+
taskTrackingGuidance, taskRequestGuidance,
|
|
293
|
+
"team_task list returns compact summaries; read a task for its full description and acceptance criteria. Use returned UUIDs for API references and stable task numbers/names when reporting to people. Add dependencies only for real prerequisites, never merely to connect the diagram.",
|
|
294
|
+
"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
|
+
],
|
|
288
296
|
parameters: TaskSchema,
|
|
289
297
|
...teamToolRenderers("team_task"),
|
|
290
|
-
async execute(_id, args, _signal, _update, ctx) {
|
|
298
|
+
async execute(_id, args, _signal, _update, ctx) {
|
|
299
|
+
const data = await handle("task", args, ctx) as TaskView | { tasks: TaskView[]; total: number };
|
|
300
|
+
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 });
|
|
301
|
+
const content = args.action === "read" ? data : "tasks" in data ? { tasks: data.tasks.map(compact), total: data.total } : compact(data);
|
|
302
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(content) }], details: data };
|
|
303
|
+
},
|
|
291
304
|
});
|
|
292
305
|
pi.registerTool({
|
|
293
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.",
|
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
|
@@ -57,7 +57,7 @@ function toolSummary(data: unknown, theme: Theme): string {
|
|
|
57
57
|
if (data && typeof data === "object") {
|
|
58
58
|
const value = data as Record<string, unknown>;
|
|
59
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.tasks.length} of ${value.total} tasks`, 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
61
|
const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? value.id;
|
|
62
62
|
const status = value.status === undefined ? "" : inline(value.status);
|
|
63
63
|
const color = status === "done" ? "success"
|
package/src/schemas.ts
CHANGED
|
@@ -21,14 +21,18 @@ export const AgentSchema = Type.Object({
|
|
|
21
21
|
}, { additionalProperties: false, description: "Omit to keep the requester's network access. Children can only narrow it. The model provider stays reachable." })),
|
|
22
22
|
}, { additionalProperties: false, description: "Launch only: OS-enforced directory access (Docker container). Paths are absolute or cwd-relative directories; write implies read and permits changing those live files; everything else on the host is hidden, and outbound access goes through a proxy that reaches only public addresses. Omit to inherit the requesting member's access; an unsandboxed requester then launches an unsandboxed member. Children can only narrow access. Launch fails if enforcement is unavailable; check team_agent list/limits sandbox first." })),
|
|
23
23
|
}, { additionalProperties: false });
|
|
24
|
+
const taskFields = {
|
|
25
|
+
title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
26
|
+
label: Type.Optional(Type.String({ minLength: 1, maxLength: taskLabelMaxLength, pattern: taskLabelPattern.source, description: "Optional 1–3-word label. Derived from title when omitted." })),
|
|
27
|
+
description: Type.Optional(Type.String({ maxLength: 16000, description: "Details and acceptance criteria. Defaults to title; include explicit criteria for substantial or delegated work." })),
|
|
28
|
+
dependencies: Type.Optional(Type.Array(id, { maxItems: 100, uniqueItems: true })),
|
|
29
|
+
};
|
|
24
30
|
export const TaskSchema = Type.Object({
|
|
25
31
|
revision: Type.Optional(Type.Integer({ minimum: 1 })), acceptance: Type.Optional(Type.Boolean()),
|
|
26
32
|
offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
27
33
|
action: Type.Union(["create", "list", "read", "edit", "claim", "start", "release", "complete", "reopen"].map(v => Type.Literal(v))),
|
|
28
|
-
id: Type.Optional(id),
|
|
29
|
-
|
|
30
|
-
description: Type.Optional(Type.String({ maxLength: 16000 })),
|
|
31
|
-
dependencies: Type.Optional(Type.Array(id, { maxItems: 100, uniqueItems: true })),
|
|
34
|
+
id: Type.Optional(id), ...taskFields,
|
|
35
|
+
tasks: Type.Optional(Type.Array(Type.Object({ ...taskFields, title: Type.String({ minLength: 1, maxLength: 500 }) }, { additionalProperties: false }), { minItems: 1, maxItems: 100, description: "Create several tasks atomically. Use instead of top-level task fields. Dependencies must refer to existing task IDs, not items in this batch." })),
|
|
32
36
|
}, { additionalProperties: false });
|
|
33
37
|
export const MessageSchema = Type.Object({
|
|
34
38
|
action: Type.Union(["send", "history", "search"].map(v => Type.Literal(v))),
|
package/src/store.ts
CHANGED
|
@@ -53,6 +53,10 @@ function taskView(state: TeamState, task: Task): TaskView {
|
|
|
53
53
|
return structuredClone({ ...task, blockedBy: task.dependencies.filter(id => state.tasks[id]?.status !== "done") });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function taskLabel(title: string): string {
|
|
57
|
+
return stripVTControlCharacters(title).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ").slice(0, taskLabelMaxLength).trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
56
60
|
function migrate(state: unknown): boolean {
|
|
57
61
|
if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
|
|
58
62
|
if (state.version !== 3) migrateTasks(state);
|
|
@@ -78,7 +82,7 @@ function migrateTasks(state: Record<string, unknown>): void {
|
|
|
78
82
|
else task.status = "done";
|
|
79
83
|
}
|
|
80
84
|
task.number = ++number;
|
|
81
|
-
task.label =
|
|
85
|
+
task.label = taskLabel(task.title) || "Task";
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -273,6 +277,7 @@ export class Store {
|
|
|
273
277
|
|
|
274
278
|
task(actor: string, args: TaskArgs): TaskView | { tasks: TaskView[]; total: number } {
|
|
275
279
|
requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
|
|
280
|
+
requireValue(args.tasks === undefined || args.action === "create", "tasks is only valid for create");
|
|
276
281
|
if (args.action === "list") {
|
|
277
282
|
const tasks = Object.values(this.state.tasks);
|
|
278
283
|
const offset = args.offset ?? 0, limit = args.limit ?? 100;
|
|
@@ -285,13 +290,24 @@ export class Store {
|
|
|
285
290
|
}
|
|
286
291
|
return this.update(state => {
|
|
287
292
|
if (args.action === "create") {
|
|
288
|
-
requireValue(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
293
|
+
requireValue(args.tasks === undefined || (Array.isArray(args.tasks) && args.tasks.length > 0 && args.tasks.length <= 100), "Batch create requires 1-100 tasks");
|
|
294
|
+
requireValue(args.tasks === undefined || [args.title, args.label, args.description, args.dependencies].every(value => value === undefined), "Use tasks or top-level task fields, not both");
|
|
295
|
+
const inputs = args.tasks ?? [args];
|
|
296
|
+
let number = Object.values(state.tasks).reduce((max, task) => Math.max(max, task.number), 0);
|
|
297
|
+
const existing = new Set(Object.keys(state.tasks));
|
|
298
|
+
const created = inputs.map(input => {
|
|
299
|
+
requireValue(object(input) && text(input.title, 500), "Task requires a title (max 500)");
|
|
300
|
+
const description = input.description === undefined ? input.title : input.description;
|
|
301
|
+
requireValue(text(description), "Task requires a description (max 16000)");
|
|
302
|
+
const label = input.label === undefined ? taskLabel(input.title) : input.label;
|
|
303
|
+
requireValue(text(label, taskLabelMaxLength) && taskLabelPattern.test(label), "Task requires a 1–3-word label (max 60 characters)");
|
|
304
|
+
const dependencies = input.dependencies === undefined ? [] : structuredClone(input.dependencies);
|
|
305
|
+
requireValue(Array.isArray(dependencies) && dependencies.length <= 100 && dependencies.every(dep => existing.has(dep)), "Dependencies must refer to existing tasks");
|
|
306
|
+
const task: Task = { id: randomUUID(), number: ++number, label, revision: 1, creator: actor, title: input.title, description, dependencies, status: "todo" };
|
|
307
|
+
state.tasks[task.id] = task;
|
|
308
|
+
return taskView(state, task);
|
|
309
|
+
});
|
|
310
|
+
return args.tasks === undefined ? created[0] : { tasks: created, total: created.length };
|
|
295
311
|
}
|
|
296
312
|
requireValue(args.id && Object.hasOwn(state.tasks, args.id), "Unknown task");
|
|
297
313
|
const task = state.tasks[args.id];
|
|
@@ -308,10 +324,11 @@ export class Store {
|
|
|
308
324
|
requireValue(!task.owner, `Task is already owned by ${task.owner}`);
|
|
309
325
|
task.owner = actor;
|
|
310
326
|
} else if (args.action === "start") {
|
|
311
|
-
requireValue(task.owner === actor, "Only the owner can start this task
|
|
327
|
+
requireValue(!task.owner || task.owner === actor, "Only the owner can start this task");
|
|
312
328
|
requireValue(task.status === "todo", "Only to-do tasks can be started");
|
|
313
329
|
const blocked = taskView(state, task).blockedBy;
|
|
314
330
|
requireValue(blocked.length === 0, `Task is blocked by unfinished dependencies: ${blocked.join(", ")}`);
|
|
331
|
+
task.owner = actor;
|
|
315
332
|
task.status = "in_progress";
|
|
316
333
|
} else if (args.action === "complete") {
|
|
317
334
|
requireValue(task.owner === actor, "Only the owner can complete this task");
|
package/src/task-summary.ts
CHANGED
|
@@ -9,8 +9,8 @@ export function summaryTasks(tasks: TaskView[]): TaskView[] {
|
|
|
9
9
|
if (task.status !== "done") for (const id of task.blockedBy) dependents.set(id, (dependents.get(id) ?? 0) + 1);
|
|
10
10
|
}
|
|
11
11
|
const rank = (task: TaskView) => task.status === "in_progress" ? 0
|
|
12
|
-
: task.status === "done" ?
|
|
13
|
-
return tasks.
|
|
12
|
+
: task.status === "done" ? 4 : task.blockedBy.length ? 3 : dependents.has(task.id) ? 1 : 2;
|
|
13
|
+
return [...tasks].sort((a, b) => rank(a) - rank(b)
|
|
14
14
|
|| (a.status === "done" && b.status === "done" ? (b.completedAt ?? -1) - (a.completedAt ?? -1) || b.number - a.number
|
|
15
15
|
: (dependents.get(b.id) ?? 0) - (dependents.get(a.id) ?? 0) || a.number - b.number)).slice(0, 5);
|
|
16
16
|
}
|
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
|
}
|
package/src/types.ts
CHANGED
|
@@ -97,7 +97,14 @@ export interface TeamState {
|
|
|
97
97
|
tasks: Record<string, Task>;
|
|
98
98
|
messages: Message[];
|
|
99
99
|
}
|
|
100
|
+
export interface TaskInput {
|
|
101
|
+
title: string;
|
|
102
|
+
label?: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
dependencies?: string[];
|
|
105
|
+
}
|
|
100
106
|
export interface TaskArgs {
|
|
107
|
+
tasks?: TaskInput[];
|
|
101
108
|
action: "create" | "list" | "read" | "edit" | "claim" | "start" | "release" | "complete" | "reopen";
|
|
102
109
|
revision?: number;
|
|
103
110
|
acceptance?: boolean;
|