ac-teams 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { bindNoticeGroups } from "./group.ts";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { AgentSchema, TaskSchema, MessageSchema, validate } from "./schemas.ts";
@@ -13,7 +14,7 @@ import { randomUUID } from "node:crypto";
13
14
  import { execFile } from "node:child_process";
14
15
  import { existsSync, readdirSync } from "node:fs";
15
16
  import { promisify } from "node:util";
16
- import { createMemberLabels, readState, teamsHome } from "./store.ts";
17
+ import { createMemberLabels, readState, stateLabels, teamsHome } from "./store.ts";
17
18
  import { teamEntryType, type TeamEntry } from "./mains.ts";
18
19
  import { isMain, memberName, taskName, type TeamState } from "./types.ts";
19
20
  import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
@@ -51,14 +52,31 @@ export default function teams(pi: ExtensionAPI) {
51
52
  const cachedLabels = createMemberLabels();
52
53
  function memberLabels(): Record<string, string> {
53
54
  try {
54
- if (coordinator) return Object.fromEntries(Object.values(coordinator.store.state.members).map(member => [member.id, memberName(member)]));
55
+ if (coordinator) return stateLabels(coordinator.store.state);
55
56
  return cachedLabels(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!)));
56
57
  } catch { return { root: "Main" }; } // A transcript can render before its team is available.
57
58
  }
58
59
  pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
59
- const warn = (message: string) => context?.ui.notify(message, "warning");
60
+ /** Warnings this extension showed, so a folded team block can absorb its own (Pi prints them as "Warning: ..."). */
61
+ const ownWarnings = new Set<string>();
62
+ const warn = (message: string) => { ownWarnings.add(`Warning: ${message}`); context?.ui.notify(message, "warning"); };
63
+ /** Highest notice handed to Pi, and highest found persisted in this session and acknowledged. */
64
+ let sent = 0, acked = 0;
60
65
  const notify = (through: number, messages: Message[] = []) => {
61
- pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
66
+ if (through <= sent) return;
67
+ // Unacknowledged posts are offered again; show each one once.
68
+ messages = messages.filter(message => message.seq > sent);
69
+ sent = through;
70
+ const labels = memberLabels(), name = (id: string) => labels[id] ?? id;
71
+ const lines = messages.map(message => `[${message.seq}] ${name(message.from)}${message.kind === "outcome" ? " completed" : ""}${message.task ? ` ${name(message.task)} [${message.task}]` : ""}: ${message.summary}${message.detail ? " (detail via team_message history)" : ""}`);
72
+ pi.sendMessage({ customType: "ac-teams", content: `Team posts:\n${lines.join("\n")}\nAct only if one needs you. Fetch detail only to settle a question about your own work.`, display: true, details: { through, messages } }, { deliverAs: "steer", triggerTurn: true });
73
+ };
74
+ /** Acknowledges notices only once they are durable session entries, so a crash before persistence redelivers them. */
75
+ const reconcile = (ctx: ExtensionContext) => {
76
+ const through = ctx.sessionManager.getEntries().reduce((max, entry) => entry.type === "custom_message" && entry.customType === "ac-teams" ? Math.max(max, Number((entry.details as { through?: number } | undefined)?.through) || 0) : max, 0);
77
+ sent = Math.max(sent, through);
78
+ if (through <= acked || (!connection && !coordinator)) return;
79
+ acked = through;
62
80
  if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
63
81
  else coordinator?.ack(coordinator.self, through);
64
82
  };
@@ -273,15 +291,16 @@ export default function teams(pi: ExtensionAPI) {
273
291
  }
274
292
  });
275
293
  pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
276
- pi.on("agent_settled", (_event, ctx) => { inputCount = 0; report(ctx, { type: "agent_settled" }); });
294
+ pi.on("turn_end", (_event, ctx) => reconcile(ctx));
295
+ pi.on("agent_settled", (_event, ctx) => { inputCount = 0; reconcile(ctx); report(ctx, { type: "agent_settled" }); });
277
296
  pi.on("tool_execution_start", (event, ctx) => report(ctx, { type: "activity", text: `Running ${event.toolName}`.slice(0, 500) }));
278
297
  pi.on("message_end", (event, ctx) => {
279
298
  const change = assistantEvent(event.message);
280
299
  if (change) report(ctx, change);
281
300
  });
282
301
  function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
283
- const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Sequence launches while members are starting or memory is pressured; stop unneeded idle members and resume their saved sessions later. Use teammate names for messaging and stable IDs for member control and tasks; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
284
- const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and include its ID in the member's brief. Use one shared task list for your own work and delegated work; do not create one task per teammate by default. Quick lookups and brief second opinions need no separate task unless their results need coordination or follow-up.";
302
+ const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Choose concurrency using resource readings and observed progress, not low headroom alone. If contention slows progress, stop unneeded idle members and resume their saved sessions later. Use teammate names for messaging and stable IDs for member control and tasks; never acknowledge notifications. Limits cover only ac-teams, not other launchers or shell commands.";
303
+ const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and pass it as the launch task; its owner's completion outcome reports back to you. Use one shared task list for your own work and delegated work; do not create one task per teammate by default. Quick lookups and brief second opinions need no separate task unless their results need coordination or follow-up.";
285
304
  pi.registerTool({
286
305
  name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees. Create or reuse team_task entries for delegated deliverables; quick lookups need no task unless results need coordination or follow-up.",
287
306
  promptGuidelines: [guidance, taskTrackingGuidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
@@ -310,12 +329,15 @@ export default function teams(pi: ExtensionAPI) {
310
329
  },
311
330
  });
312
331
  pi.registerTool({
313
- name: "team_message", label: "Team message", description: "Send a persistent direct/team message to teammate names or IDs, or read cursor-paged history. Send returns a receipt with resolved IDs; history and search return full messages. New messages queue a compact follow-up notification for each recipient.",
314
- promptGuidelines: ["Do not poll team_message history while waiting for replies; notifications trigger a follow-up turn. On notification, read team_message history after your last read cursor and advance it through the returned messages. An empty result means no newer messages, not that a teammate has finished."],
332
+ name: "team_message", label: "Team message", description: "Post to the team board, read history or search, or wait. A post has a required one-line summary that stands alone, optional detail, and links an existing task or replies to a post. It reaches named recipients (teammate names or IDs) plus the linked task's owner and delegator; it is never broadcast. Recipients receive summaries at their next safe point. Wait yields this turn until a post reaches you or lands on the given tasks.",
333
+ promptGuidelines: ["Post only what changes someone's work: an outcome, a blocker, a question, or new context. Do not acknowledge notices or narrate progress.", "Notices carry summaries; read history only for the detail a question about your own work needs.", "After delegating, do not repeat the delegated work. Do distinct work, or call team_message wait with the delegated task IDs as your last action; never poll history."],
315
334
  parameters: MessageSchema,
316
335
  ...teamToolRenderers("team_message", memberLabels),
317
336
  async execute(_id, args, _signal, _update, ctx) {
318
- const result = await handle("message", args, ctx);
337
+ // Posts already shown in a notice are not news to this wait.
338
+ const result = await handle("message", args.action === "wait" && args.after === undefined ? { ...args, after: sent } : args, ctx);
339
+ // Nothing ready: end the turn without another model request; the next matching notice starts a new one.
340
+ if (args.action === "wait") return { ...output(result), terminate: (result as Message[]).length === 0 };
319
341
  if (args.action === "send") {
320
342
  const { seq, id, recipients } = result as Message;
321
343
  return output({ seq, id, recipients });
@@ -393,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
393
415
  pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
394
416
  `Joined team ${state.id} as ${name(team!.member)}, a main session.`,
395
417
  open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
396
- recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
418
+ recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.summary.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
397
419
  ].join("\n\n") }, { triggerTurn: false });
398
420
  }
399
421
  /** /teams leave: hands this session's work back and drops its main-session role. */
@@ -410,7 +432,7 @@ export default function teams(pi: ExtensionAPI) {
410
432
  }
411
433
  pi.on("session_start", async (_event, ctx) => {
412
434
  if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
413
- context = ctx; exiting = false; inputCount = 0;
435
+ context = ctx; exiting = false; inputCount = 0; sent = acked = 0;
414
436
  if (child) {
415
437
  if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
416
438
  const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
@@ -428,6 +450,9 @@ export default function teams(pi: ExtensionAPI) {
428
450
  if (data?.left) disable("This session left its team; use /teams join to join one");
429
451
  else if (team) await rejoin(ctx);
430
452
  }
453
+ // Notices persisted before a crash or reconnect count as delivered; acknowledge them so they are not shown twice.
454
+ reconcile(ctx);
455
+ bindNoticeGroups(ctx, memberLabels, () => memberLabels()[memberId()] ?? "this session", line => ownWarnings.has(line));
431
456
  startTelemetry(ctx);
432
457
  if (!warned) {
433
458
  warned = true;
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/resources.ts CHANGED
@@ -3,8 +3,8 @@ import { readFileSync } from "node:fs";
3
3
  export interface HostResources {
4
4
  scope: "host";
5
5
  memory: { totalBytes: number; availableBytes: number; swapUsedBytes: number } | null;
6
- /** Percentage of wall time stalled on memory over the last ten seconds (Linux PSI). */
7
- memoryPressure: { someAvg10: number; fullAvg10: number } | null;
6
+ /** Linux PSI percentages: partial ten-second pressure and full ten-/sixty-second pressure. */
7
+ memoryPressure: { someAvg10: number; fullAvg10: number; fullAvg60: number } | null;
8
8
  warnings: string[];
9
9
  }
10
10
 
@@ -22,19 +22,19 @@ export function hostResources(read: (path: string) => string = path => readFileS
22
22
  const swapTotal = bytes("SwapTotal"), swapFree = bytes("SwapFree");
23
23
  if ([totalBytes, availableBytes, swapTotal, swapFree].every(Number.isSafeInteger) && totalBytes > 0 && availableBytes <= totalBytes && swapFree <= swapTotal) {
24
24
  result.memory = { totalBytes, availableBytes, swapUsedBytes: swapTotal - swapFree };
25
- if (availableBytes < totalBytes * 0.1) result.warnings.push("Host available memory is below 10%. Sequence work; stop unneeded idle members and resume their saved sessions later.");
25
+ if (availableBytes < totalBytes * 0.1) result.warnings.push("Host available memory is below 10%. This is low headroom, not a per-member capacity estimate.");
26
26
  }
27
27
  } catch { /* Missing or unreadable metrics remain explicitly unavailable. */ }
28
28
  try {
29
29
  const text = read("/proc/pressure/memory");
30
- const avg = (kind: string) => {
31
- const match = text.match(new RegExp(`^${kind} avg10=(\\d+(?:\\.\\d+)?) `, "m"));
30
+ const avg = (kind: string, window: 10 | 60) => {
31
+ const match = text.match(new RegExp(`^${kind} .*?\\bavg${window}=(\\d+(?:\\.\\d+)?) `, "m"));
32
32
  return match ? Number(match[1]) : NaN;
33
33
  };
34
- const someAvg10 = avg("some"), fullAvg10 = avg("full");
35
- if ([someAvg10, fullAvg10].every(value => Number.isFinite(value) && value >= 0 && value <= 100) && fullAvg10 <= someAvg10) {
36
- result.memoryPressure = { someAvg10, fullAvg10 };
37
- if (someAvg10 > 0) result.warnings.push(`Host tasks stalled on memory for ${someAvg10}% of the last ten seconds. Prefer sequential work over more teammates.`);
34
+ const someAvg10 = avg("some", 10), fullAvg10 = avg("full", 10), fullAvg60 = avg("full", 60);
35
+ if ([someAvg10, fullAvg10, fullAvg60].every(value => Number.isFinite(value) && value >= 0 && value <= 100) && fullAvg10 <= someAvg10) {
36
+ result.memoryPressure = { someAvg10, fullAvg10, fullAvg60 };
37
+ if (fullAvg60 >= 5 || fullAvg10 >= 10) result.warnings.push(`Significant memory contention detected (full avg10: ${fullAvg10}%, full avg60: ${fullAvg60}%). Consider reducing concurrency if progress is slowing.`);
38
38
  }
39
39
  } catch { /* PSI is optional on older kernels. */ }
40
40
  }
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
  }