ac-teams 0.0.14 → 0.0.16
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/operations.md +11 -3
- package/docs/usage.md +21 -21
- package/package.json +1 -1
- package/src/coordinator.ts +32 -16
- package/src/graph.ts +13 -4
- package/src/group.ts +134 -0
- package/src/index.ts +37 -12
- package/src/panel.ts +114 -45
- package/src/render.ts +12 -12
- package/src/resources.ts +9 -9
- package/src/schemas.ts +12 -3
- package/src/store.ts +76 -20
- package/src/types.ts +27 -4
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Your Pi session is the root; its child agents are members. Ask Pi to delegate a
|
|
|
26
26
|
| `team_task` | Plan solo or team work, create tasks in batches, and track ownership and dependencies |
|
|
27
27
|
| `team_message` | Send notifications and read or search team history |
|
|
28
28
|
|
|
29
|
-
`/teams` or `/teams panel` opens Tasks, Teammates, and
|
|
29
|
+
`/teams` or `/teams panel` opens Tasks, Teammates, and Board. `/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.
|
|
30
30
|
|
|
31
31
|
The task summary sits above the input. The agent selector sits below the input, above your existing footer, and shows descriptive names, models, and context usage. ac-teams publishes a compact team status without replacing the footer or editor supplied by other extensions. `/teams config` toggles these displays independently.
|
|
32
32
|
|
package/docs/operations.md
CHANGED
|
@@ -35,15 +35,21 @@ Other delegation tools remain outside these limits. See [companion tools](usage.
|
|
|
35
35
|
|
|
36
36
|
`team_agent list` and `limits` include a fresh `resources` advisory. Launch and teammate resume results include the sample taken before startup.
|
|
37
37
|
|
|
38
|
-
It reports host available RAM, occupied swap, recent memory stalls, and live/starting member counts. Values are bytes; Linux pressure averages are percentages
|
|
38
|
+
It reports host available RAM, occupied swap, recent memory stalls, and live/starting member counts. Values are bytes; Linux pressure averages are percentages.
|
|
39
|
+
|
|
40
|
+
`someAvg10` and `fullAvg10` cover ten-second trends; `fullAvg60` covers a sixty-second trend. Missing or invalid metrics are `null` with a warning, not zero or an all-clear.
|
|
39
41
|
|
|
40
42
|
The check reads only `/proc/meminfo` and `/proc/pressure/memory` on demand. It adds no polling, process-tree scans, or subprocesses.
|
|
41
43
|
|
|
42
|
-
Available RAM below 10% triggers a low-headroom warning (a heuristic, not a per-member budget).
|
|
44
|
+
Available RAM below 10% triggers a low-headroom warning (a heuristic, not a per-member budget). Low headroom alone does not prescribe sequential work.
|
|
45
|
+
|
|
46
|
+
Memory contention warns when `fullAvg60 >= 5` or `fullAvg10 >= 10`. Full pressure measures time when all non-idle work is stalled on memory. Partial pressure (`someAvg10`) remains informational; occupied swap alone does not trigger a pressure warning.
|
|
47
|
+
|
|
48
|
+
These thresholds are advisory defaults, not measured limits for optimal concurrency. Consider reducing concurrency if contention is slowing progress.
|
|
43
49
|
|
|
44
50
|
Starting members reserve count slots synchronously, but may not yet consume their full memory. Concurrent launch results warn about earlier starts.
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
Choose concurrency using resource readings and observed progress. These advisories never block a launch, change the configured ceiling, reserve RAM, or stop a member. Host figures do not account for a tighter container/cgroup limit or guarantee future capacity; other teams and non-team processes also consume resources.
|
|
47
53
|
|
|
48
54
|
Idle members keep Pi and its extensions alive. Stop unneeded members to release their owned processes, then resume their saved sessions when needed.
|
|
49
55
|
|
|
@@ -121,6 +127,8 @@ State schema 3 adds stable task numbers and short labels. Opening a schema 1 or
|
|
|
121
127
|
|
|
122
128
|
Schema 1 also migrates progress: `pending` becomes `todo`, `claimed` becomes `in_progress`, and `completed` becomes `done`. Migration writes atomically and rejects invalid snapshots without replacement. Older ac-teams code cannot reopen schema 3; stop old sessions before updating.
|
|
123
129
|
|
|
130
|
+
State schema 5 turns messages into board posts: each has a `summary`, optional `detail`, and an optional `task` or `replyTo`; tasks gain an optional `delegator` and `outcome` post reference, and records an optional `waiting` registration. Opening a schema 4 team keeps every message's text unchanged as its summary. Older done tasks have no outcome, and no delegation is inferred. Schema 5 state cannot be opened by older ac-teams versions.
|
|
131
|
+
|
|
124
132
|
State schema 4 adds a role to each record: `main` or `member`. Opening a schema 3 team makes the root record a main session and every other record a member. A record without a role has left the team. A promotion records each step, so a failure rolls the member back to a stopped member with its original transcript path.
|
|
125
133
|
|
|
126
134
|
Only the coordinator writes state. Mutations validate a copy, flush a temporary snapshot, atomically rename it, and flush the directory before returning success. An exclusive process-identity lock prevents concurrent owners. Private team directories use 0700; state files and the authenticated Unix socket use 0600. Uncertain locks fail closed.
|
package/docs/usage.md
CHANGED
|
@@ -6,7 +6,7 @@ ac-teams provides three tools for planning and tracking solo and delegated work.
|
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| `team_agent` | `launch`, `list`, `status`, `stop`, `resume`, `limits`, `cleanup` | A parent controls its descendants, not peers or ancestors |
|
|
8
8
|
| `team_task` | `create`, `list`, `read`, `edit`, `claim`, `start`, `release`, `complete`, `reopen` | Claim reserves work; dependencies gate starting, not ownership |
|
|
9
|
-
| `team_message` | `send`, `history`, `search` |
|
|
9
|
+
| `team_message` | `send`, `history`, `search`, `wait` | Routing selects who is notified, not who can read |
|
|
10
10
|
|
|
11
11
|
In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams teammate <name-or-id>` opens a live teammate directly; autocomplete suggests subcommands and teammate names. You can also enter an exact teammate ID. The older `/teams open <id>` form remains supported. Headless roots retain the status listing. `/teams stop` stops every member; main sessions keep running. `/teams join` and `/teams leave` change which team this session belongs to. See [operations](operations.md) for limits, recovery, and worktree cleanup.
|
|
12
12
|
|
|
@@ -22,13 +22,13 @@ Context shows Pi's reported token estimate, window size, and percentage, for exa
|
|
|
22
22
|
|
|
23
23
|
- Press Down from an empty editor to focus Main in the agent selector. Up/Down select members; Enter opens the selected live session; Esc returns to editing.
|
|
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
|
-
- `/teams` opens three tabs. Tab moves between Tasks, Teammates, and
|
|
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.
|
|
25
|
+
- `/teams` opens three tabs. Tab moves between Tasks, Teammates, and Board; Esc closes the panel.
|
|
26
|
+
- **Tasks:** Arrow keys select nodes in the dependency graph. G toggles graph/list, and Enter shows full details. The graph opens fitted to the pane; `-` and `+` shorten or restore titles gradually (number and status always stay), and F returns to fit. The selected box has a heavy border and accent text. Green means Done, red Blocked, cyan Working, and neutral Ready; written labels remain visible. Large or unsupported graphs fall back to the list.
|
|
27
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
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
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
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.
|
|
31
|
-
- **
|
|
31
|
+
- **Board:** Shows the latest 100 posts, one row each with sender, kind (post, reply, or outcome), summary, task, and time. The details pane shows recipients, task, summary, detail, and replies. Use `team_message history` to read older posts.
|
|
32
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.
|
|
33
33
|
|
|
34
34
|
A task request is a proposal, not an instruction to create a task. Main receives guidance to review the agreed scope and existing work first: reuse a task that already covers the request, extend one when coherent and permitted, or create distinct in-scope work. It should clarify unresolved scope conflicts and explain its decision with task references.
|
|
@@ -37,9 +37,9 @@ The send receipt confirms delivery, not acceptance or task creation. This is age
|
|
|
37
37
|
|
|
38
38
|
Each team tool call uses one line with a status dot, tool name, action, and result preview. Bold, colored badges such as `[todo]` separate statuses from titles and names.
|
|
39
39
|
|
|
40
|
-
The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full
|
|
40
|
+
The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full response JSON; mouse-enabled Pi views also support clicking the row.
|
|
41
41
|
|
|
42
|
-
Tool summaries and team notification previews prefer teammate names over IDs. Message receipt IDs stay out of compact summaries. Ctrl+O expands the
|
|
42
|
+
Tool summaries and team notification previews prefer teammate names over IDs. Message receipt IDs stay out of compact summaries. Ctrl+O expands the response, including IDs. Unknown members fall back to their IDs. UI state colors do not replace the underlying progress, ownership, or dependency fields.
|
|
43
43
|
|
|
44
44
|
## Main sessions share one team
|
|
45
45
|
|
|
@@ -88,7 +88,7 @@ Task list and mutation responses give the model compact summaries, including UUI
|
|
|
88
88
|
|
|
89
89
|
## Give members bounded assignments
|
|
90
90
|
|
|
91
|
-
Before delegating a deliverable, create or reuse a shared task and
|
|
91
|
+
Before delegating a deliverable, create or reuse a shared task and pass it as the launch `task`. The new member owns it, you become its delegator, and its posts and completion outcome reach you. Track independently owned outcomes, not every agent, tool call, or procedural step. For example:
|
|
92
92
|
|
|
93
93
|
- A fix and an independently owned test assignment need separate tasks.
|
|
94
94
|
- A diagnosis others need for follow-up needs a task, even if the investigation is read-only.
|
|
@@ -101,13 +101,13 @@ Review the current task list and relevant details first. Reuse an existing task
|
|
|
101
101
|
{"action":"create","label":"Parser review","title":"Review parser errors and malformed input","description":"Read-only review of src/parser.ts. Acceptance: report concrete malformed-input failures with reproduction steps, or report that none were found."}
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Pass the returned task UUID as `task`. Example `team_agent` arguments:
|
|
105
105
|
|
|
106
106
|
```json
|
|
107
|
-
{"action":"launch","name":"parser-review","
|
|
107
|
+
{"action":"launch","name":"parser-review","task":"<task-id>","brief":"Review src/parser.ts for malformed-input failures. Read only; do not delegate or edit."}
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
The launch task must be a to-do task that is unowned or yours; a failed launch returns it. ac-teams neither creates tasks automatically nor blocks launches without tasks. After delegating, do distinct work or `wait`; do not repeat the delegated work.
|
|
111
111
|
|
|
112
112
|
Every launch needs a unique descriptive `name`: one or two lowercase words, hyphenated if two, at most 40 characters. For example, `parser-review` or `research`. `main`, `root`, and names matching an existing member's ID are reserved. Names remain stable across stop/resume; IDs remain the API references.
|
|
113
113
|
|
|
@@ -141,9 +141,9 @@ Use the returned task ID with these actions:
|
|
|
141
141
|
| --- | --- |
|
|
142
142
|
| `claim` | Reserve an unowned to-do task, even if blocked. Progress stays `todo`. |
|
|
143
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. |
|
|
144
|
-
| `complete` | The owner moves `in_progress` to `done`, with `acceptance: true
|
|
144
|
+
| `complete` | The owner moves `in_progress` to `done`, with `acceptance: true` and an outcome `summary` (optional `detail`). One atomic step records the outcome post and the done state; an identical retry returns the original. Ownership remains. |
|
|
145
145
|
| `release` | Clear ownership. In-progress work returns to `todo`; done work stays `done`. |
|
|
146
|
-
| `reopen` | Explicitly move `done` to `todo`, retaining any owner. Refuse if a dependent is in progress or done. |
|
|
146
|
+
| `reopen` | Explicitly move `done` to `todo`, retaining any owner and clearing the outcome reference; the outcome post stays. Refuse if a dependent is in progress or done. |
|
|
147
147
|
|
|
148
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.
|
|
149
149
|
|
|
@@ -151,27 +151,27 @@ Include `dependencies` on creation or `edit`. Any member can edit unowned to-do
|
|
|
151
151
|
|
|
152
152
|
`acceptance: true` asserts that the agent checked the acceptance criteria; it is not an independent test runner. Stop/crash retains ownership and progress. There is no automatic reassignment.
|
|
153
153
|
|
|
154
|
-
##
|
|
154
|
+
## Post to the board instead of polling
|
|
155
155
|
|
|
156
|
-
Example `team_message` arguments:
|
|
156
|
+
Every post has a one-line `summary` that stands alone, optional `detail`, and an existing `task` or a `replyTo` post. Example `team_message` arguments:
|
|
157
157
|
|
|
158
158
|
```json
|
|
159
|
-
{"action":"send","
|
|
159
|
+
{"action":"send","task":"<task-id>","summary":"Parser rejects empty input with a clear error; two malformed cases still crash.","detail":"Repro: ..."}
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
Use exact teammate names (such as `"oom-review"`) or member IDs in `recipients`; IDs remain valid.
|
|
162
|
+
Use exact teammate names (such as `"oom-review"`) or member IDs in `recipients`; IDs remain valid. A post reaches its named `recipients`, the linked task's owner and delegator (the creator when there is none), the author of a replied-to post, and members waiting on that task, never the sender. A reply inherits its post's task. Posts are never broadcast: one with no recipients, task, or reply is rejected, as are unknown member IDs. All team members can read the full history.
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
Receipts and stored history retain resolved IDs. All team members can read the full history, including direct messages.
|
|
164
|
+
Receipts and stored history retain resolved IDs.
|
|
167
165
|
|
|
168
166
|
```json
|
|
169
167
|
{"action":"history","after":0,"limit":20}
|
|
170
168
|
```
|
|
171
169
|
|
|
172
|
-
Use the last returned `seq` as the next `after` cursor. `search` adds a case-sensitive literal `query
|
|
170
|
+
Use the last returned `seq` as the next `after` cursor. `search` adds a case-sensitive literal `query` over summaries and detail; results remain cursor-paged. Reads never consume posts. Task and message result pages are limited to 100 items.
|
|
171
|
+
|
|
172
|
+
Notices carry each post's summary and steer into the recipient's next safe point: an idle member wakes, a busy one sees them after its current tool batch. Read history only for detail your own work needs. A notice is acknowledged once it is persisted in the recipient's session, so a crash before that redelivers it.
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
`wait` with `tasks` yields: if a post is already waiting it returns at once; otherwise the turn ends without another model request, and the next post addressed to you or on those tasks starts a new one. Starting any turn clears the wait.
|
|
175
175
|
|
|
176
176
|
Messages survive crashes; exactly-once delivery or model execution does not. Avoid acknowledgement-only replies.
|
|
177
177
|
|
package/package.json
CHANGED
package/src/coordinator.ts
CHANGED
|
@@ -29,6 +29,9 @@ export const taskRequestGuidance = [
|
|
|
29
29
|
"Respect completion checks and add only real dependencies. Explain the decision and any actual task changes to the user.",
|
|
30
30
|
].join(" ");
|
|
31
31
|
|
|
32
|
+
/** Posts per notice, oldest first; the session acknowledges a batch before the next one goes out. */
|
|
33
|
+
const noticeBatch = 20;
|
|
34
|
+
|
|
32
35
|
export function assistantEvent(message: unknown): { type: string; text: string } | undefined {
|
|
33
36
|
if (!isRecord(message) || message.role !== "assistant") return;
|
|
34
37
|
if (message.stopReason === "error") return { type: "model_error", text: String(message.errorMessage || "Model request failed").slice(0, 2000) };
|
|
@@ -95,7 +98,7 @@ export class Coordinator {
|
|
|
95
98
|
this.options = options;
|
|
96
99
|
this.self = options.self ?? "root";
|
|
97
100
|
const root: Member = { id: "root", role: "main", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile, model: options.model, thinking: options.thinking };
|
|
98
|
-
const initial: TeamState = { version:
|
|
101
|
+
const initial: TeamState = { version: 5, id: options.id, key: randomUUID(), limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
|
|
99
102
|
this.store = new Store(initial, options.home);
|
|
100
103
|
this.socketPath = join(this.store.dir, "coordinator.sock");
|
|
101
104
|
}
|
|
@@ -280,7 +283,7 @@ export class Coordinator {
|
|
|
280
283
|
current.modelName = event.modelName;
|
|
281
284
|
current.contextUsage = event.contextUsage ?? undefined;
|
|
282
285
|
}
|
|
283
|
-
else if (event.type === "agent_start") { current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
|
|
286
|
+
else if (event.type === "agent_start") { delete current.waiting; current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
|
|
284
287
|
else if (event.type === "agent_settled") { delete current.needsInput; current.status = "idle"; current.activity = current.error ? "Model request failed" : current.summary || "Available for follow-up"; }
|
|
285
288
|
else if (event.type === "activity") current.activity = event.text;
|
|
286
289
|
else if (event.type === "summary") current.summary = event.text?.slice(0, 500);
|
|
@@ -291,7 +294,7 @@ export class Coordinator {
|
|
|
291
294
|
}
|
|
292
295
|
if (event.type === "model_error" && !isMain(member) && member.status !== "stopping") {
|
|
293
296
|
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : this.self;
|
|
294
|
-
this.store.message(actor, { action: "send", recipients: [recipient],
|
|
297
|
+
this.store.message(actor, { action: "send", recipients: [recipient], summary: `Member ${actor} model error: ${event.text || "Model request failed"}` });
|
|
295
298
|
this.notify(recipient);
|
|
296
299
|
}
|
|
297
300
|
}
|
|
@@ -331,7 +334,7 @@ export class Coordinator {
|
|
|
331
334
|
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to ${memberName(this.store.state.members[main])} instead. `; recipient = main; }
|
|
332
335
|
}
|
|
333
336
|
}
|
|
334
|
-
const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
|
|
337
|
+
const message = this.store.message(actor, { action: "send", recipients: [recipient], summary: text }, true) as Message;
|
|
335
338
|
this.notify(recipient);
|
|
336
339
|
return `${fallback}Sent to ${isMain(this.store.state.members[recipient]) ? memberName(this.store.state.members[recipient]) : recipient} · message ${message.seq}`;
|
|
337
340
|
}
|
|
@@ -356,7 +359,7 @@ export class Coordinator {
|
|
|
356
359
|
const live = this.live();
|
|
357
360
|
const startingMembers = live.filter(member => member.status === "starting").length;
|
|
358
361
|
const warnings = [...sample.warnings];
|
|
359
|
-
if (startingMembers) warnings.push(`${startingMembers} teammate(s) still starting; their full memory cost is not yet reflected
|
|
362
|
+
if (startingMembers) warnings.push(`${startingMembers} teammate(s) still starting; their full memory cost is not yet reflected in host headroom.`);
|
|
360
363
|
return { ...sample, liveMembers: live.length, startingMembers, warnings };
|
|
361
364
|
}
|
|
362
365
|
ack(id: string, through: number): void {
|
|
@@ -365,6 +368,8 @@ export class Coordinator {
|
|
|
365
368
|
if (this.closed) throw new Error("Team is stopping");
|
|
366
369
|
if (through <= this.store.state.members[id].notified) return;
|
|
367
370
|
this.store.update(state => { state.members[id].notified = through; });
|
|
371
|
+
// Batches are bounded, so posts beyond the acknowledged batch go out next.
|
|
372
|
+
this.notify(id);
|
|
368
373
|
}
|
|
369
374
|
private notify(id: string): void {
|
|
370
375
|
if (this.closed || this.notices.has(id)) return;
|
|
@@ -373,11 +378,11 @@ export class Coordinator {
|
|
|
373
378
|
const member = this.store.state.members[id];
|
|
374
379
|
if (!member || !isLive(member)) return;
|
|
375
380
|
const pending = this.store.state.messages.filter(message => message.seq > member.notified && message.recipients.includes(id));
|
|
376
|
-
const
|
|
381
|
+
const batch = pending.slice(0, noticeBatch), through = batch.at(-1)?.seq;
|
|
377
382
|
if (!through) return;
|
|
378
383
|
try {
|
|
379
|
-
if (id === this.self) this.options.notice(through,
|
|
380
|
-
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages:
|
|
384
|
+
if (id === this.self) this.options.notice(through, batch);
|
|
385
|
+
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: batch }); }
|
|
381
386
|
} catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
|
|
382
387
|
}, 50));
|
|
383
388
|
}
|
|
@@ -392,10 +397,10 @@ export class Coordinator {
|
|
|
392
397
|
if (member.access) throw new Error("Sandboxed members cannot send user team actions; use Main");
|
|
393
398
|
return this.userAction(actor, args as UserAction);
|
|
394
399
|
}
|
|
395
|
-
if (operation === "task"
|
|
396
|
-
|
|
397
|
-
const result = this.store.message(actor, args as MessageArgs);
|
|
398
|
-
|
|
400
|
+
if (operation === "task" || operation === "message") {
|
|
401
|
+
const last = this.store.state.messages.at(-1)?.seq ?? 0;
|
|
402
|
+
const result = operation === "task" ? this.store.task(actor, args as TaskArgs) : this.store.message(actor, args as MessageArgs);
|
|
403
|
+
for (const message of this.store.state.messages.filter(message => message.seq > last)) for (const id of message.recipients) this.notify(id);
|
|
399
404
|
return result;
|
|
400
405
|
}
|
|
401
406
|
return this.agent(actor, args as AgentArgs, authorizedLimit);
|
|
@@ -471,8 +476,14 @@ export class Coordinator {
|
|
|
471
476
|
const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined, handover: undefined } : { id, name: args.name, role: "member", parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
|
|
472
477
|
const resources = this.resources();
|
|
473
478
|
for (const warning of resources.warnings) this.options.warning(warning);
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
const assigned = args.task === undefined ? undefined : state.tasks[args.task];
|
|
480
|
+
if (args.task !== undefined && (!assigned || assigned.status !== "todo" || (assigned.owner && assigned.owner !== actor))) throw new Error("A launch task must be an existing to-do task that is unowned or yours");
|
|
481
|
+
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe or double-assign.
|
|
482
|
+
this.store.update(next => {
|
|
483
|
+
next.members[id] = member;
|
|
484
|
+
if (assigned) Object.assign(next.tasks[assigned.id], { owner: id, delegator: actor, revision: next.tasks[assigned.id].revision + 1 });
|
|
485
|
+
});
|
|
486
|
+
const brief = assigned ? `${args.brief}\n\nYou own team task ${taskName(assigned)} [${assigned.id}], delegated by ${memberName(parent)}. Start it, and finish with team_task complete and an outcome summary; that one call reports back.` : args.brief;
|
|
476
487
|
this.publishStatus();
|
|
477
488
|
member.model = args.model; member.thinking = args.thinking ?? "off";
|
|
478
489
|
member.terminal = this.options.interactive === true;
|
|
@@ -546,14 +557,19 @@ export class Coordinator {
|
|
|
546
557
|
if (rpcState.model.name !== undefined || rpcState.contextUsage !== undefined) this.recordEvent(id, { type: "telemetry", model: args.model, modelName: rpcState.model.name, contextUsage: rpcState.contextUsage ?? null });
|
|
547
558
|
this.store.update(next => { if (next.members[id].status === "starting") next.members[id].status = prompt ? "busy" : "idle"; });
|
|
548
559
|
this.publishStatus();
|
|
549
|
-
if (prompt) await agent.request("prompt", { message:
|
|
560
|
+
if (prompt) await agent.request("prompt", { message: brief, streamingBehavior: "followUp" });
|
|
550
561
|
return { ...this.publicMember(this.store.state.members[id]), resources };
|
|
551
562
|
} catch (error) {
|
|
552
563
|
clearTimeout(timer); this.ready.delete(id);
|
|
553
564
|
if (agent) await agent.stop();
|
|
554
565
|
// The exit handler may already own the container removal; wait for it so a failed launch leaves nothing running.
|
|
555
566
|
await this.releaseSandbox(id); await this.releasing.get(id);
|
|
556
|
-
this.store.update(next => {
|
|
567
|
+
this.store.update(next => {
|
|
568
|
+
next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed";
|
|
569
|
+
// A failed launch returns its untouched reservation.
|
|
570
|
+
const task = assigned && next.tasks[assigned.id];
|
|
571
|
+
if (task && task.owner === id && task.status === "todo") { delete task.owner; delete task.delegator; task.revision++; }
|
|
572
|
+
});
|
|
557
573
|
this.publishStatus();
|
|
558
574
|
throw error;
|
|
559
575
|
}
|
package/src/graph.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { render, type MermaidArt } from "grok-mermaid";
|
|
|
2
2
|
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { TaskView } from "./types.ts";
|
|
6
6
|
import { cleanText } from "./render.ts";
|
|
7
7
|
|
|
8
8
|
export function taskStatus(task: TaskView): "Done" | "Blocked" | "Working" | "Ready" {
|
|
@@ -16,11 +16,20 @@ const label = (text: string) => stripVTControlCharacters(text).replace(/[\x00-\x
|
|
|
16
16
|
.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
17
17
|
const cell = (art: MermaidArt, y: number, x: number) => sliceByColumn(art.plain[y] ?? "", x, 1);
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** Shorten a title to at most `budget` columns, preferring a word boundary, with an ellipsis when cut. */
|
|
20
|
+
export function shortenTitle(title: string, budget: number): string {
|
|
21
|
+
if (visibleWidth(title) <= budget) return title;
|
|
22
|
+
if (budget < 2) return "";
|
|
23
|
+
const cut = sliceByColumn(title, 0, budget - 1), space = cut.lastIndexOf(" ");
|
|
24
|
+
return (space >= cut.length * 0.6 ? cut.slice(0, space) : cut).trimEnd() + "…";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Each box shows `#n title · status`; zooming out gives the title fewer columns. Number and status always stay. */
|
|
28
|
+
export function buildTaskGraph(tasks: TaskView[], titleBudget = Infinity): TaskGraph | null {
|
|
20
29
|
if (!tasks.length || tasks.length > 100 || tasks.reduce((size, task) => size + task.dependencies.length, 0) > 300) return null;
|
|
21
30
|
const ids = new Set(tasks.map(task => task.id));
|
|
22
31
|
if (ids.size !== tasks.length || new Set(tasks.map(task => task.number)).size !== tasks.length || tasks.some(task => task.id.length > 128 || !Number.isSafeInteger(task.number) || task.number < 1 || task.dependencies.some(id => !ids.has(id)))) return null;
|
|
23
|
-
const source = ["flowchart TD", ...tasks.map((task, index) => `n${index}["○ ${label(cleanText(
|
|
32
|
+
const source = ["flowchart TD", ...tasks.map((task, index) => `n${index}["○ ${label(`#${task.number} ${shortenTitle(cleanText(task.title), titleBudget)}`.trimEnd())} · ${taskStatus(task)}"]`),
|
|
24
33
|
...tasks.flatMap((task, index) => task.dependencies.map(id => `n${tasks.findIndex(parent => parent.id === id)} --> n${index}`)),
|
|
25
34
|
].join("\n");
|
|
26
35
|
let art: MermaidArt | null;
|
|
@@ -43,7 +52,7 @@ export function buildTaskGraph(tasks: TaskView[]): TaskGraph | null {
|
|
|
43
52
|
const left = borders[i], right = borders[i + 1];
|
|
44
53
|
if (cell(rendered, y - 1, left) !== "┌" || cell(rendered, y - 1, right) !== "┐") continue;
|
|
45
54
|
const header = sliceByColumn(rendered.plain[y], left + 1, right - left - 1).trimStart();
|
|
46
|
-
const task = tasks.find(task => header.startsWith(`○ #${task.number} `));
|
|
55
|
+
const task = tasks.find(task => header.startsWith(`○ #${task.number} `) || header === `○ #${task.number}`);
|
|
47
56
|
if (!task || bounds.has(task.id)) continue;
|
|
48
57
|
for (let bottom = y + 1; bottom < rendered.plain.length; bottom++) {
|
|
49
58
|
if (cell(rendered, bottom, left) === "└" && cell(rendered, bottom, right) === "┘") {
|
package/src/group.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
4
|
+
|
|
5
|
+
type Row = {
|
|
6
|
+
message?: { customType?: string; details?: { messages?: { kind?: string }[] } };
|
|
7
|
+
toolName?: string; args?: { action?: unknown }; expanded?: boolean; _expanded?: boolean;
|
|
8
|
+
render(width: number): string[];
|
|
9
|
+
};
|
|
10
|
+
type Chat = { children: unknown[]; render(width: number): string[] };
|
|
11
|
+
const hook = Symbol.for("ac-teams.hook.notice-groups");
|
|
12
|
+
const widget = "ac-teams-notice-groups";
|
|
13
|
+
|
|
14
|
+
const isRow = (child: unknown): child is Row => typeof child === "object" && child !== null && typeof (child as Row).render === "function";
|
|
15
|
+
const isNotice = (row: Row) => row.message?.customType === "ac-teams";
|
|
16
|
+
const isTeam = (child: unknown): child is Row => isRow(child) && (isNotice(child) || /^team_(agent|task|message)$/.test(child.toolName ?? ""));
|
|
17
|
+
|
|
18
|
+
type Post = { from?: string; summary?: string; task?: string; kind?: string };
|
|
19
|
+
type Labels = Readonly<Record<string, string>>;
|
|
20
|
+
const quiet = new Set(["list", "read", "status", "history", "search", "limits"]);
|
|
21
|
+
const oneLine = (text: unknown) => String(text ?? "").split("\n")[0]!.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
22
|
+
|
|
23
|
+
/** What the folded rows did, in plain words: the header phrases, then one line per post. Read-only calls add nothing. */
|
|
24
|
+
/** Transcript state carried across blocks, top to bottom: which tasks are done, and whether the session yielded on a wait. */
|
|
25
|
+
type Progress = { done: Set<string>; waiting: boolean; self: string };
|
|
26
|
+
|
|
27
|
+
function describe(rows: Row[], labels: Labels, progress: Progress): { header: string; posts: string[] } {
|
|
28
|
+
const name = (id: unknown) => typeof id === "string" ? labels[id] ?? id : "?";
|
|
29
|
+
const short = (id: unknown) => name(id).split(" ")[0]!;
|
|
30
|
+
const phrases = new Map<string, string[]>();
|
|
31
|
+
const add = (verb: string, ...items: string[]) => phrases.set(verb, [...(phrases.get(verb) ?? []), ...items]);
|
|
32
|
+
const posts: string[] = [], watched = new Set<string>();
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const args = (row.args ?? {}) as Record<string, unknown>, action = String(args.action ?? "");
|
|
35
|
+
if (!row.toolName) {
|
|
36
|
+
for (const post of (row.message?.details?.messages ?? []) as Post[]) {
|
|
37
|
+
if (post.task) watched.add(post.task);
|
|
38
|
+
if (post.kind === "outcome" && post.task) progress.done.add(post.task);
|
|
39
|
+
// The first post after a wait is the one that started the next turn.
|
|
40
|
+
const woke = progress.waiting ? ` (woke ${progress.self})` : "";
|
|
41
|
+
progress.waiting = false;
|
|
42
|
+
posts.push(`${name(post.from)} ${post.kind === "outcome" ? `completed ${name(post.task)}` : `→ ${post.task ? name(post.task) : "you"}`}${woke}: ${oneLine(post.summary)}`);
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
progress.waiting = row.toolName === "team_message" && action === "wait";
|
|
47
|
+
if (Array.isArray(args.tasks) && action === "wait") for (const id of args.tasks) if (typeof id === "string") watched.add(id);
|
|
48
|
+
if (quiet.has(action)) continue;
|
|
49
|
+
else if (row.toolName === "team_agent" && action === "launch") add("launched", String(args.name ?? "a teammate"));
|
|
50
|
+
else if (row.toolName === "team_task" && action === "create") add("created", ...(Array.isArray(args.tasks) ? args.tasks : [args]).map(() => "task"));
|
|
51
|
+
else if (row.toolName === "team_message" && action === "wait") add("waiting on", ...(Array.isArray(args.tasks) ? args.tasks.map(short) : ["new posts"]));
|
|
52
|
+
else if (row.toolName === "team_message" && action === "send") add("messaged", ...(Array.isArray(args.recipients) ? args.recipients.map(name) : [args.task ? short(args.task) : "team"]));
|
|
53
|
+
else add(action, row.toolName === "team_task" ? short(args.id) : name(args.id));
|
|
54
|
+
}
|
|
55
|
+
const done = posts.filter(post => post.includes(" completed ")).length;
|
|
56
|
+
const parts = [...phrases].map(([verb, items]) => verb === "created" ? `created ${items.length} task${items.length === 1 ? "" : "s"}` : `${verb} ${[...new Set(items)].join(", ")}`);
|
|
57
|
+
if (posts.length) parts.push(done === posts.length ? `${done} completed` : `${posts.length} update${posts.length === 1 ? "" : "s"}`);
|
|
58
|
+
// One word for where things stand: every task this block touched is done, the session is yielded on a wait, or work continues.
|
|
59
|
+
const status = watched.size && [...watched].every(id => progress.done.has(id)) ? "done" : progress.waiting ? "waiting" : "working";
|
|
60
|
+
return { header: [status, ...parts].join(" · "), posts };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Folds each run of team tool rows and team notices into one block: a plain-words header, then one line per post.
|
|
65
|
+
* Rows that draw nothing (an empty assistant turn between tool calls) stay inside a run; any visible other row ends it.
|
|
66
|
+
* An expanded row shows the whole run natively.
|
|
67
|
+
*/
|
|
68
|
+
export function groupTeamRows(children: unknown[], width: number, style: (header: string, posts: string[], width: number, warnings: string[]) => string[], labels: Labels, self = "this session", ours: (line: string) => boolean = () => false): unknown[] {
|
|
69
|
+
const out: unknown[] = [];
|
|
70
|
+
const progress: Progress = { done: new Set(), waiting: false, self };
|
|
71
|
+
for (let i = 0; i < children.length;) {
|
|
72
|
+
if (!isTeam(children[i])) {
|
|
73
|
+
// The user's message also wakes a yielded session; footers and other rows do not.
|
|
74
|
+
if ((children[i] as object | null)?.constructor?.name === "UserMessageComponent") progress.waiting = false;
|
|
75
|
+
out.push(children[i]); i++; continue;
|
|
76
|
+
}
|
|
77
|
+
let end = i, last = i;
|
|
78
|
+
const team: Row[] = [], found: { index: number; text: string }[] = [];
|
|
79
|
+
while (end < children.length) {
|
|
80
|
+
const child = children[end];
|
|
81
|
+
if (isTeam(child)) { team.push(child); last = end; }
|
|
82
|
+
else {
|
|
83
|
+
const lines = isRow(child) ? child.render(width).map(line => stripVTControlCharacters(line).trim()).filter(Boolean) : undefined;
|
|
84
|
+
// Blank rows (spacers, an empty assistant turn) stay inside a run, and so do ac-teams' own warnings; anything else ends it.
|
|
85
|
+
if (!lines) break;
|
|
86
|
+
if (lines.length === 1 && ours(lines[0]!)) found.push({ index: end, text: lines[0]! });
|
|
87
|
+
else if (lines.length) break;
|
|
88
|
+
}
|
|
89
|
+
end++;
|
|
90
|
+
}
|
|
91
|
+
const warnings = [...new Set(found.filter(item => item.index < last).map(item => item.text))];
|
|
92
|
+
const members = children.slice(i, last + 1);
|
|
93
|
+
// Describe in transcript order now, so progress reflects every earlier block; rendering only reads the result.
|
|
94
|
+
const { header, posts } = describe(team, labels, progress);
|
|
95
|
+
if (team.length < 2) out.push(...members);
|
|
96
|
+
else {
|
|
97
|
+
out.push({ render(width: number) {
|
|
98
|
+
if (team.some(row => row.expanded || row._expanded)) return members.flatMap(row => (row as Row).render(width));
|
|
99
|
+
// Native rendering stays in charge of visibility, so /silent (which empties notice rows) still hides a notice-only block.
|
|
100
|
+
if (team.every(row => isNotice(row) && !row.render(width).length)) return [];
|
|
101
|
+
return ["", ...style(header, posts, width, warnings)];
|
|
102
|
+
}, invalidate() {} });
|
|
103
|
+
}
|
|
104
|
+
i = last + 1;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Pi has no API to merge transcript rows, so this wraps the chat container's render through an invisible widget; any unexpected shape leaves the transcript native. */
|
|
110
|
+
export function bindNoticeGroups(ctx: ExtensionContext, labels: () => Labels, self: () => string, ours: (line: string) => boolean) {
|
|
111
|
+
if (ctx.mode !== "tui") return;
|
|
112
|
+
ctx.ui.setWidget(widget, tui => {
|
|
113
|
+
// SAFETY: Pi's private layout (document, then chat last); the shape checks below reject anything else.
|
|
114
|
+
const chat = (tui as unknown as { children?: { children?: unknown[] }[] }).children?.[0]?.children?.at(-1) as Chat | undefined;
|
|
115
|
+
if (chat && Array.isArray(chat.children) && typeof chat.render === "function" && !Object.hasOwn(chat, hook)) {
|
|
116
|
+
const original = chat.render;
|
|
117
|
+
const theme = ctx.ui.theme;
|
|
118
|
+
const style = (header: string, posts: string[], width: number, warnings: string[]) => {
|
|
119
|
+
const lines = [theme.fg("accent", theme.bold("ac-teams")) + theme.fg("muted", ` · ${header}`) + theme.fg("dim", " (Ctrl+O for details)")];
|
|
120
|
+
for (const warning of warnings) lines.push(theme.fg("warning", ` ${warning}`));
|
|
121
|
+
for (const post of posts.slice(-5)) lines.push(theme.fg("text", ` ${post}`));
|
|
122
|
+
if (posts.length > 5) lines.splice(1, 0, theme.fg("dim", ` … ${posts.length - 5} earlier`));
|
|
123
|
+
return lines.map(line => truncateToWidth(line, width));
|
|
124
|
+
};
|
|
125
|
+
Object.defineProperty(chat, hook, { value: true });
|
|
126
|
+
chat.render = function (this: Chat, width: number) {
|
|
127
|
+
const children = this.children;
|
|
128
|
+
this.children = groupTeamRows(children, width, style, labels(), self(), ours);
|
|
129
|
+
try { return original.call(this, width); } finally { this.children = children; }
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { render: () => [], invalidate() {} };
|
|
133
|
+
}, { placement: "belowEditor" });
|
|
134
|
+
}
|