@staix/agent-hub 0.12.17 → 0.12.19

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.
@@ -4,7 +4,7 @@ export interface Approval {
4
4
  id: string; peer: string; title: string; expiresAt: number;
5
5
  options: { optionId: string; name: string; kind: string }[];
6
6
  }
7
- export interface ConsoleEvent { text: string; peer?: string; kind?: string }
7
+ export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
8
8
  export interface ConsoleState {
9
9
  mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
10
10
  input: string; editing: boolean; history: string[]; historyIndex: number;
@@ -22,11 +22,48 @@ export function initialConsoleState(panels = false): ConsoleState {
22
22
  }
23
23
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
24
24
  export function terminalText(value: unknown): string {
25
- return sanitize(String(value ?? ""))
25
+ return sanitize(String(value ?? "")
26
26
  .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
27
27
  .replace(/\x1b[P_X^][\s\S]*?(?:\x1b\\|$)/g, "")
28
28
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
29
- .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
29
+ .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
30
+ }
31
+ export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
32
+ export interface Span { text: string; tone?: Tone }
33
+ export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
34
+ info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
35
+ });
36
+ export function paint(line: Span[], color: boolean): string {
37
+ return line.map(span => {
38
+ const text = terminalText(span.text);
39
+ const sgr = span.tone && Object.hasOwn(PALETTE, span.tone) ? PALETTE[span.tone] : undefined;
40
+ return color && sgr && text ? sgr + text + "\x1b[0m" : text;
41
+ }).join("");
42
+ }
43
+ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
44
+ if (flag === "always") return true;
45
+ if (flag === "never") return false;
46
+ if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
47
+ return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
48
+ }
49
+ export function stateTone(state: string): Tone | undefined {
50
+ if (state === "idle" || state === "approved") return "success";
51
+ if (["busy", "paused", "in_review", "changes_requested", "ready"].includes(state)) return "attention";
52
+ if (["failed", "check failed", "needs_review"].includes(state)) return "failure";
53
+ return undefined;
54
+ }
55
+ const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
56
+ /** Clip only sanitized plain text, then retain the tones of the surviving prefix. */
57
+ function fitLine(line: Span[], columns: number): Span[] {
58
+ const clean = line.map(s => ({ ...s, text: terminalText(s.text).replace(/\n/g, " ") }));
59
+ const text = clean.map(s => s.text).join("");
60
+ const clipped = fit(text, columns);
61
+ const marker = text === clipped ? "" : ".".repeat(Math.max(0, Math.min(3, columns)));
62
+ let remaining = clipped.length - marker.length;
63
+ const out: Span[] = [];
64
+ for (const s of clean) { const part = s.text.slice(0, Math.max(0, remaining)); if (part) out.push({ ...s, text: part }); remaining -= part.length; }
65
+ if (marker) out.push(span(marker));
66
+ return out;
30
67
  }
31
68
  export function fit(value: unknown, columns: number): string {
32
69
  const text = terminalText(value).replace(/\n/g, " ");
@@ -179,35 +216,64 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
179
216
  return done();
180
217
  }
181
218
  export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
219
+ return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
220
+ }
221
+ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
182
222
  const permission = s.approvals[s.approvalIndex];
183
223
  let prompt = s.editing ? `: ${s.input}` : "q quit | Tab panels | : command | a allow d deny v view [ ] select";
184
224
  if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
185
225
  if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
186
226
  if (s.optionChoice) prompt = s.approvals.find(a => a.id === s.optionChoice)?.options.filter(o => o.kind.startsWith("allow")).map((o, i) => `${i + 1}:${o.name}`).join(" | ") ?? "";
227
+ const summary: Span[] = [];
228
+ for (const [id, p] of Object.entries(s.peers)) {
229
+ if (summary.length) summary.push(span(" "));
230
+ summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
231
+ if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
232
+ if (p.paused) summary.push(span(" paused", "attention"));
233
+ }
187
234
  const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
188
- const summary = Object.entries(s.peers).map(([id, p]) => `${id}:${p.state} q${p.queued ?? 0}${p.needsReview ? ` review${p.needsReview}` : ""}${p.paused ? " paused" : ""}`).join(" ");
189
- const footer = [fit(summary + (quotas ? ` | ${quotas}` : ""), columns), fit(`${s.approvals.length} approvals${permission ? ` | ${permission.peer} ${permission.id} ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s` : ""}${s.notice ? ` | ${s.notice}` : ""}`, columns), fit(prompt, columns)];
235
+ if (quotas) summary.push(span(` | ${quotas}`));
236
+ const approvals = [span(`${s.approvals.length} approvals`, s.approvals.length ? "attention" : undefined)];
237
+ if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s`, "muted"));
238
+ if (s.notice) approvals.push(span(` | ${s.notice}`));
239
+ const footer = [fitLine(summary, columns), fitLine(approvals, columns), fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
190
240
  if (s.mode === "stream") return footer;
191
- const title = ["Peers", "Approvals", "Tasks", "Queue", "Events"][s.panel - 1];
192
- const lines = [fit(`agent-hub | 1 Peers 2 Approvals 3 Tasks 4 Queue 5 Events | ${title}`, columns), fit(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`, columns)];
193
- if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns));
194
- else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5))))); }
241
+ const titles = ["Peers", "Approvals", "Tasks", "Queue", "Events"];
242
+ const title = titles[s.panel - 1];
243
+ const tabs = [span("agent-hub | ", "info")];
244
+ for (const [i, name] of titles.entries()) tabs.push(span(`${i + 1} ${name}${i < 4 ? " " : ""}`, i + 1 === s.panel ? "strong" : "info"));
245
+ tabs.push(span(` | ${title}`, "strong"));
246
+ const lines: Span[][] = [fitLine(tabs, columns), fitLine([span(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`)], columns)];
247
+ if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns).map(text => [span(text)]));
248
+ else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5)))).map(text => [span(text)])); }
195
249
  else {
196
250
  const data = panelRows(s); const capacity = Math.max(1, s.panel === 2 ? Math.floor((rows - 6) / 2) : rows - 6); const start = Math.max(0, s.selection - capacity + 1);
197
251
  for (const [i, row] of data.slice(start, start + capacity).entries()) {
198
- let text = "";
252
+ const selected = start + i === s.selection;
253
+ const line = [span(`${selected ? ">" : " "} `, selected ? "strong" : undefined)];
199
254
  if (s.panel === 1) {
200
255
  const b = s.budget[row.id];
201
- text = `${row.id} ${row.state} ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} !:${row.queuedImportant ?? 0} review:${row.needsReview ?? 0} ${row.paused ? `paused:${JSON.stringify(row.paused)}` : ""} ${b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : ""} ${b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"} model:${row.servedBy ?? row.requestedModel ?? "?"}`;
202
- } else if (s.panel === 2) text = `${row.id} ${row.peer} ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s ${row.title}`;
203
- else if (s.panel === 3) { const at = row.history?.at(-1)?.at ?? row.updated ?? row.created; text = `#${row.id} ${row.state}${row.ready ? " ready" : ""} ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"} ${row.title}`; }
204
- else if (s.panel === 4) text = `${row.id} ${row.peer} ${row.state} rev:${row.revision} age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`;
205
- else text = row.text;
206
- lines.push(fit(`${start + i === s.selection ? ">" : " "} ${text}`, columns));
256
+ line.push(span(row.id, row.state === "offline" ? undefined : selected ? "strong" : "info"), span(" "), span(row.state, stateTone(row.state)),
257
+ span(` ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} `), span(`!:${row.queuedImportant ?? 0}`, row.queuedImportant ? "attention" : undefined),
258
+ span(` review:${row.needsReview ?? 0}`, row.needsReview ? "failure" : undefined), span(" "),
259
+ span(row.paused ? `paused:${JSON.stringify(row.paused)}` : "", "attention"), span(" "),
260
+ span(b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : "", "attention"), span(" "),
261
+ span(b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"), span(` model:${row.servedBy ?? row.requestedModel ?? "?"}`));
262
+ } else if (s.panel === 2) line.push(span(`${row.id} ${row.peer}`, "attention"), span(` ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s`, "muted"), span(` ${row.title}`));
263
+ else if (s.panel === 3) {
264
+ const last = row.history?.at(-1);
265
+ const at = last?.at ?? row.updated ?? row.created;
266
+ const tone = last?.event === "check failed" || last?.event === "failed" ? "failure" : stateTone(row.state);
267
+ const failure = (last?.event === "check failed" || last?.event === "failed") && row.state !== last.event ? ` ${last.event}` : "";
268
+ line.push(span(`#${row.id}`, selected ? "strong" : "info"), span(" "), span(row.state, tone), span(failure, "failure"), span(row.ready ? " ready" : "", "attention"),
269
+ span(` ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} `), span(`age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"}`, "muted"), span(` ${row.title}`));
270
+ } else if (s.panel === 4) line.push(span(row.id, selected ? "strong" : "info"), span(` ${row.peer} `), span(row.state, stateTone(row.state)), span(` rev:${row.revision} `), span(`age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`, "muted"));
271
+ else { const [header, ...body] = String(row.text).split("\n"); line.push(span(header, row.tone)); if (body.length) line.push(span("\n" + body.join("\n"))); }
272
+ lines.push(fitLine(line, columns));
207
273
  }
208
- if (!data.length) lines.push("(empty)");
209
- if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)));
274
+ if (!data.length) lines.push([span("(empty)")]);
275
+ if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)).map(text => [span(text)]));
210
276
  }
211
- const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push("");
277
+ const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push([]);
212
278
  return [...body, ...footer];
213
279
  }
@@ -3,8 +3,9 @@ import { StringDecoder } from "node:string_decoder";
3
3
  import type { ControlClient } from "../hub/control-client.ts";
4
4
  import { contextLine } from "./status-lines.ts";
5
5
  import { renderTailEvent } from "./tail-render.ts";
6
- import { initialConsoleState, pruneApprovals, reduceConsole, renderConsole, terminalText, wrap } from "./console-state.ts";
7
- import type { ConsoleEffect, ConsoleEvent } from "./console-state.ts";
6
+ import { initialConsoleState, paint, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } from "./console-state.ts";
7
+ import type { ConsoleEffect, ConsoleEvent, Tone } from "./console-state.ts";
8
+ import type { BusEvent } from "../hub/bus.ts";
8
9
 
9
10
  export interface ConsoleTerminal {
10
11
  columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
@@ -19,11 +20,23 @@ export interface ConsoleOptions {
19
20
  client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
20
21
  cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
21
22
  terminal?: ConsoleTerminal;
23
+ color?: boolean;
22
24
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
23
25
  runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
24
26
  pollMs?: number;
25
27
  }
26
28
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
29
+ /** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
30
+ export function eventTone(e: BusEvent): Tone | undefined {
31
+ if (e.t === "state") return stateTone(e.state);
32
+ if (e.t === "undeliverable" || e.t === "overflow") return "failure";
33
+ if (e.t === "envelope") {
34
+ if (e.dropped === "hop") return "failure";
35
+ if (e.env.priority === "important" || e.env.kind === "task" || e.env.kind === "review" || e.env.kind === "budget") return "attention";
36
+ return "info";
37
+ }
38
+ return undefined;
39
+ }
27
40
  function nativeTerminal(): ConsoleTerminal {
28
41
  return {
29
42
  get columns() { return process.stdout.columns ?? 80; }, get rows() { return process.stdout.rows ?? 24; },
@@ -46,6 +59,7 @@ function nativeTerminal(): ConsoleTerminal {
46
59
  /** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
47
60
  export async function runConsole(options: ConsoleOptions): Promise<void> {
48
61
  const { client } = options; const terminal = options.terminal ?? nativeTerminal();
62
+ const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
49
63
  let state = initialConsoleState(!!options.panels);
50
64
  let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
51
65
  let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
@@ -54,15 +68,19 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
54
68
  const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
55
69
  let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
56
70
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
57
- const safeWrite = (text: string) => terminal.write(terminalText(text));
58
- const writeStream = (text: string) => {
71
+ const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
72
+ const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => wrap(text, columns).map(line => [{ text: line, ...(index === 0 && event.tone ? { tone: event.tone } : {}) }]));
73
+ const writeStream = (event: ConsoleEvent) => {
59
74
  terminal.write(`\x1b[${rows - 3};1H`);
60
- for (const line of wrap(text, columns)) { safeWrite(line); terminal.write("\r\n"); }
75
+ for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
61
76
  };
62
77
  const stream = (event: ConsoleEvent) => {
63
78
  state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
64
- if (plain) safeWrite(event.text + "\n");
65
- else if (state.mode === "stream") writeStream(event.text);
79
+ if (plain) {
80
+ const [header, ...body] = terminalText(event.text).split("\n");
81
+ terminal.write(paint([{ text: header ?? "", tone: event.tone }, ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
82
+ }
83
+ else if (state.mode === "stream") writeStream(event);
66
84
  else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
67
85
  };
68
86
  const draw = () => {
@@ -70,15 +88,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
70
88
  if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
71
89
  else if (state.mode === "stream" && inAlternate) {
72
90
  terminal.write(`\x1b[?1049l\x1b[1;${rows - 3}r`); inAlternate = false;
73
- if (droppedStream) writeStream(`${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.`);
74
- for (const event of pendingStream) writeStream(event.text);
91
+ if (droppedStream) writeStream({ text: `${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.` });
92
+ for (const event of pendingStream) writeStream(event);
75
93
  pendingStream = []; droppedStream = 0;
76
94
  }
77
95
  terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 3}r` : "\x1b[r");
78
- const lines = renderConsole(state, columns, rows);
96
+ const lines = renderConsoleLines(state, columns, rows);
79
97
  const start = state.mode === "stream" ? rows - 2 : 1;
80
- for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); safeWrite(line); }
81
- terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(lines.at(-1) ?? "") + 1)}H\x1b[?25h`);
98
+ for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
99
+ terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(paint(lines.at(-1) ?? [], false)) + 1)}H\x1b[?25h`);
82
100
  };
83
101
  const stop = (reason?: string) => {
84
102
  if (!active) return;
@@ -130,7 +148,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
130
148
  });
131
149
  const effect = async (action: ConsoleEffect) => {
132
150
  if (action.type === "exit") return stop();
133
- if (action.type === "permit") { client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) }); return; }
151
+ if (action.type === "permit") {
152
+ client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
153
+ if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
154
+ return;
155
+ }
134
156
  if (action.type === "show") {
135
157
  const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
136
158
  if (!active) return;
@@ -157,14 +179,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
157
179
  client.onPush = msg => {
158
180
  if (!active) return;
159
181
  try {
160
- if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t });
182
+ if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
161
183
  else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
162
184
  else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
163
185
  else if (msg.t === "permission") {
164
- stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission" });
186
+ stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission", tone: "attention" });
165
187
  if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
166
188
  } else if (msg.t === "permission_closed") {
167
189
  state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
190
+ if (msg.reason === "expired" || msg.outcome === "cancelled" || msg.outcome?.startsWith("reject")) stream({ text: ` ! ${msg.peer} permission ${msg.id} ${msg.reason ?? msg.outcome}`, peer: msg.peer, kind: "permission", tone: "failure" });
168
191
  }
169
192
  draw();
170
193
  } catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
package/src/cli/main.ts CHANGED
@@ -33,12 +33,13 @@ import { setupOllamaModel } from "./models-setup.ts";
33
33
  import { unknownContext } from "../hub/context-window.ts";
34
34
  import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
35
35
  import { parseSince, readEvents } from "../hub/events.ts";
36
- import { formatReport, summarize } from "../hub/report.ts";
36
+ import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
37
37
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
38
38
  import { pathWarnings } from "../hub/conflicts.ts";
39
39
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
40
40
  import { recordCliAudit } from "./identity-audit.ts";
41
41
  import { runConsole } from "./console.ts";
42
+ import { resolveColor } from "./console-state.ts";
42
43
  import { renderTailEvent } from "./tail-render.ts";
43
44
 
44
45
  /** `--since 7d|24h|<iso>` for export and report; everything when absent. */
@@ -61,7 +62,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
61
62
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
62
63
  ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
63
64
  ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
64
- ahub console [--panels] enter the human console, leaving the daemon running on exit
65
+ ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
65
66
  ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
66
67
  ahub restart [--dry-run] [--yes] recover this project's runtime
67
68
  ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
@@ -96,7 +97,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
96
97
  ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
97
98
  ahub status | logs [-f] | doctor | kill
98
99
  ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
99
- ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
100
+ ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
100
101
  ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
101
102
  ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
102
103
  ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
@@ -491,8 +492,10 @@ const commands: Record<string, () => Promise<void> | void> = {
491
492
  },
492
493
 
493
494
  console: async () => {
494
- if (args.some(arg => arg !== "--panels")) fail("usage: ahub console [--panels]");
495
- await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels") });
495
+ if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
496
+ const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
497
+ if (color instanceof Error) return fail(color.message);
498
+ await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
496
499
  },
497
500
 
498
501
  up: async () => {
@@ -825,7 +828,14 @@ const commands: Record<string, () => Promise<void> | void> = {
825
828
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
826
829
  },
827
830
  report: () => {
828
- const r = summarize(readEvents(join(stateDir, "events.jsonl"), since()));
831
+ const by = args.indexOf("--by");
832
+ if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
833
+ const events = readEvents(join(stateDir, "events.jsonl"), since());
834
+ if (by >= 0) {
835
+ const r = summarizeByTask(events);
836
+ return console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatTaskReport(r).join("\n"));
837
+ }
838
+ const r = summarize(events);
829
839
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
830
840
  },
831
841
  facts: async () => {
@@ -0,0 +1,20 @@
1
+ import type { Envelope } from "./envelope.ts";
2
+
3
+ export type Attribution = "delivery" | "single_open" | "unattributed";
4
+ export interface TaskAttribution { task?: number; attribution: Attribution; pii?: true }
5
+
6
+ /** Only one distinct positive task id in the original delivery can identify its turn. */
7
+ export function deliveryTask(originals: Envelope[]): number | undefined {
8
+ const ids = new Set(originals.flatMap(env => {
9
+ const id = Number(env.refs?.task);
10
+ return Number.isSafeInteger(id) && id > 0 ? [id] : [];
11
+ }));
12
+ return ids.size === 1 ? ids.values().next().value : undefined;
13
+ }
14
+
15
+ /** Rule order is deliberate: a delivered review belongs to its task, regardless of ownership. */
16
+ export function attribute(delivery: number | undefined, inProgress: number[]): TaskAttribution {
17
+ if (delivery !== undefined && Number.isSafeInteger(delivery) && delivery > 0) return { task: delivery, attribution: "delivery" };
18
+ if (inProgress.length === 1) return { task: inProgress[0]!, attribution: "single_open" };
19
+ return { attribution: "unattributed" };
20
+ }
package/src/hub/bus.ts CHANGED
@@ -17,6 +17,8 @@ export type BusEvent =
17
17
  | { t: "state"; peer: PeerId; state: PeerState };
18
18
 
19
19
  export interface BusOptions {
20
+ /** Observe original envelopes immediately before a transport starts its turn. */
21
+ onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
20
22
  retryMs: number;
21
23
  /** A queue this long is delivered without waiting for the batch window. */
22
24
  batchMax: number;
@@ -125,6 +127,8 @@ export class Bus {
125
127
  onQueues?: () => void;
126
128
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
127
129
  onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
130
+ /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
131
+ onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
128
132
  /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
129
133
  onDeliveryFailed?: (peer: PeerId) => void;
130
134
  private recoveryHeld = false;
@@ -135,6 +139,7 @@ export class Bus {
135
139
 
136
140
  constructor(opts: Partial<BusOptions> = {}) {
137
141
  this.opts = { ...DEFAULT_BUS, ...opts };
142
+ this.onDeliver = opts.onDeliver;
138
143
  this.journal = opts.journal;
139
144
  if (this.journal) {
140
145
  const state = this.journal.snapshot();
@@ -753,6 +758,7 @@ export class Bus {
753
758
  const deliveryId = crypto.randomUUID();
754
759
  if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
755
760
  try {
761
+ try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
756
762
  if (this.journal) await peer.deliver(out, deliveryId);
757
763
  else { await peer.deliver(out); this.delivered(id, delivery); }
758
764
  } catch {
package/src/hub/daemon.ts CHANGED
@@ -46,6 +46,7 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./in
46
46
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
47
47
  import { currentRouting, detectSignals } from "./routing.ts";
48
48
  import { Bus } from "./bus.ts";
49
+ import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
49
50
  import { DeliveryJournal } from "./delivery-journal.ts";
50
51
  import { startDashboard } from "./ui.ts";
51
52
  import { PROTOCOL, stateDirFor } from "./control-client.ts";
@@ -956,14 +957,26 @@ export async function startDaemon(opts: DaemonOptions) {
956
957
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
957
958
  const runId = Date.now().toString(36);
958
959
  let turnSeq = 0;
959
- const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean }>();
960
+ const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution }>();
961
+ const pendingDeliveryTask = new Map<PeerId, number>();
962
+ const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
963
+ const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
964
+ const task = result.task === undefined ? undefined : board.get(result.task);
965
+ return { ...result, ...(task && tasks.isPii(task) ? { pii: true as const } : {}) };
966
+ };
960
967
  const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
968
+ bus.onDeliver = (peer, originals) => {
969
+ pendingDeliveryTask.delete(peer);
970
+ const task = deliveryTask(originals);
971
+ if (task !== undefined) pendingDeliveryTask.set(peer, task);
972
+ };
961
973
  bus.onDelivered = (peer, originals) => {
974
+ pendingDeliveryTask.delete(peer);
962
975
  if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
963
976
  const turn = turns.get(peer);
964
977
  supervisionTurns.set(peer, { id: turn?.id ?? `supervision-${randomUUID()}`, at: turn?.start ?? Date.now(), ...(turn?.tokens ? { tokens: turn.tokens } : {}), ...(contextSession(peer) ? { session: contextSession(peer) } : {}) });
965
978
  };
966
- bus.onDeliveryFailed = peer => { supervisionTurns.delete(peer); };
979
+ bus.onDeliveryFailed = peer => { pendingDeliveryTask.delete(peer); supervisionTurns.delete(peer); };
967
980
  const finishSupervisionTurn = (peer: PeerId) => {
968
981
  const receipt = supervisionTurns.get(peer);
969
982
  if (!receipt) return;
@@ -1000,7 +1013,7 @@ export async function startDaemon(opts: DaemonOptions) {
1000
1013
  const supervisionTurn = supervisionTurns.get(peer);
1001
1014
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
1002
1015
  if (n > 0) {
1003
- event({ type: "tokens", peer, n });
1016
+ event({ type: "tokens", peer, n, ...taskAttribution(peer) });
1004
1017
  const turn = turns.get(peer);
1005
1018
  if (turn) turn.tokens += n;
1006
1019
  }
@@ -1382,7 +1395,7 @@ export async function startDaemon(opts: DaemonOptions) {
1382
1395
  claudeNativeUsageSeen.add(record.id);
1383
1396
  const reported = claudeReportedTokens(record.usage);
1384
1397
  if (reported !== undefined) addTokens("claude", reported);
1385
- event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
1398
+ event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution("claude"), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
1386
1399
  }
1387
1400
  };
1388
1401
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
@@ -1631,6 +1644,9 @@ export async function startDaemon(opts: DaemonOptions) {
1631
1644
  // The adapter's own state: a pause shows a busy peer as paused, and its turn goes on all the same.
1632
1645
  const busy = (bus.peers.get(e.peer)?.state ?? e.state) === "busy";
1633
1646
  if (busy && !open) {
1647
+ const delivery = pendingDeliveryTask.get(e.peer);
1648
+ pendingDeliveryTask.delete(e.peer);
1649
+ const attribution = taskAttribution(e.peer, delivery);
1634
1650
  const id = `${e.peer}#${runId}.${++turnSeq}`;
1635
1651
  // A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
1636
1652
  // gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
@@ -1639,7 +1655,7 @@ export async function startDaemon(opts: DaemonOptions) {
1639
1655
  turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
1640
1656
  const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
1641
1657
  try { turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
1642
- turns.set(e.peer, { id, start: Date.now(), tokens: 0, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
1658
+ turns.set(e.peer, { id, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
1643
1659
  event({ type: "turn_start", peer: e.peer, turn: id });
1644
1660
  } else if (!busy && open) {
1645
1661
  turns.delete(e.peer);
@@ -1658,7 +1674,7 @@ export async function startDaemon(opts: DaemonOptions) {
1658
1674
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
1659
1675
  }
1660
1676
  // A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn.
1661
- if (e.peer !== "claude" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
1677
+ if (e.peer !== "claude" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...open.attribution, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
1662
1678
  if (e.peer !== "claude") { // Claude's native turn end is its Stop hook
1663
1679
  if ((bus.peers.get(e.peer)?.state ?? e.state) === "idle") queueMicrotask(() => finishSupervisionTurn(e.peer));
1664
1680
  turnEnded.set(e.peer, Date.now());
@@ -1963,11 +1979,22 @@ export async function startDaemon(opts: DaemonOptions) {
1963
1979
  });
1964
1980
  piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
1965
1981
  let piReply: Envelope | undefined;
1982
+ // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
1983
+ const piAlways = new Set<string>();
1984
+ const piPermit = async (title: string, tool: string): Promise<boolean> => {
1985
+ if (piAlways.has(tool)) log(`permission auto-allowed for pi: ${tool} (granted until Pi restarts)`); // the name only, never the arguments
1986
+ else {
1987
+ const picked = await onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "always", name: `Always allow ${tool} until Pi restarts`, kind: "allow_always" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] });
1988
+ if (picked === "always") piAlways.add(tool);
1989
+ else if (picked !== "allow") return false;
1990
+ }
1991
+ return pi.acceptingTools && bus.peers.get("pi") === pi;
1992
+ };
1966
1993
  const ctx: ToolContext = {
1967
1994
  cwd: opts.cwd, deny: config.local.deny,
1968
1995
  sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny),
1969
1996
  sandboxEnv: { ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: "pi" },
1970
- permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
1997
+ permit: async () => false, // Pi's calls ask through piPermit in executeTool, which knows the tool's name
1971
1998
  send: (text, to) => {
1972
1999
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
1973
2000
  const refused = pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) });
@@ -1989,12 +2016,12 @@ export async function startDaemon(opts: DaemonOptions) {
1989
2016
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
1990
2017
  const nativeTurn = pi.observationTurn;
1991
2018
  const output = TASK_TOOLS.some((t) => t.name === name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp("pi", name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal, permit: async title => {
1992
- if (!signal) return ctx.permit(title);
2019
+ if (!signal) return piPermit(title, name);
1993
2020
  if (signal.aborted) return false;
1994
2021
  return new Promise<boolean>((resolve, reject) => {
1995
2022
  const finish = (allowed: boolean) => { signal.removeEventListener("abort", aborted); resolve(allowed && !signal.aborted); };
1996
2023
  const aborted = () => finish(false); signal.addEventListener("abort", aborted, { once: true });
1997
- Promise.resolve(ctx.permit(title)).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
2024
+ piPermit(title, name).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
1998
2025
  });
1999
2026
  } });
2000
2027
  if (!signal?.aborted && bus.peers.get("pi") === pi) {
@@ -2087,7 +2114,7 @@ export async function startDaemon(opts: DaemonOptions) {
2087
2114
  const local = new LocalPeer("local", {
2088
2115
  cwd: opts.cwd,
2089
2116
  omni,
2090
- onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
2117
+ onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, ...taskAttribution("local", record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
2091
2118
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
2092
2119
  ...(route ? { route } : {}),
2093
2120
  ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
package/src/hub/events.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import type { TaskAttribution } from "./attribution.ts";
2
3
 
3
4
  /** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
4
5
  export const EVENTS_SCHEMA = 1;
@@ -23,7 +24,7 @@ export type HubEvent =
23
24
  | { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
24
25
  | { type: "state"; peer: string; state: string }
25
26
  | { type: "turn_start"; peer: string; turn: string }
26
- | { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
27
+ | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
27
28
  | { type: "native_turn_end"; peer: string; id?: string }
28
29
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
29
30
  | { type: "agent_cli"; peer: string; command: string; refused: boolean }
@@ -32,8 +33,8 @@ export type HubEvent =
32
33
  | { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
33
34
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
34
35
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
35
- | { type: "tokens"; peer: string; n: number }
36
- | { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
36
+ | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
37
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
37
38
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
38
39
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
39
40
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }