ac-teams 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/operations.md +11 -3
- package/docs/usage.md +21 -21
- package/package.json +1 -1
- package/src/coordinator.ts +32 -16
- package/src/graph.ts +13 -4
- package/src/group.ts +134 -0
- package/src/index.ts +37 -12
- package/src/panel.ts +114 -45
- package/src/render.ts +12 -12
- package/src/resources.ts +9 -9
- package/src/schemas.ts +12 -3
- package/src/store.ts +76 -20
- package/src/types.ts +27 -4
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bindNoticeGroups } from "./group.ts";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { AgentSchema, TaskSchema, MessageSchema, validate } from "./schemas.ts";
|
|
@@ -13,7 +14,7 @@ import { randomUUID } from "node:crypto";
|
|
|
13
14
|
import { execFile } from "node:child_process";
|
|
14
15
|
import { existsSync, readdirSync } from "node:fs";
|
|
15
16
|
import { promisify } from "node:util";
|
|
16
|
-
import { createMemberLabels, readState, teamsHome } from "./store.ts";
|
|
17
|
+
import { createMemberLabels, readState, stateLabels, teamsHome } from "./store.ts";
|
|
17
18
|
import { teamEntryType, type TeamEntry } from "./mains.ts";
|
|
18
19
|
import { isMain, memberName, taskName, type TeamState } from "./types.ts";
|
|
19
20
|
import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
|
|
@@ -51,14 +52,31 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
51
52
|
const cachedLabels = createMemberLabels();
|
|
52
53
|
function memberLabels(): Record<string, string> {
|
|
53
54
|
try {
|
|
54
|
-
if (coordinator) return
|
|
55
|
+
if (coordinator) return stateLabels(coordinator.store.state);
|
|
55
56
|
return cachedLabels(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!)));
|
|
56
57
|
} catch { return { root: "Main" }; } // A transcript can render before its team is available.
|
|
57
58
|
}
|
|
58
59
|
pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
|
|
59
|
-
|
|
60
|
+
/** Warnings this extension showed, so a folded team block can absorb its own (Pi prints them as "Warning: ..."). */
|
|
61
|
+
const ownWarnings = new Set<string>();
|
|
62
|
+
const warn = (message: string) => { ownWarnings.add(`Warning: ${message}`); context?.ui.notify(message, "warning"); };
|
|
63
|
+
/** Highest notice handed to Pi, and highest found persisted in this session and acknowledged. */
|
|
64
|
+
let sent = 0, acked = 0;
|
|
60
65
|
const notify = (through: number, messages: Message[] = []) => {
|
|
61
|
-
|
|
66
|
+
if (through <= sent) return;
|
|
67
|
+
// Unacknowledged posts are offered again; show each one once.
|
|
68
|
+
messages = messages.filter(message => message.seq > sent);
|
|
69
|
+
sent = through;
|
|
70
|
+
const labels = memberLabels(), name = (id: string) => labels[id] ?? id;
|
|
71
|
+
const lines = messages.map(message => `[${message.seq}] ${name(message.from)}${message.kind === "outcome" ? " completed" : ""}${message.task ? ` ${name(message.task)} [${message.task}]` : ""}: ${message.summary}${message.detail ? " (detail via team_message history)" : ""}`);
|
|
72
|
+
pi.sendMessage({ customType: "ac-teams", content: `Team posts:\n${lines.join("\n")}\nAct only if one needs you. Fetch detail only to settle a question about your own work.`, display: true, details: { through, messages } }, { deliverAs: "steer", triggerTurn: true });
|
|
73
|
+
};
|
|
74
|
+
/** Acknowledges notices only once they are durable session entries, so a crash before persistence redelivers them. */
|
|
75
|
+
const reconcile = (ctx: ExtensionContext) => {
|
|
76
|
+
const through = ctx.sessionManager.getEntries().reduce((max, entry) => entry.type === "custom_message" && entry.customType === "ac-teams" ? Math.max(max, Number((entry.details as { through?: number } | undefined)?.through) || 0) : max, 0);
|
|
77
|
+
sent = Math.max(sent, through);
|
|
78
|
+
if (through <= acked || (!connection && !coordinator)) return;
|
|
79
|
+
acked = through;
|
|
62
80
|
if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
|
|
63
81
|
else coordinator?.ack(coordinator.self, through);
|
|
64
82
|
};
|
|
@@ -273,15 +291,16 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
273
291
|
}
|
|
274
292
|
});
|
|
275
293
|
pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
|
|
276
|
-
pi.on("
|
|
294
|
+
pi.on("turn_end", (_event, ctx) => reconcile(ctx));
|
|
295
|
+
pi.on("agent_settled", (_event, ctx) => { inputCount = 0; reconcile(ctx); report(ctx, { type: "agent_settled" }); });
|
|
277
296
|
pi.on("tool_execution_start", (event, ctx) => report(ctx, { type: "activity", text: `Running ${event.toolName}`.slice(0, 500) }));
|
|
278
297
|
pi.on("message_end", (event, ctx) => {
|
|
279
298
|
const change = assistantEvent(event.message);
|
|
280
299
|
if (change) report(ctx, change);
|
|
281
300
|
});
|
|
282
301
|
function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
|
|
283
|
-
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee.
|
|
284
|
-
const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and
|
|
302
|
+
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Choose concurrency using resource readings and observed progress, not low headroom alone. If contention slows progress, stop unneeded idle members and resume their saved sessions later. Use teammate names for messaging and stable IDs for member control and tasks; never acknowledge notifications. Limits cover only ac-teams, not other launchers or shell commands.";
|
|
303
|
+
const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and pass it as the launch task; its owner's completion outcome reports back to you. Use one shared task list for your own work and delegated work; do not create one task per teammate by default. Quick lookups and brief second opinions need no separate task unless their results need coordination or follow-up.";
|
|
285
304
|
pi.registerTool({
|
|
286
305
|
name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees. Create or reuse team_task entries for delegated deliverables; quick lookups need no task unless results need coordination or follow-up.",
|
|
287
306
|
promptGuidelines: [guidance, taskTrackingGuidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
|
|
@@ -310,12 +329,15 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
310
329
|
},
|
|
311
330
|
});
|
|
312
331
|
pi.registerTool({
|
|
313
|
-
name: "team_message", label: "Team message", description: "
|
|
314
|
-
promptGuidelines: ["
|
|
332
|
+
name: "team_message", label: "Team message", description: "Post to the team board, read history or search, or wait. A post has a required one-line summary that stands alone, optional detail, and links an existing task or replies to a post. It reaches named recipients (teammate names or IDs) plus the linked task's owner and delegator; it is never broadcast. Recipients receive summaries at their next safe point. Wait yields this turn until a post reaches you or lands on the given tasks.",
|
|
333
|
+
promptGuidelines: ["Post only what changes someone's work: an outcome, a blocker, a question, or new context. Do not acknowledge notices or narrate progress.", "Notices carry summaries; read history only for the detail a question about your own work needs.", "After delegating, do not repeat the delegated work. Do distinct work, or call team_message wait with the delegated task IDs as your last action; never poll history."],
|
|
315
334
|
parameters: MessageSchema,
|
|
316
335
|
...teamToolRenderers("team_message", memberLabels),
|
|
317
336
|
async execute(_id, args, _signal, _update, ctx) {
|
|
318
|
-
|
|
337
|
+
// Posts already shown in a notice are not news to this wait.
|
|
338
|
+
const result = await handle("message", args.action === "wait" && args.after === undefined ? { ...args, after: sent } : args, ctx);
|
|
339
|
+
// Nothing ready: end the turn without another model request; the next matching notice starts a new one.
|
|
340
|
+
if (args.action === "wait") return { ...output(result), terminate: (result as Message[]).length === 0 };
|
|
319
341
|
if (args.action === "send") {
|
|
320
342
|
const { seq, id, recipients } = result as Message;
|
|
321
343
|
return output({ seq, id, recipients });
|
|
@@ -393,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
393
415
|
pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
|
|
394
416
|
`Joined team ${state.id} as ${name(team!.member)}, a main session.`,
|
|
395
417
|
open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
|
|
396
|
-
recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.
|
|
418
|
+
recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.summary.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
|
|
397
419
|
].join("\n\n") }, { triggerTurn: false });
|
|
398
420
|
}
|
|
399
421
|
/** /teams leave: hands this session's work back and drops its main-session role. */
|
|
@@ -410,7 +432,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
410
432
|
}
|
|
411
433
|
pi.on("session_start", async (_event, ctx) => {
|
|
412
434
|
if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
|
|
413
|
-
context = ctx; exiting = false; inputCount = 0;
|
|
435
|
+
context = ctx; exiting = false; inputCount = 0; sent = acked = 0;
|
|
414
436
|
if (child) {
|
|
415
437
|
if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
|
|
416
438
|
const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
|
|
@@ -428,6 +450,9 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
428
450
|
if (data?.left) disable("This session left its team; use /teams join to join one");
|
|
429
451
|
else if (team) await rejoin(ctx);
|
|
430
452
|
}
|
|
453
|
+
// Notices persisted before a crash or reconnect count as delivered; acknowledge them so they are not shown twice.
|
|
454
|
+
reconcile(ctx);
|
|
455
|
+
bindNoticeGroups(ctx, memberLabels, () => memberLabels()[memberId()] ?? "this session", line => ownWarnings.has(line));
|
|
431
456
|
startTelemetry(ctx);
|
|
432
457
|
if (!warned) {
|
|
433
458
|
warned = true;
|
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
|
-
|
|
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", "
|
|
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 =
|
|
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(
|
|
120
|
-
label("
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
207
|
+
left = graphData ? graphData.rows : tasks.length ? [fg("muted", " Task"), "",
|
|
188
208
|
...tasks.map((task, index) => {
|
|
189
|
-
const text = `${index === taskIndex ? "●" : " "} ${
|
|
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("
|
|
215
|
-
+ (showTasks ? `${gap}${pad("
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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)
|
|
247
|
-
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
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("
|
|
273
|
-
|
|
274
|
-
...assigned.map(task => `${
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"",
|
|
280
|
-
label("
|
|
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}
|
|
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 ? "" :
|
|
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
|
|
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 =
|
|
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;
|
|
133
|
-
const
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
/**
|
|
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%.
|
|
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}
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
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
|
}
|