ac-teams 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/operations.md +2 -0
- package/docs/usage.md +21 -21
- package/package.json +1 -1
- package/src/coordinator.ts +31 -15
- package/src/graph.ts +13 -4
- package/src/group.ts +134 -0
- package/src/index.ts +37 -12
- package/src/panel.ts +114 -45
- package/src/render.ts +12 -12
- package/src/schemas.ts +12 -3
- package/src/store.ts +76 -20
- package/src/types.ts +27 -4
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/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
|
}
|
package/src/store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { stateRoot } from "./config.ts";
|
|
5
5
|
import { validatePolicy } from "./egress.ts";
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
|
-
import { isDescendant,
|
|
7
|
+
import { isDescendant, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
|
|
8
8
|
import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
// UI envelopes add task context after the public 16,000-character input limit.
|
|
@@ -58,7 +58,15 @@ function taskLabel(title: string): string {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function migrate(state: unknown): boolean {
|
|
61
|
-
if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
|
|
61
|
+
if (!object(state) || ![1, 2, 3, 4].includes(state.version as number)) return false;
|
|
62
|
+
if (state.version !== 4) migrateRoles(state);
|
|
63
|
+
// Schema 5: a post's former text becomes its summary, unchanged.
|
|
64
|
+
requireValue(Array.isArray(state.messages), "Invalid legacy messages");
|
|
65
|
+
for (const message of state.messages) if (object(message) && Object.hasOwn(message, "text")) { message.summary = message.text; delete message.text; }
|
|
66
|
+
state.version = 5;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
function migrateRoles(state: Record<string, unknown>): void {
|
|
62
70
|
if (state.version !== 3) migrateTasks(state);
|
|
63
71
|
// Schema 4: roles replace the special root ID; the creator's record becomes a main session.
|
|
64
72
|
const root = state.root ?? "root";
|
|
@@ -67,7 +75,6 @@ function migrate(state: unknown): boolean {
|
|
|
67
75
|
delete state.root;
|
|
68
76
|
state.key = randomUUID();
|
|
69
77
|
state.version = 4;
|
|
70
|
-
return true;
|
|
71
78
|
}
|
|
72
79
|
function migrateTasks(state: Record<string, unknown>): void {
|
|
73
80
|
requireValue(object(state.tasks), "Invalid legacy state tasks");
|
|
@@ -89,7 +96,7 @@ function migrateTasks(state: Record<string, unknown>): void {
|
|
|
89
96
|
function validate(state: unknown): asserts state is TeamState {
|
|
90
97
|
requireValue(object(state), "Invalid state snapshot");
|
|
91
98
|
id(state.id);
|
|
92
|
-
requireValue(state.version ===
|
|
99
|
+
requireValue(state.version === 5 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
|
|
93
100
|
const members = state.members;
|
|
94
101
|
const names = new Set<string>(["main", "root", ...Object.keys(members)]);
|
|
95
102
|
for (const [key, member] of Object.entries(members)) {
|
|
@@ -108,6 +115,7 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
108
115
|
requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
|
|
109
116
|
requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
|
|
110
117
|
requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
|
|
118
|
+
requireValue(member.waiting === undefined || (object(member.waiting) && Array.isArray(member.waiting.tasks) && member.waiting.tasks.every(task => typeof task === "string" && Object.hasOwn(state.tasks as object, task))), "Invalid member wait");
|
|
111
119
|
requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
|
|
112
120
|
requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
|
|
113
121
|
requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
|
|
@@ -132,7 +140,8 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
132
140
|
requireValue(task.completedAt === undefined || integer(task.completedAt), "Invalid task completion time");
|
|
133
141
|
requireValue(task.dependencies.every(dep => typeof dep === "string" && dep !== key && Object.hasOwn(tasks, dep)) && new Set(task.dependencies).size === task.dependencies.length, "Invalid task dependencies");
|
|
134
142
|
requireValue(task.owner === undefined || (typeof task.owner === "string" && Object.hasOwn(members, task.owner)), "Invalid task owner");
|
|
135
|
-
requireValue(task.creator === undefined || (typeof
|
|
143
|
+
requireValue([task.creator, task.delegator].every(member => member === undefined || (typeof member === "string" && Object.hasOwn(members, member))), "Invalid task creator or delegator");
|
|
144
|
+
requireValue(task.outcome === undefined || (task.status === "done" && state.messages.some(message => object(message) && message.id === task.outcome && message.task === key)), "Invalid task outcome");
|
|
136
145
|
requireValue(task.status !== "in_progress" || task.owner !== undefined, "In-progress task requires an owner");
|
|
137
146
|
requireValue(task.status === "todo" || task.dependencies.every(dep => (tasks[dep] as Record<string, unknown>).status === "done"), "Active or done task has unfinished dependencies");
|
|
138
147
|
edges[key] = task.dependencies;
|
|
@@ -150,11 +159,44 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
150
159
|
let last = 0;
|
|
151
160
|
const messageIds = new Set<string>();
|
|
152
161
|
for (const message of state.messages) {
|
|
153
|
-
requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.
|
|
162
|
+
requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.summary, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
|
|
163
|
+
requireValue((message.detail === undefined || text(message.detail)) && (message.task === undefined || (typeof message.task === "string" && Object.hasOwn(tasks, message.task))) && (message.replyTo === undefined || (typeof message.replyTo === "string" && messageIds.has(message.replyTo))) && (message.kind === undefined || (message.kind === "outcome" && message.task !== undefined)), "Invalid message detail or link");
|
|
154
164
|
last = message.seq; messageIds.add(message.id);
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Appends a post. Attention goes to explicit recipients, the linked task's owner and delegator (else creator),
|
|
170
|
+
* and the replied-to post's author; never the sender, and never an implicit broadcast.
|
|
171
|
+
*/
|
|
172
|
+
function post(state: TeamState, actor: string, args: Pick<MessageArgs, "recipients" | "summary" | "detail" | "task" | "replyTo">, user = false): Message {
|
|
173
|
+
// A user's request is authored in a session but addressed to that session's own model.
|
|
174
|
+
const limit = user ? maxMessageText : 16000;
|
|
175
|
+
requireValue(text(args.summary, limit), `A post needs a nonempty summary of at most ${limit} characters`);
|
|
176
|
+
requireValue(args.detail === undefined || text(args.detail), "Detail must be nonempty and at most 16000 characters");
|
|
177
|
+
const original = args.replyTo === undefined ? undefined : state.messages.find(message => message.id === args.replyTo);
|
|
178
|
+
requireValue(args.replyTo === undefined || original, "Unknown post to reply to");
|
|
179
|
+
requireValue(!args.task || !original?.task || args.task === original.task, "A reply stays on the replied-to post's task");
|
|
180
|
+
const taskId = args.task ?? original?.task;
|
|
181
|
+
const task = taskId === undefined ? undefined : state.tasks[taskId];
|
|
182
|
+
requireValue(taskId === undefined || task, "Unknown task");
|
|
183
|
+
requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique known member names or IDs");
|
|
184
|
+
const members = Object.values(state.members);
|
|
185
|
+
// Names resolve to durable IDs; a stopped member still receives its post on resume.
|
|
186
|
+
const explicit = (args.recipients ?? []).map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
|
|
187
|
+
requireValue(new Set(explicit).size === explicit.length && explicit.every((recipient): recipient is string => recipient !== undefined), "Recipients must be unique known member names or IDs");
|
|
188
|
+
requireValue(explicit.length > 0 || task || original, "Name recipients, a task, or a post to reply to; posts are never broadcast");
|
|
189
|
+
const waiters = taskId === undefined ? [] : Object.values(state.members).filter(member => member.waiting?.tasks.includes(taskId)).map(member => member.id);
|
|
190
|
+
const routed = [...explicit, task?.owner, task?.delegator ?? task?.creator, original?.from, ...waiters];
|
|
191
|
+
const recipients = [...new Set(routed.filter((id): id is string => !!id && (id !== actor || (user && explicit.includes(id)))))];
|
|
192
|
+
const message: Message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients, summary: args.summary, time: new Date().toISOString() };
|
|
193
|
+
if (args.detail !== undefined) message.detail = args.detail;
|
|
194
|
+
if (taskId !== undefined) message.task = taskId;
|
|
195
|
+
if (original) message.replyTo = original.id;
|
|
196
|
+
state.messages.push(message);
|
|
197
|
+
return message;
|
|
198
|
+
}
|
|
199
|
+
|
|
158
200
|
// Check every component rather than following a symlink into another home.
|
|
159
201
|
function ensureHome(path: string): void {
|
|
160
202
|
const parent = dirname(path);
|
|
@@ -183,6 +225,11 @@ export function readState(dir: string): TeamState {
|
|
|
183
225
|
} finally { closeSync(fd); }
|
|
184
226
|
}
|
|
185
227
|
|
|
228
|
+
/** Display names for members and tasks; their IDs are distinct UUIDs, so one map names both. */
|
|
229
|
+
export function stateLabels(state: TeamState): Record<string, string> {
|
|
230
|
+
return Object.fromEntries([...Object.values(state.members).map(member => [member.id, memberName(member)]), ...Object.values(state.tasks).map(task => [task.id, `#${task.number} ${task.title}`])]);
|
|
231
|
+
}
|
|
232
|
+
|
|
186
233
|
/** Cache only display names, not histories; atomic snapshot replacements invalidate the cache. */
|
|
187
234
|
export function createMemberLabels(): (dir: string) => Record<string, string> {
|
|
188
235
|
let key: string | undefined, labels: Record<string, string>;
|
|
@@ -190,7 +237,7 @@ export function createMemberLabels(): (dir: string) => Record<string, string> {
|
|
|
190
237
|
const file = join(dir, "state.json"), stat = lstatSync(file, { bigint: true });
|
|
191
238
|
const next = `${file}:${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}:${stat.mode}:${stat.uid}:${stat.nlink}`;
|
|
192
239
|
if (next !== key) {
|
|
193
|
-
labels =
|
|
240
|
+
labels = stateLabels(readState(dir));
|
|
194
241
|
key = next;
|
|
195
242
|
}
|
|
196
243
|
return labels;
|
|
@@ -325,6 +372,12 @@ export class Store {
|
|
|
325
372
|
}
|
|
326
373
|
requireValue(args.id && Object.hasOwn(state.tasks, args.id), "Unknown task");
|
|
327
374
|
const task = state.tasks[args.id];
|
|
375
|
+
if (args.action === "complete" && task.outcome) {
|
|
376
|
+
// A retried completion returns the original; a different outcome is a conflict, not a second post.
|
|
377
|
+
const published = state.messages.find(message => message.id === task.outcome)!;
|
|
378
|
+
requireValue(published.from === actor && published.summary === args.summary && published.detail === args.detail, "Task is already done with a different outcome; reopen it before completing again");
|
|
379
|
+
return taskView(state, task);
|
|
380
|
+
}
|
|
328
381
|
requireValue(args.revision === undefined || (integer(args.revision) && args.revision === task.revision), "Stale or invalid task revision; read the task before retrying");
|
|
329
382
|
if (args.action === "edit") {
|
|
330
383
|
requireValue(task.status === "todo", "Only to-do tasks can be edited");
|
|
@@ -348,6 +401,10 @@ export class Store {
|
|
|
348
401
|
requireValue(task.owner === actor, "Only the owner can complete this task");
|
|
349
402
|
requireValue(task.status === "in_progress", "Only in-progress tasks can be completed; start it first");
|
|
350
403
|
requireValue(args.acceptance === true, "Completion requires acceptance=true after checking acceptance criteria");
|
|
404
|
+
requireValue(text(args.summary), "Completion requires an outcome summary: what was done, how it was checked, and any caveats");
|
|
405
|
+
const outcome = post(state, actor, { summary: args.summary, detail: args.detail, task: task.id });
|
|
406
|
+
outcome.kind = "outcome";
|
|
407
|
+
task.outcome = outcome.id;
|
|
351
408
|
task.status = "done";
|
|
352
409
|
task.completedAt = Date.now();
|
|
353
410
|
} else if (args.action === "release") {
|
|
@@ -362,7 +419,7 @@ export class Store {
|
|
|
362
419
|
const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
|
|
363
420
|
requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
|
|
364
421
|
task.status = "todo";
|
|
365
|
-
delete task.completedAt;
|
|
422
|
+
delete task.completedAt; delete task.outcome;
|
|
366
423
|
} else throw new Error("Unknown task action");
|
|
367
424
|
task.revision++;
|
|
368
425
|
return taskView(state, task);
|
|
@@ -371,17 +428,16 @@ export class Store {
|
|
|
371
428
|
|
|
372
429
|
message(actor: string, args: MessageArgs, enveloped = false): Message | Message[] {
|
|
373
430
|
requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
|
|
374
|
-
if (args.action === "send") return this.update(state =>
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return structuredClone(message);
|
|
431
|
+
if (args.action === "send") return this.update(state => structuredClone(post(state, actor, args, enveloped)));
|
|
432
|
+
if (args.action === "wait") return this.update(state => {
|
|
433
|
+
const tasks = args.tasks ?? [];
|
|
434
|
+
requireValue(Array.isArray(tasks) && tasks.length <= 100 && tasks.every(task => Object.hasOwn(state.tasks, task)), "Wait tasks must be existing task IDs");
|
|
435
|
+
// Register and recheck in one mutation, so a post committed after the caller last looked still wakes it.
|
|
436
|
+
const after = args.after ?? state.members[actor].notified;
|
|
437
|
+
const ready = state.messages.filter(message => message.seq > after && (message.recipients.includes(actor) || (message.task !== undefined && tasks.includes(message.task))));
|
|
438
|
+
if (ready.length) delete state.members[actor].waiting;
|
|
439
|
+
else state.members[actor].waiting = { tasks: [...new Set(tasks)] };
|
|
440
|
+
return structuredClone(ready);
|
|
385
441
|
});
|
|
386
442
|
requireValue(args.action === "history" || args.action === "search", "Unknown message action");
|
|
387
443
|
const after = args.after ?? 0, limit = args.limit ?? 20;
|
|
@@ -389,7 +445,7 @@ export class Store {
|
|
|
389
445
|
if (args.action === "search") requireValue(text(args.query, 500), "Search requires a nonempty query (max 500)");
|
|
390
446
|
const results = [];
|
|
391
447
|
for (const message of this.state.messages) {
|
|
392
|
-
if (message.seq > after && (args.action !== "search" || message.
|
|
448
|
+
if (message.seq > after && (args.action !== "search" || (message.summary.includes(args.query!) || !!message.detail?.includes(args.query!)))) results.push(message);
|
|
393
449
|
if (results.length === limit) break;
|
|
394
450
|
}
|
|
395
451
|
return structuredClone(results);
|