@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.
- package/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/events.md +37 -3
- package/docs/operations.md +56 -8
- package/docs/quickstart.md +1 -1
- package/docs/security.md +6 -3
- package/docs/smoke.md +33 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +69 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/local-worker.ts +11 -7
- package/src/cli/console-state.ts +85 -19
- package/src/cli/console.ts +38 -15
- package/src/cli/main.ts +16 -6
- package/src/hub/attribution.ts +20 -0
- package/src/hub/bus.ts +6 -0
- package/src/hub/daemon.ts +37 -10
- package/src/hub/events.ts +4 -3
- package/src/hub/report.ts +109 -0
package/src/cli/console-state.ts
CHANGED
|
@@ -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
|
-
|
|
189
|
-
const
|
|
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
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
}
|
package/src/cli/console.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
58
|
-
const
|
|
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
|
|
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)
|
|
65
|
-
|
|
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
|
|
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 =
|
|
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`);
|
|
81
|
-
terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(lines.at(-1) ??
|
|
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") {
|
|
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]
|
|
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
|
-
|
|
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
|
|
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: (
|
|
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
|
|
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
|
-
|
|
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] }
|