ac-teams 0.0.15 → 0.0.17

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 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 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.
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
 
@@ -127,6 +127,8 @@ State schema 3 adds stable task numbers and short labels. Opening a schema 1 or
127
127
 
128
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.
129
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
+
130
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.
131
133
 
132
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` | Recipients select who receives notification, not who can read |
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 Activity; Esc closes the panel.
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
- - **Activity:** Shows the latest 100 persistent team messages rather than every tool update. Use `team_message history` to read older messages.
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 request and response JSON; mouse-enabled Pi views also support clicking the row.
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 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.
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 include its ID in the member's brief. Track independently owned outcomes, not every agent, tool call, or procedural step. For example:
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
- Put the returned task UUID in the brief in place of `<task-id>`. Example `team_agent` arguments:
104
+ Pass the returned task UUID as `task`. Example `team_agent` arguments:
105
105
 
106
106
  ```json
107
- {"action":"launch","name":"parser-review","brief":"Task <task-id>: claim and start it, then review src/parser.ts. Read only; do not delegate or edit. Send findings to root through team_message. Complete the task after checking its acceptance criteria, or report blockers."}
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
- These are prompt guidelines, not launch requirements. ac-teams neither creates tasks automatically nor blocks launches without tasks.
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`. Ownership remains. |
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
- ## Send messages instead of polling
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","recipients":["root"],"text":"Parser review complete. No edits; two malformed-input cases need tests."}
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. Omit `recipients` to notify all other live members.
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
- 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.
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`; results remain cursor-paged. Reads never consume messages. Task and message result pages are limited to 100 items.
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
- New messages queue a compact Pi follow-up: an idle member wakes, while a busy member finishes its current work first. Notifications batch for 50 ms and reference history rather than copying every message into context. Notification acknowledgement means queued, not read or acted on.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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: 4, id: options.id, key: randomUUID(), limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
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], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
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
  }
@@ -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 through = pending.at(-1)?.seq;
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, pending.slice(-5));
380
- else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
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") return this.store.task(actor, args as TaskArgs);
396
- if (operation === "message") {
397
- const result = this.store.message(actor, args as MessageArgs);
398
- if ((args as MessageArgs).action === "send") for (const id of Object.keys(this.store.state.members)) this.notify(id);
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
- // Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
475
- this.store.update(next => { next.members[id] = member; });
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: args.brief, streamingBehavior: "followUp" });
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 => { next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
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 { taskName, type TaskView } from "./types.ts";
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
17
17
  const cell = (art: MermaidArt, y: number, x: number) => sliceByColumn(art.plain[y] ?? "", x, 1);
18
18
 
19
- export function buildTaskGraph(tasks: TaskView[]): TaskGraph | null {
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(taskName(task)))} · ${taskStatus(task)}"]`),
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
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { bindNoticeGroups } from "./group.ts";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { AgentSchema, TaskSchema, MessageSchema, validate } from "./schemas.ts";
@@ -13,7 +14,7 @@ import { randomUUID } from "node:crypto";
13
14
  import { execFile } from "node:child_process";
14
15
  import { existsSync, readdirSync } from "node:fs";
15
16
  import { promisify } from "node:util";
16
- import { createMemberLabels, readState, teamsHome } from "./store.ts";
17
+ import { createMemberLabels, readState, stateLabels, teamsHome } from "./store.ts";
17
18
  import { teamEntryType, type TeamEntry } from "./mains.ts";
18
19
  import { isMain, memberName, taskName, type TeamState } from "./types.ts";
19
20
  import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
@@ -51,14 +52,31 @@ export default function teams(pi: ExtensionAPI) {
51
52
  const cachedLabels = createMemberLabels();
52
53
  function memberLabels(): Record<string, string> {
53
54
  try {
54
- if (coordinator) return Object.fromEntries(Object.values(coordinator.store.state.members).map(member => [member.id, memberName(member)]));
55
+ if (coordinator) return stateLabels(coordinator.store.state);
55
56
  return cachedLabels(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!)));
56
57
  } catch { return { root: "Main" }; } // A transcript can render before its team is available.
57
58
  }
58
59
  pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
59
- const warn = (message: string) => context?.ui.notify(message, "warning");
60
+ /** Warnings this extension showed, so a folded team block can absorb its own (Pi prints them as "Warning: ..."). */
61
+ const ownWarnings = new Set<string>();
62
+ const warn = (message: string) => { ownWarnings.add(`Warning: ${message}`); context?.ui.notify(message, "warning"); };
63
+ /** Highest notice handed to Pi, and highest found persisted in this session and acknowledged. */
64
+ let sent = 0, acked = 0;
60
65
  const notify = (through: number, messages: Message[] = []) => {
61
- 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 });
66
+ if (through <= sent) return;
67
+ // Unacknowledged posts are offered again; show each one once.
68
+ messages = messages.filter(message => message.seq > sent);
69
+ sent = through;
70
+ const labels = memberLabels(), name = (id: string) => labels[id] ?? id;
71
+ const lines = messages.map(message => `[${message.seq}] ${name(message.from)}${message.kind === "outcome" ? " completed" : ""}${message.task ? ` ${name(message.task)} [${message.task}]` : ""}: ${message.summary}${message.detail ? " (detail via team_message history)" : ""}`);
72
+ pi.sendMessage({ customType: "ac-teams", content: `Team posts:\n${lines.join("\n")}\nAct only if one needs you. Fetch detail only to settle a question about your own work.`, display: true, details: { through, messages } }, { deliverAs: "steer", triggerTurn: true });
73
+ };
74
+ /** Acknowledges notices only once they are durable session entries, so a crash before persistence redelivers them. */
75
+ const reconcile = (ctx: ExtensionContext) => {
76
+ const through = ctx.sessionManager.getEntries().reduce((max, entry) => entry.type === "custom_message" && entry.customType === "ac-teams" ? Math.max(max, Number((entry.details as { through?: number } | undefined)?.through) || 0) : max, 0);
77
+ sent = Math.max(sent, through);
78
+ if (through <= acked || (!connection && !coordinator)) return;
79
+ acked = through;
62
80
  if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
63
81
  else coordinator?.ack(coordinator.self, through);
64
82
  };
@@ -273,15 +291,16 @@ export default function teams(pi: ExtensionAPI) {
273
291
  }
274
292
  });
275
293
  pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
276
- pi.on("agent_settled", (_event, ctx) => { inputCount = 0; report(ctx, { type: "agent_settled" }); });
294
+ pi.on("turn_end", (_event, ctx) => reconcile(ctx));
295
+ pi.on("agent_settled", (_event, ctx) => { inputCount = 0; reconcile(ctx); report(ctx, { type: "agent_settled" }); });
277
296
  pi.on("tool_execution_start", (event, ctx) => report(ctx, { type: "activity", text: `Running ${event.toolName}`.slice(0, 500) }));
278
297
  pi.on("message_end", (event, ctx) => {
279
298
  const change = assistantEvent(event.message);
280
299
  if (change) report(ctx, change);
281
300
  });
282
301
  function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
283
- const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Choose concurrency using resource readings and observed progress, not low headroom alone. If contention slows progress, stop unneeded idle members and resume their saved sessions later. 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.";
284
- 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.";
302
+ const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Choose concurrency using resource readings and observed progress, not low headroom alone. If contention slows progress, stop unneeded idle members and resume their saved sessions later. Use teammate names for messaging and stable IDs for member control and tasks; never acknowledge notifications. Limits cover only ac-teams, not other launchers or shell commands.";
303
+ const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and pass it as the launch task; its owner's completion outcome reports back to you. 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.";
285
304
  pi.registerTool({
286
305
  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.",
287
306
  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."],
@@ -310,12 +329,15 @@ export default function teams(pi: ExtensionAPI) {
310
329
  },
311
330
  });
312
331
  pi.registerTool({
313
- 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.",
314
- 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."],
332
+ name: "team_message", label: "Team message", description: "Post to the team board, read history or search, or wait. A post has a required one-line summary that stands alone, optional detail, and links an existing task or replies to a post. It reaches named recipients (teammate names or IDs) plus the linked task's owner and delegator; it is never broadcast. Recipients receive summaries at their next safe point. Wait yields this turn until a post reaches you or lands on the given tasks.",
333
+ promptGuidelines: ["Post only what changes someone's work: an outcome, a blocker, a question, or new context. Do not acknowledge notices or narrate progress.", "Notices carry summaries; read history only for the detail a question about your own work needs.", "After delegating, do not repeat the delegated work. Do distinct work, or call team_message wait with the delegated task IDs as your last action; never poll history."],
315
334
  parameters: MessageSchema,
316
335
  ...teamToolRenderers("team_message", memberLabels),
317
336
  async execute(_id, args, _signal, _update, ctx) {
318
- const result = await handle("message", args, ctx);
337
+ // Posts already shown in a notice are not news to this wait.
338
+ const result = await handle("message", args.action === "wait" && args.after === undefined ? { ...args, after: sent } : args, ctx);
339
+ // Nothing ready: end the turn without another model request; the next matching notice starts a new one.
340
+ if (args.action === "wait") return { ...output(result), terminate: (result as Message[]).length === 0 };
319
341
  if (args.action === "send") {
320
342
  const { seq, id, recipients } = result as Message;
321
343
  return output({ seq, id, recipients });
@@ -393,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
393
415
  pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
394
416
  `Joined team ${state.id} as ${name(team!.member)}, a main session.`,
395
417
  open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
396
- recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
418
+ recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.summary.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
397
419
  ].join("\n\n") }, { triggerTurn: false });
398
420
  }
399
421
  /** /teams leave: hands this session's work back and drops its main-session role. */
@@ -410,7 +432,7 @@ export default function teams(pi: ExtensionAPI) {
410
432
  }
411
433
  pi.on("session_start", async (_event, ctx) => {
412
434
  if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
413
- context = ctx; exiting = false; inputCount = 0;
435
+ context = ctx; exiting = false; inputCount = 0; sent = acked = 0;
414
436
  if (child) {
415
437
  if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
416
438
  const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
@@ -428,6 +450,9 @@ export default function teams(pi: ExtensionAPI) {
428
450
  if (data?.left) disable("This session left its team; use /teams join to join one");
429
451
  else if (team) await rejoin(ctx);
430
452
  }
453
+ // Notices persisted before a crash or reconnect count as delivered; acknowledge them so they are not shown twice.
454
+ reconcile(ctx);
455
+ bindNoticeGroups(ctx, memberLabels, () => memberLabels()[memberId()] ?? "this session", line => ownWarnings.has(line));
431
456
  startTelemetry(ctx);
432
457
  if (!warned) {
433
458
  warned = true;