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 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
 
@@ -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 over the last ten seconds. Missing or invalid metrics are `null` with a warning, not zero or an all-clear.
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). Any nonzero ten-second memory-stall average also warns. Occupied swap alone does not trigger a pressure warning.
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
- Sequence launches and recheck `list` after startup before adding more. 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.
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` | 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.14",
3
+ "version": "0.0.16",
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
  }
@@ -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. Sequence launches and recheck team_agent list after startup.`);
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 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
+ }