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