ac-teams 0.0.15 → 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/src/panel.ts CHANGED
@@ -2,17 +2,22 @@ import { basename } from "node:path";
2
2
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { Editor, indexedColor, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
4
  import { contextSummary } from "./telemetry.ts";
5
- import { buildTaskGraph, moveTaskSelection, taskStatus } from "./graph.ts";
5
+ import { buildTaskGraph, moveTaskSelection, taskStatus, type TaskGraph } from "./graph.ts";
6
6
  import { cleanText as clean } from "./render.ts";
7
7
  import type { PanelHost } from "./ui-types.ts";
8
- import { taskName, type TaskView } from "./types.ts";
8
+ import { taskName, type Message, type TaskView } from "./types.ts";
9
9
 
10
10
  export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Promise<void> {
11
11
  if (ctx.mode !== "tui") throw new Error("Teams panel requires interactive terminal mode");
12
12
  let snapshot = await host.snapshot();
13
13
  let tasks = snapshot.tasks;
14
14
  let events = snapshot.messages.slice().reverse();
15
- let taskGraph = buildTaskGraph(tasks);
15
+ // Zoom is the title width each box gets, from the longest title (100%) down to none (number and status only).
16
+ const longest = () => Math.max(1, ...tasks.map(task => visibleWidth(clean(task.title))));
17
+ let graphs = new Map<number, TaskGraph | null>();
18
+ const graphAt = (budget: number) => { if (!graphs.has(budget)) graphs.set(budget, buildTaskGraph(tasks, budget)); return graphs.get(budget)!; };
19
+ let zoom: number | "fit" = "fit", shownZoom = longest();
20
+ let taskGraph = graphAt(shownZoom);
16
21
  const drafts = host.drafts;
17
22
  const name = (id: string | undefined) => id === "root" ? "Main" : clean(snapshot.members.find(member => member.id === id)?.name ?? id ?? "Unassigned");
18
23
  const creator = (task: TaskView) => (task as TaskView & { creator?: string }).creator ?? "root";
@@ -26,7 +31,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
26
31
  let timer: ReturnType<typeof setTimeout> | undefined;
27
32
  let focused = true;
28
33
  let memberQuery = "", searching = false, hideStopped = false;
29
- const tabs = ["Tasks", "Teammates", "Activity"];
34
+ const tabs = ["Tasks", "Teammates", "Board"];
30
35
  const selectedTask = () => tasks[taskIndex];
31
36
  const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)]
32
37
  .filter(id => name(id).toLowerCase().includes(memberQuery.toLowerCase())
@@ -46,7 +51,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
46
51
  taskIndex = Math.max(0, tasks.findIndex(task => task.id === taskId));
47
52
  memberIndex = Math.max(0, memberIds().indexOf(memberId));
48
53
  eventIndex = Math.max(0, events.findIndex(event => event.id === eventId));
49
- if (changed) taskGraph = buildTaskGraph(tasks);
54
+ if (changed) { graphs = new Map(); taskGraph = graphAt(Math.min(shownZoom, longest())); }
50
55
  } catch (error) { if (!disposed) receipt = `Refresh failed: ${clean(String(error))}`; }
51
56
  finally { if (!disposed) { refresh(); timer = setTimeout(() => void poll(), 2000); } }
52
57
  }
@@ -115,23 +120,36 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
115
120
  const task = selectedTask();
116
121
  if (!task) return wrap([heading("No tasks yet"), "Request a task with N. Main creates and organizes it."], width);
117
122
  const unlocked = tasks.filter(other => other.dependencies.includes(task.id));
123
+ const outcome = task.outcome ? snapshot.messages.find(message => message.id === task.outcome) : undefined;
124
+ const [first = "", ...rest] = outcome ? clean(outcome.summary).split("\n") : [];
125
+ const prerequisites = task.dependencies.map(id => {
126
+ const parent = tasks.find(item => item.id === id);
127
+ return parent ? `${taskTitle(parent.id)} · ${stateLabel(parent)}` : `${clean(id)} · Unavailable`;
128
+ });
118
129
  return wrap([
119
- heading(clean(taskName(task))), label("Title", clean(task.title)), label("ID", task.id), "",
120
- label("State", stateLabel(task)), label("Owner", name(task.owner)), label("Creator", name(creator(task))), "",
121
- heading("Description"), clean(task.description), "",
122
- heading("Prerequisites"),
123
- ...(task.dependencies.length ? task.dependencies.map(id => {
124
- const parent = tasks.find(item => item.id === id);
125
- return parent ? `${clean(taskName(parent))} · ${taskStatus(parent)}` : `${clean(id)} · Unavailable`;
126
- }) : [fg("muted", "None; this task can start independently.")]), "",
127
- heading("Unlocks"), ...(unlocked.length ? unlocked.map(item => clean(taskName(item))) : [fg("muted", "No dependent tasks.")]),
128
- "",
129
- fg("accent", "C Give feedback to creator"), fg("accent", "O Open owner's full session"),
130
- ...(!detail ? [fg("muted", "Enter Full details")] : []),
130
+ heading(taskTitle(task.id)) + fg("muted", " · ") + stateLabel(task), "",
131
+ label("Owner", name(task.owner)), ...(task.delegator ? [label("Delegator", name(task.delegator))] : []),
132
+ label("Creator", name(creator(task))), "",
133
+ ...(outcome ? [section(`Outcome from ${name(outcome.from)}`, width), theme.bold(first), ...rest, ...(outcome.detail ? ["", fg("muted", clean(outcome.detail))] : []), ""] : []),
134
+ section("Description", width), clean(task.description), "",
135
+ section("Dependencies", width),
136
+ label("Needs", prerequisites.length ? prerequisites.join(", ") : fg("muted", "Nothing; can start now")),
137
+ label("Unlocks", unlocked.length ? unlocked.map(item => taskTitle(item.id)).join(", ") : fg("muted", "No other tasks")), "",
138
+ section("Actions", width), fg("accent", "C") + " Give feedback to creator", fg("accent", "O") + " Open owner's session", "",
139
+ fg("dim", `ID ${task.id}`),
131
140
  ], width);
132
141
  }
133
142
 
134
143
  function graphLines(width: number) {
144
+ if (zoom === "fit") {
145
+ // Largest title width that still fits the pane (binary search; graphs are cached per width).
146
+ const fits = (budget: number) => { const g = graphAt(budget); return !!g && g.art.width <= width && g.art.plain.length <= bodyHeight - 1; };
147
+ let low = 0, high = longest();
148
+ if (fits(high)) low = high;
149
+ else while (low < high - 1) { const mid = (low + high) >> 1; if (fits(mid)) low = mid; else high = mid; }
150
+ shownZoom = low;
151
+ } else shownZoom = zoom;
152
+ taskGraph = graphAt(shownZoom);
135
153
  if (!taskGraph) return undefined;
136
154
  const { art, bounds } = taskGraph;
137
155
  const selected = bounds.get(selectedTask()?.id ?? "");
@@ -150,10 +168,12 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
150
168
  if (active && y === box.top + 1) text = text.replace("○ ", "● ");
151
169
  const border = y === box.top || y === box.bottom;
152
170
  const content = sliceByColumn(text, 1, box.right - box.left - 1), status = taskStatus(task);
153
- const color = status === "Done" ? "dim" : "text";
171
+ const color = active ? "accent" : status === "Done" ? "dim" : "text";
154
172
  const index = y === box.bottom - 1 && content.trimEnd().endsWith(status) ? content.lastIndexOf(status) : -1;
155
173
  const interior = index < 0 ? fg(color, content) : fg(color, content.slice(0, index)) + stateLabel(task) + fg(color, content.slice(index + status.length));
156
- const styled = border ? statusText(task, text) : statusText(task, "│") + (active ? theme.bold(interior) : interior) + statusText(task, "│");
174
+ // The selected box keeps its status colour but gets a heavy border and bold text, so selection never competes with status.
175
+ const edge = (part: string) => statusText(task, active ? part.replace(/[┌┐└┘─│┬┴]/g, ch => ({ "┌": "┏", "┐": "┓", "└": "┗", "┘": "┛", "─": "━", "│": "┃", "┬": "┯", "┴": "┷" })[ch]!) : part);
176
+ const styled = border ? edge(text) : edge("│") + (active ? theme.bold(interior) : interior) + edge("│");
157
177
  row = sliceByColumn(row, 0, box.left) + styled + sliceByColumn(row, box.right + 1, art.width);
158
178
  }
159
179
  const indent = " ".repeat(Math.max(0, Math.floor((width - art.width) / 2)));
@@ -184,9 +204,9 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
184
204
  let selectedHeight = 1, graphWidth = 0;
185
205
  if (tab === 0) {
186
206
  const graphData = graph ? graphLines(leftWidth) : undefined;
187
- left = graphData ? graphData.rows : tasks.length ? [fg("muted", " Task / State"), "",
207
+ left = graphData ? graphData.rows : tasks.length ? [fg("muted", " Task"), "",
188
208
  ...tasks.map((task, index) => {
189
- const text = `${index === taskIndex ? "●" : " "} ${clean(taskName(task))}`;
209
+ const text = `${index === taskIndex ? "●" : " "} ${taskTitle(task.id)}`;
190
210
  const body = fg(taskStatus(task) === "Done" ? "dim" : "text", truncateToWidth(text, Math.max(1, leftWidth - 10)));
191
211
  return (index === taskIndex ? theme.bold(body) : body) + " · " + stateLabel(task);
192
212
  })] : [heading("No tasks yet"), "N Request a task from Main"];
@@ -211,8 +231,8 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
211
231
  const nameExtra = Math.min(spare, Math.max(0, ...ids.map(id => visibleWidth(name(id)) - nameWidth)));
212
232
  const gap = " ".repeat(4 + Math.floor((spare - nameExtra) / gaps));
213
233
  nameWidth += nameExtra + (spare - nameExtra) % gaps;
214
- left = [fg("muted", ` ${pad("Member", nameWidth)}${gap}${pad("Session", sessionWidth)}`
215
- + (showTasks ? `${gap}${pad("Tasks", tasksWidth)}` : "") + (showDirectory ? `${gap}Directory` : "")), ...ids.map((id, index) => {
234
+ left = [fg("muted", ` ${pad("Member", nameWidth)}${gap}${pad("State", sessionWidth)}`
235
+ + (showTasks ? `${gap}${pad("Done", tasksWidth)}` : "") + (showDirectory ? `${gap}Directory` : "")), ...ids.map((id, index) => {
216
236
  const member = snapshot.members.find(member => member.id === id);
217
237
  const selected = index === memberIndex;
218
238
  const stopped = member && ["stopped", "interrupted"].includes(member.status);
@@ -230,11 +250,19 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
230
250
  selectedRow = memberIndex + 1;
231
251
  right = split ? memberDetails(rightWidth) : [];
232
252
  } else {
233
- left = events.length ? [fg("muted", " Recent messages · newest first"), "", ...events.flatMap((event, index) => [
234
- `${index === eventIndex ? "●" : " "} ${name(event.from)} → ${event.recipients.map(name).join(", ") || "Team"}`,
235
- fg("muted", ` ${clean(event.time)} · ${clean(event.text).split("\n")[0]}`), "",
236
- ])] : [heading("No activity yet"), fg("muted", "Recent persistent team messages appear here.")];
237
- selectedRow = eventIndex * 3 + 2;
253
+ // One row per post: who, what kind, its summary headline, then task and time as quieter context.
254
+ const fromWidth = Math.min(16, Math.max(4, ...events.map(event => visibleWidth(name(event.from)))));
255
+ const taskWidth = Math.min(5, Math.max(4, ...events.map(event => event.task ? visibleWidth(taskRef(event.task)) : 0)));
256
+ const summaryWidth = Math.max(1, leftWidth - (2 + fromWidth + 2 + 7 + 2 + 2 + taskWidth + 2 + 5));
257
+ left = events.length ? [fg("muted", ` ${pad("From", fromWidth)} ${pad("Kind", 7)} ${pad("Summary", summaryWidth)} ${pad("Task", taskWidth)} Time`),
258
+ ...events.map((event, index) => {
259
+ const selected = index === eventIndex;
260
+ const from = pad(name(event.from), fromWidth), summary = pad(headline(event), summaryWidth);
261
+ return fg(selected ? "accent" : "dim", `${selected ? "●" : " "} `) + (selected ? theme.bold(fg("accent", from)) : from) + " "
262
+ + kindLabel(event) + " " + (selected ? theme.bold(summary) : summary) + " "
263
+ + fg("muted", pad(event.task ? taskRef(event.task) : "", taskWidth)) + " " + fg("dim", clock(event.time).slice(0, 5));
264
+ })] : [heading("No posts yet"), fg("muted", "Updates, questions, replies, and outcomes appear here.")];
265
+ selectedRow = eventIndex + 1;
238
266
  right = split ? eventDetails(rightWidth) : [];
239
267
  }
240
268
 
@@ -243,8 +271,12 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
243
271
  if (selectedRow + selectedHeight > scroll + viewHeight) scroll = selectedRow + selectedHeight - viewHeight;
244
272
  scroll = Math.max(0, Math.min(scroll, Math.max(0, left.length - viewHeight)));
245
273
  const viewport = left.slice(scroll, scroll + viewHeight);
246
- if (graphWidth) viewport.unshift(fg("muted", `↑ ${scroll} rows above · ↓ ${Math.max(0, left.length - scroll - viewHeight)} below`
247
- + (graphWidth > leftWidth ? ` · ← ${columnScroll} cols · ${Math.max(0, graphWidth - columnScroll - leftWidth)} →` : "")));
274
+ if (graphWidth) {
275
+ const hidden = scroll || left.length > scroll + viewHeight || graphWidth > leftWidth;
276
+ viewport.unshift(fg("muted", `Zoom ${Math.round(100 * shownZoom / longest())}%${zoom === "fit" ? " (fit)" : ""}`
277
+ + (hidden ? ` · ↑ ${scroll} rows · ↓ ${Math.max(0, left.length - scroll - viewHeight)} rows` : "")
278
+ + (graphWidth > leftWidth ? ` · ← ${columnScroll} cols · ${Math.max(0, graphWidth - columnScroll - leftWidth)} cols →` : "")));
279
+ }
248
280
  return Array.from({ length: bodyHeight }, (_, index) => {
249
281
  let row = viewport[index] ?? "";
250
282
  if (!graphWidth && index === 0 && scroll > 0) row = fg("muted", `↑ ${scroll} rows above`);
@@ -253,10 +285,39 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
253
285
  });
254
286
  }
255
287
 
288
+ /** Task number and full title; labels are too short to say what a post is about. */
289
+ function taskTitle(id: string) {
290
+ const task = tasks.find(item => item.id === id);
291
+ return task ? clean(`#${task.number} ${task.title}`) : clean(id);
292
+ }
293
+
294
+ const headline = (event: Message) => clean(event.summary).split("\n")[0];
295
+ const taskRef = (id: string) => { const task = tasks.find(item => item.id === id); return task ? `#${task.number}` : "#?"; };
296
+ const kindLabel = (event: Message) => event.kind === "outcome" ? fg("success", pad("Outcome", 7))
297
+ : event.replyTo ? fg("accent", pad("Reply", 7)) : fg("muted", pad("Post", 7));
298
+ // A labelled rule separates tiers more clearly than bold text alone.
299
+ const section = (title: string, width: number) => theme.bold(fg("accent", title)) + " " + fg("dim", "─".repeat(Math.max(0, width - visibleWidth(title) - 1)));
300
+ const post = (id: string | undefined) => snapshot.messages.find(message => message.id === id);
301
+ const clock = (time: string) => { const date = new Date(time); return isNaN(+date) ? clean(time) : date.toTimeString().slice(0, 8); };
302
+
303
+ // Tiers: headline (first summary line), the rest of the summary, then optional detail and the thread around it.
256
304
  function eventDetails(width: number) {
257
305
  const event = events[eventIndex];
258
- if (!event) return wrap([heading("No activity yet"), "Recent persistent team messages appear here."], width);
259
- return wrap([heading("Team message"), "", label("From", name(event.from)), label("To", event.recipients.map(name).join(", ") || "Team"), label("Time", clean(event.time)), "", clean(event.text)], width);
306
+ if (!event) return wrap([heading("No posts yet"), "Updates, questions, replies, and outcomes appear here."], width);
307
+ const [first = "", ...rest] = clean(event.summary).split("\n");
308
+ const parent = post(event.replyTo), task = event.task ? tasks.find(item => item.id === event.task) : undefined;
309
+ const replies = snapshot.messages.filter(message => message.replyTo === event.id);
310
+ const date = new Date(event.time);
311
+ return wrap([
312
+ kindLabel(event).trimEnd() + fg("muted", ` from ${name(event.from)}`),
313
+ label("To", event.recipients.map(name).join(", ") || "Team"),
314
+ ...(event.task ? [label("Task", `${taskTitle(event.task)}${task ? ` · ${stateLabel(task)}` : ""}`)] : []),
315
+ ...(parent ? [label("Reply to", `${name(parent.from)}: ${headline(parent)}`)] : []),
316
+ label("Time", isNaN(+date) ? clean(event.time) : date.toLocaleString()), "",
317
+ section("Summary", width), theme.bold(first), ...rest,
318
+ ...(event.detail ? ["", section("Detail", width), clean(event.detail)] : []),
319
+ ...(replies.length ? ["", section(`Replies (${replies.length})`, width), ...replies.map(reply => `${name(reply.from)}: ${headline(reply)}`)] : []),
320
+ ], width);
260
321
  }
261
322
 
262
323
  function memberDetails(width: number) {
@@ -265,20 +326,21 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
265
326
  if (!id) return wrap([heading("No matching teammates"), "Change the name filter or show stopped members."], width);
266
327
  const member = snapshot.members.find(member => member.id === id);
267
328
  const assigned = tasks.filter(task => task.owner === id);
268
- return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable")).replace(/^./, char => char.toUpperCase())),
329
+ const stopped = member && ["stopped", "interrupted"].includes(member.status);
330
+ return wrap([
331
+ heading(name(id)) + fg("muted", " · ") + (member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable")).replace(/^./, char => char.toUpperCase())), "",
269
332
  label("Model", clean(member?.modelName || member?.model?.slice(member.model.indexOf("/") + 1) || "Unknown")),
270
333
  label("Context", clean(contextSummary(member?.contextUsage).replace(/^context unknown/, "Unknown"))),
271
334
  label("Directory", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
272
- label("Tasks", taskProgress(id)), fg("muted", "Completed / currently assigned"),
273
- ...(member?.error ? [label("Error", clean(member.error))] : []), "", heading("Assigned tasks"),
274
- ...assigned.map(task => `${clean(taskName(task))} · ${taskStatus(task)}`),
275
- ...(assigned.length ? [] : [fg("muted", "No assigned tasks.")]), "",
276
- fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open session"),
277
- ...(!detail ? [fg("muted", "→ Details")] : []),
278
- ...(!host.child && member?.role === "member" ? [fg("accent", "P Promote to main session"), fg("muted", "Stops member; reopens transcript as a main session.")] : []),
279
- "", heading("Session details"), label("ID", id),
280
- label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
281
- label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None"),
335
+ ...(member?.error ? [label("Error", fg("error", clean(member.error)))] : []), "",
336
+ section(`Tasks (${assigned.filter(task => task.status === "done").length} of ${assigned.length} done)`, width),
337
+ ...(assigned.length ? assigned.map(task => `${taskTitle(task.id)} · ${stateLabel(task)}`) : [fg("muted", "No assigned tasks.")]), "",
338
+ section("Actions", width), fg("accent", "Enter") + (stopped ? " Resume and open session" : " Open session"),
339
+ ...(!host.child && member?.role === "member" ? [fg("accent", "P") + " Promote to main session " + fg("muted", "(stops the member, reopens its transcript as a main session)")] : []), "",
340
+ section("Session", width),
341
+ fg("muted", label("ID", id)),
342
+ fg("muted", label("File", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable"))),
343
+ fg("muted", label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None")),
282
344
  ], width);
283
345
  }
284
346
 
@@ -297,7 +359,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
297
359
  : compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
298
360
  : detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back"
299
361
  : tab === 1 ? "↑↓ Scroll · Enter Open session · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
300
- : tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List · C Feedback · N New task · Tab Next · Esc Close`
362
+ : tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List${graph ? " · +/- Zoom · F Fit" : ""} · C Feedback · N New task · Tab Next · Esc Close`
301
363
  : tab === 1 ? `${filters} · ↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close`
302
364
  : "↑↓ Select · Enter Details · O Open related session · Tab Next · Esc Close";
303
365
  const teammates = snapshot.members.filter(member => member.id !== "root");
@@ -307,7 +369,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
307
369
  const summaryText = tab === 0
308
370
  ? `${tasks.length} tasks · ${["Working", "Ready", "Blocked", "Done"].map(state => `${tasks.filter(task => taskStatus(task) === state).length} ${state.toLowerCase()}`).join(" · ")}`
309
371
  : tab === 1 ? `${teammates.length} teammate${teammates.length === 1 ? "" : "s"} · ${["working", "idle", "stopped", "needs input"].map(state => `${memberStates.filter(value => value === state).length} ${state}`).join(" · ")}`
310
- : `${events.length} message${events.length === 1 ? "" : "s"} · newest first`;
372
+ : `${events.length} post${events.length === 1 ? "" : "s"} · ${events.filter(event => event.kind === "outcome").length} outcomes · ${events.filter(event => event.replyTo).length} replies`;
311
373
  const summary = wrapTextWithAnsi(fg("muted", summaryText), inner);
312
374
  const receipts = receipt ? wrapTextWithAnsi(fg("muted", receipt), inner) : [];
313
375
  const hints = wrapTextWithAnsi(fg("dim", help), inner);
@@ -360,6 +422,13 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
360
422
  else if (tab === 1) memberIndex = Math.max(0, Math.min(memberIds().length - 1, memberIndex + delta));
361
423
  else eventIndex = Math.max(0, Math.min(events.length - 1, eventIndex + delta));
362
424
  } else if (data.toLowerCase() === "g" && tab === 0 && !detail) { graph = !graph; scroll = 0; }
425
+ else if (tab === 0 && graph && !detail && ["+", "=", "-"].includes(data)) {
426
+ // Steps of 1.25x title width, like a smooth zoom; always at least one column so each press shows a change.
427
+ const at = zoom === "fit" ? shownZoom : zoom;
428
+ zoom = data === "-" ? Math.max(0, Math.min(at - 1, Math.floor(at / 1.25)))
429
+ : Math.min(longest(), Math.max(at + 1, Math.ceil(at * 1.25)));
430
+ scroll = 0; columnScroll = 0;
431
+ } else if (tab === 0 && graph && !detail && data.toLowerCase() === "f") { zoom = "fit"; scroll = 0; columnScroll = 0; }
363
432
  else if (data.toLowerCase() === "n") begin("new");
364
433
  else if (data.toLowerCase() === "c" && tab === 0) begin("feedback");
365
434
  else if (data.toLowerCase() === "p" && tab === 1 && !host.child) {
package/src/render.ts CHANGED
@@ -62,7 +62,7 @@ function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string
62
62
  const value = data as Record<string, unknown>;
63
63
  if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme, labels);
64
64
  if (Array.isArray(value.tasks)) return preview(value.tasks, value.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme, labels);
65
- const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
65
+ const label = value.title ?? value.name ?? value.brief ?? value.summary ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
66
66
  const status = value.status === undefined ? "" : inline(value.status);
67
67
  const color = status === "done" ? "success"
68
68
  : status === "interrupted" ? "error"
@@ -75,7 +75,7 @@ function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string
75
75
  }
76
76
  return parts.filter(Boolean).join(" ");
77
77
  }
78
- return data === undefined || data === null ? "" : inline(data);
78
+ return data === undefined || data === null ? "" : memberLabel(data, labels);
79
79
  }
80
80
 
81
81
  export function teamToolRenderers(name: string, getLabels: MemberLabels = defaultLabels): ToolRenderers {
@@ -97,12 +97,12 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
97
97
  try { data = JSON.parse(text); } catch { data = text; }
98
98
  }
99
99
  const labels = getLabels();
100
- const outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme, labels) || "done";
100
+ const waiting = action === "wait" && Array.isArray(data) && data.length === 0;
101
+ const outcome = context.isError ? `error · ${inline(text)}` : waiting ? "turn ended; the next post wakes this session" : toolSummary(data, theme, labels) || "done";
101
102
  const request = toolSummary(fields, theme, labels);
102
103
  const heading = `${theme.fg(context.isError ? "error" : "success", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → ${outcome}${request && !outcome.includes(request) ? ` · ${request}` : ""}`;
103
- const requestJSON = cleanText(JSON.stringify(context.args, null, 2));
104
104
  const responseJSON = cleanText(result.details === undefined ? text : JSON.stringify(result.details, null, 2));
105
- const hiddenLines = requestJSON.split("\n").length + responseJSON.split("\n").length + 2;
105
+ const hiddenLines = responseJSON.split("\n").length;
106
106
  const hint = theme.fg("muted", options.expanded ? " (" : ` … +${hiddenLines} lines (`)
107
107
  + keyHint("app.tools.expand", options.expanded ? "to collapse" : "to expand") + theme.fg("muted", ")");
108
108
  const row = new Container();
@@ -117,9 +117,6 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
117
117
  },
118
118
  });
119
119
  if (options.expanded) {
120
- row.addChild(new Text(theme.fg("muted", "Request"), context.outputPad, 0));
121
- row.addChild(new Text(requestJSON, context.outputPad, 0));
122
- row.addChild(new Text(theme.fg("muted", "Response"), context.outputPad, 0));
123
120
  row.addChild(new Text(responseJSON, context.outputPad, 0));
124
121
  }
125
122
  return row;
@@ -129,12 +126,15 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
129
126
 
130
127
  export function teamMessageRenderer(getLabels: MemberLabels = defaultLabels): MessageRenderer {
131
128
  return (message, { expanded }, theme) => {
132
- const details = message.details as { through?: number; messages?: { from: string; text: string; recipients: string[] }[] } | undefined;
133
- const recent = details?.messages?.at(-1);
129
+ const details = message.details as { through?: number; messages?: { from: string; summary: string; recipients: string[]; task?: string; kind?: string }[] } | undefined;
130
+ const posts = details?.messages ?? [], recent = posts.at(-1);
131
+ const more = posts.length > 1 ? ` (+${posts.length - 1} more)` : "";
134
132
  const labels = getLabels();
135
133
  const name = (id: string) => memberLabel(id, labels);
136
- const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
137
- return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
134
+ // Lead with what happened: "count-a completed #1 Count lines · 37 lines", or "count-a → Main · #1 … · question".
135
+ const what = recent && (recent.kind === "outcome" && recent.task ? `${name(recent.from)} completed ${name(recent.task)}` : `${name(recent.from)} → ${recent.recipients.map(name).join(", ")}${recent.task ? ` · ${name(recent.task)}` : ""}`);
136
+ const preview = recent ? `${what} · ${recent.summary.split("\n")[0]}${more}` : `Updates through ${details?.through ?? "unknown"}`;
137
+ return entry("ac-teams", details ?? { content: message.content }, expanded, theme, false, preview);
138
138
  };
139
139
  }
140
140
 
package/src/schemas.ts CHANGED
@@ -13,6 +13,7 @@ export const AgentSchema = Type.Object({
13
13
  worktree: Type.Optional(Type.Boolean()), base: Type.Optional(id), target: Type.Optional(id),
14
14
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
15
15
  depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 10 })),
16
+ task: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Launch only: an existing to-do task the new member owns, with you as its delegator. Its activity reaches you; include its goal in the brief." })),
16
17
  access: Type.Optional(Type.Object({
17
18
  read: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
18
19
  write: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
@@ -32,12 +33,19 @@ export const TaskSchema = Type.Object({
32
33
  offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
33
34
  action: Type.Union(["create", "list", "read", "edit", "claim", "start", "release", "complete", "reopen"].map(v => Type.Literal(v))),
34
35
  id: Type.Optional(id), ...taskFields,
36
+ summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Complete: the outcome, published atomically with done: what was done, how it was checked, and caveats. Start with the result itself (e.g. 37 lines); the first line is the one people see. Complete enough that the delegator can act without reading detail." })),
37
+ detail: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Complete: optional supporting detail, read on demand." })),
35
38
  tasks: Type.Optional(Type.Array(Type.Object({ ...taskFields, title: Type.String({ minLength: 1, maxLength: 500 }) }, { additionalProperties: false }), { minItems: 1, maxItems: 100, description: "Create several tasks atomically. Use instead of top-level task fields. Dependencies must refer to existing task IDs, not items in this batch." })),
36
39
  }, { additionalProperties: false });
37
40
  export const MessageSchema = Type.Object({
38
- action: Type.Union(["send", "history", "search"].map(v => Type.Literal(v))),
39
- recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs. Each must resolve to a different live member. Omit to notify all other live members." })),
40
- text: Type.Optional(text), query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
41
+ action: Type.Union(["send", "history", "search", "wait"].map(v => Type.Literal(v))),
42
+ tasks: Type.Optional(Type.Array(id, { maxItems: 100, uniqueItems: true, description: "Wait: also wake on posts linked to these tasks, e.g. ones you delegated." })),
43
+ recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs." })),
44
+ summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Send: delivered directly to recipients. Start with the point (the first line is the one people see) and what it changes; complete enough to act on." })),
45
+ detail: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Send: optional supporting detail, read only when needed." })),
46
+ task: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Send: link an existing task; its owner and delegator receive the post." })),
47
+ replyTo: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Send: the post ID this answers; its author receives the reply and it stays on that post's task." })),
48
+ query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
41
49
  after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
42
50
  }, { additionalProperties: false });
43
51
  const UserActionSchema = Type.Object({
@@ -67,6 +75,7 @@ export function validate(operation: string, value: unknown): void {
67
75
  const args = value as { action: string; name?: string };
68
76
  if (args.action === "launch" && !args.name) throw new Error("A descriptive teammate name is required on launch");
69
77
  if (args.action !== "launch" && args.name !== undefined) throw new Error("A teammate name can only be set on launch");
78
+ if (args.action !== "launch" && (value as { task?: unknown }).task !== undefined) throw new Error("A task can only be assigned on launch");
70
79
  if (args.action !== "launch" && (value as { access?: unknown }).access !== undefined) throw new Error("Access can only be set on launch; resume keeps the member's access");
71
80
  }
72
81
  }
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { stateRoot } from "./config.ts";
5
5
  import { validatePolicy } from "./egress.ts";
6
6
  import { stripVTControlCharacters } from "node:util";
7
- import { isDescendant, isLive, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
8
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
9
9
 
10
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -58,7 +58,15 @@ function taskLabel(title: string): string {
58
58
  }
59
59
 
60
60
  function migrate(state: unknown): boolean {
61
- if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
61
+ if (!object(state) || ![1, 2, 3, 4].includes(state.version as number)) return false;
62
+ if (state.version !== 4) migrateRoles(state);
63
+ // Schema 5: a post's former text becomes its summary, unchanged.
64
+ requireValue(Array.isArray(state.messages), "Invalid legacy messages");
65
+ for (const message of state.messages) if (object(message) && Object.hasOwn(message, "text")) { message.summary = message.text; delete message.text; }
66
+ state.version = 5;
67
+ return true;
68
+ }
69
+ function migrateRoles(state: Record<string, unknown>): void {
62
70
  if (state.version !== 3) migrateTasks(state);
63
71
  // Schema 4: roles replace the special root ID; the creator's record becomes a main session.
64
72
  const root = state.root ?? "root";
@@ -67,7 +75,6 @@ function migrate(state: unknown): boolean {
67
75
  delete state.root;
68
76
  state.key = randomUUID();
69
77
  state.version = 4;
70
- return true;
71
78
  }
72
79
  function migrateTasks(state: Record<string, unknown>): void {
73
80
  requireValue(object(state.tasks), "Invalid legacy state tasks");
@@ -89,7 +96,7 @@ function migrateTasks(state: Record<string, unknown>): void {
89
96
  function validate(state: unknown): asserts state is TeamState {
90
97
  requireValue(object(state), "Invalid state snapshot");
91
98
  id(state.id);
92
- requireValue(state.version === 4 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
99
+ requireValue(state.version === 5 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
93
100
  const members = state.members;
94
101
  const names = new Set<string>(["main", "root", ...Object.keys(members)]);
95
102
  for (const [key, member] of Object.entries(members)) {
@@ -108,6 +115,7 @@ function validate(state: unknown): asserts state is TeamState {
108
115
  requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
109
116
  requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
110
117
  requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
118
+ requireValue(member.waiting === undefined || (object(member.waiting) && Array.isArray(member.waiting.tasks) && member.waiting.tasks.every(task => typeof task === "string" && Object.hasOwn(state.tasks as object, task))), "Invalid member wait");
111
119
  requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
112
120
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
113
121
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
@@ -132,7 +140,8 @@ function validate(state: unknown): asserts state is TeamState {
132
140
  requireValue(task.completedAt === undefined || integer(task.completedAt), "Invalid task completion time");
133
141
  requireValue(task.dependencies.every(dep => typeof dep === "string" && dep !== key && Object.hasOwn(tasks, dep)) && new Set(task.dependencies).size === task.dependencies.length, "Invalid task dependencies");
134
142
  requireValue(task.owner === undefined || (typeof task.owner === "string" && Object.hasOwn(members, task.owner)), "Invalid task owner");
135
- requireValue(task.creator === undefined || (typeof task.creator === "string" && Object.hasOwn(members, task.creator)), "Invalid task creator");
143
+ requireValue([task.creator, task.delegator].every(member => member === undefined || (typeof member === "string" && Object.hasOwn(members, member))), "Invalid task creator or delegator");
144
+ requireValue(task.outcome === undefined || (task.status === "done" && state.messages.some(message => object(message) && message.id === task.outcome && message.task === key)), "Invalid task outcome");
136
145
  requireValue(task.status !== "in_progress" || task.owner !== undefined, "In-progress task requires an owner");
137
146
  requireValue(task.status === "todo" || task.dependencies.every(dep => (tasks[dep] as Record<string, unknown>).status === "done"), "Active or done task has unfinished dependencies");
138
147
  edges[key] = task.dependencies;
@@ -150,11 +159,44 @@ function validate(state: unknown): asserts state is TeamState {
150
159
  let last = 0;
151
160
  const messageIds = new Set<string>();
152
161
  for (const message of state.messages) {
153
- requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.text, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
162
+ requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.summary, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
163
+ requireValue((message.detail === undefined || text(message.detail)) && (message.task === undefined || (typeof message.task === "string" && Object.hasOwn(tasks, message.task))) && (message.replyTo === undefined || (typeof message.replyTo === "string" && messageIds.has(message.replyTo))) && (message.kind === undefined || (message.kind === "outcome" && message.task !== undefined)), "Invalid message detail or link");
154
164
  last = message.seq; messageIds.add(message.id);
155
165
  }
156
166
  }
157
167
 
168
+ /**
169
+ * Appends a post. Attention goes to explicit recipients, the linked task's owner and delegator (else creator),
170
+ * and the replied-to post's author; never the sender, and never an implicit broadcast.
171
+ */
172
+ function post(state: TeamState, actor: string, args: Pick<MessageArgs, "recipients" | "summary" | "detail" | "task" | "replyTo">, user = false): Message {
173
+ // A user's request is authored in a session but addressed to that session's own model.
174
+ const limit = user ? maxMessageText : 16000;
175
+ requireValue(text(args.summary, limit), `A post needs a nonempty summary of at most ${limit} characters`);
176
+ requireValue(args.detail === undefined || text(args.detail), "Detail must be nonempty and at most 16000 characters");
177
+ const original = args.replyTo === undefined ? undefined : state.messages.find(message => message.id === args.replyTo);
178
+ requireValue(args.replyTo === undefined || original, "Unknown post to reply to");
179
+ requireValue(!args.task || !original?.task || args.task === original.task, "A reply stays on the replied-to post's task");
180
+ const taskId = args.task ?? original?.task;
181
+ const task = taskId === undefined ? undefined : state.tasks[taskId];
182
+ requireValue(taskId === undefined || task, "Unknown task");
183
+ requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique known member names or IDs");
184
+ const members = Object.values(state.members);
185
+ // Names resolve to durable IDs; a stopped member still receives its post on resume.
186
+ const explicit = (args.recipients ?? []).map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
187
+ requireValue(new Set(explicit).size === explicit.length && explicit.every((recipient): recipient is string => recipient !== undefined), "Recipients must be unique known member names or IDs");
188
+ requireValue(explicit.length > 0 || task || original, "Name recipients, a task, or a post to reply to; posts are never broadcast");
189
+ const waiters = taskId === undefined ? [] : Object.values(state.members).filter(member => member.waiting?.tasks.includes(taskId)).map(member => member.id);
190
+ const routed = [...explicit, task?.owner, task?.delegator ?? task?.creator, original?.from, ...waiters];
191
+ const recipients = [...new Set(routed.filter((id): id is string => !!id && (id !== actor || (user && explicit.includes(id)))))];
192
+ const message: Message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients, summary: args.summary, time: new Date().toISOString() };
193
+ if (args.detail !== undefined) message.detail = args.detail;
194
+ if (taskId !== undefined) message.task = taskId;
195
+ if (original) message.replyTo = original.id;
196
+ state.messages.push(message);
197
+ return message;
198
+ }
199
+
158
200
  // Check every component rather than following a symlink into another home.
159
201
  function ensureHome(path: string): void {
160
202
  const parent = dirname(path);
@@ -183,6 +225,11 @@ export function readState(dir: string): TeamState {
183
225
  } finally { closeSync(fd); }
184
226
  }
185
227
 
228
+ /** Display names for members and tasks; their IDs are distinct UUIDs, so one map names both. */
229
+ export function stateLabels(state: TeamState): Record<string, string> {
230
+ return Object.fromEntries([...Object.values(state.members).map(member => [member.id, memberName(member)]), ...Object.values(state.tasks).map(task => [task.id, `#${task.number} ${task.title}`])]);
231
+ }
232
+
186
233
  /** Cache only display names, not histories; atomic snapshot replacements invalidate the cache. */
187
234
  export function createMemberLabels(): (dir: string) => Record<string, string> {
188
235
  let key: string | undefined, labels: Record<string, string>;
@@ -190,7 +237,7 @@ export function createMemberLabels(): (dir: string) => Record<string, string> {
190
237
  const file = join(dir, "state.json"), stat = lstatSync(file, { bigint: true });
191
238
  const next = `${file}:${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}:${stat.mode}:${stat.uid}:${stat.nlink}`;
192
239
  if (next !== key) {
193
- labels = Object.fromEntries(Object.values(readState(dir).members).map(member => [member.id, memberName(member)]));
240
+ labels = stateLabels(readState(dir));
194
241
  key = next;
195
242
  }
196
243
  return labels;
@@ -325,6 +372,12 @@ export class Store {
325
372
  }
326
373
  requireValue(args.id && Object.hasOwn(state.tasks, args.id), "Unknown task");
327
374
  const task = state.tasks[args.id];
375
+ if (args.action === "complete" && task.outcome) {
376
+ // A retried completion returns the original; a different outcome is a conflict, not a second post.
377
+ const published = state.messages.find(message => message.id === task.outcome)!;
378
+ requireValue(published.from === actor && published.summary === args.summary && published.detail === args.detail, "Task is already done with a different outcome; reopen it before completing again");
379
+ return taskView(state, task);
380
+ }
328
381
  requireValue(args.revision === undefined || (integer(args.revision) && args.revision === task.revision), "Stale or invalid task revision; read the task before retrying");
329
382
  if (args.action === "edit") {
330
383
  requireValue(task.status === "todo", "Only to-do tasks can be edited");
@@ -348,6 +401,10 @@ export class Store {
348
401
  requireValue(task.owner === actor, "Only the owner can complete this task");
349
402
  requireValue(task.status === "in_progress", "Only in-progress tasks can be completed; start it first");
350
403
  requireValue(args.acceptance === true, "Completion requires acceptance=true after checking acceptance criteria");
404
+ requireValue(text(args.summary), "Completion requires an outcome summary: what was done, how it was checked, and any caveats");
405
+ const outcome = post(state, actor, { summary: args.summary, detail: args.detail, task: task.id });
406
+ outcome.kind = "outcome";
407
+ task.outcome = outcome.id;
351
408
  task.status = "done";
352
409
  task.completedAt = Date.now();
353
410
  } else if (args.action === "release") {
@@ -362,7 +419,7 @@ export class Store {
362
419
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
363
420
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
364
421
  task.status = "todo";
365
- delete task.completedAt;
422
+ delete task.completedAt; delete task.outcome;
366
423
  } else throw new Error("Unknown task action");
367
424
  task.revision++;
368
425
  return taskView(state, task);
@@ -371,17 +428,16 @@ export class Store {
371
428
 
372
429
  message(actor: string, args: MessageArgs, enveloped = false): Message | Message[] {
373
430
  requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
374
- if (args.action === "send") return this.update(state => {
375
- const limit = enveloped ? maxMessageText : 16000;
376
- requireValue(text(args.text, limit), `Message text must be nonempty and at most ${limit} characters`);
377
- const members = Object.values(state.members);
378
- requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique live member names or IDs");
379
- const recipients = args.recipients === undefined ? members.filter(member => member.id !== actor && isLive(member)).map(member => member.id)
380
- : args.recipients.map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
381
- requireValue((args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every((recipient): recipient is string => recipient !== undefined && isLive(state.members[recipient])), "Recipients must be unique live member names or IDs");
382
- const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
383
- state.messages.push(message);
384
- return structuredClone(message);
431
+ if (args.action === "send") return this.update(state => structuredClone(post(state, actor, args, enveloped)));
432
+ if (args.action === "wait") return this.update(state => {
433
+ const tasks = args.tasks ?? [];
434
+ requireValue(Array.isArray(tasks) && tasks.length <= 100 && tasks.every(task => Object.hasOwn(state.tasks, task)), "Wait tasks must be existing task IDs");
435
+ // Register and recheck in one mutation, so a post committed after the caller last looked still wakes it.
436
+ const after = args.after ?? state.members[actor].notified;
437
+ const ready = state.messages.filter(message => message.seq > after && (message.recipients.includes(actor) || (message.task !== undefined && tasks.includes(message.task))));
438
+ if (ready.length) delete state.members[actor].waiting;
439
+ else state.members[actor].waiting = { tasks: [...new Set(tasks)] };
440
+ return structuredClone(ready);
385
441
  });
386
442
  requireValue(args.action === "history" || args.action === "search", "Unknown message action");
387
443
  const after = args.after ?? 0, limit = args.limit ?? 20;
@@ -389,7 +445,7 @@ export class Store {
389
445
  if (args.action === "search") requireValue(text(args.query, 500), "Search requires a nonempty query (max 500)");
390
446
  const results = [];
391
447
  for (const message of this.state.messages) {
392
- if (message.seq > after && (args.action !== "search" || message.text.includes(args.query!))) results.push(message);
448
+ if (message.seq > after && (args.action !== "search" || (message.summary.includes(args.query!) || !!message.detail?.includes(args.query!)))) results.push(message);
393
449
  if (results.length === limit) break;
394
450
  }
395
451
  return structuredClone(results);