@staix/agent-hub 0.7.11 → 0.8.1
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 +10 -1
- package/README.md +2 -2
- package/docs/events.md +37 -0
- package/docs/operations.md +91 -10
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -0
- package/docs/smoke.md +36 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +16 -4
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/codex-appserver.ts +43 -3
- package/src/cli/main.ts +91 -0
- package/src/hub/board.ts +29 -9
- package/src/hub/budget.ts +3 -0
- package/src/hub/daemon.ts +139 -17
- package/src/hub/events.ts +91 -0
- package/src/hub/hub-tools.ts +15 -3
- package/src/hub/lifecycle.ts +13 -2
- package/src/hub/report.ts +68 -0
- package/src/hub/snapshots.ts +191 -0
- package/src/hub/tasks.ts +119 -28
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +2 -1
package/src/cli/main.ts
CHANGED
|
@@ -28,6 +28,18 @@ import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
|
28
28
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
29
29
|
|
|
30
30
|
import { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
31
|
+
import { parseSince, readEvents } from "../hub/events.ts";
|
|
32
|
+
import { formatReport, summarize } from "../hub/report.ts";
|
|
33
|
+
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
34
|
+
|
|
35
|
+
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
36
|
+
const since = (): number => {
|
|
37
|
+
const at = args.indexOf("--since");
|
|
38
|
+
if (at < 0) return 0;
|
|
39
|
+
const t = parseSince(args[at + 1] ?? "");
|
|
40
|
+
if (t === undefined) fail("--since takes 7d, 24h, 90m or an ISO date");
|
|
41
|
+
return t;
|
|
42
|
+
};
|
|
31
43
|
|
|
32
44
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
33
45
|
|
|
@@ -72,6 +84,11 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
72
84
|
ahub queue show <delivery-id> inspect one delivery and revision
|
|
73
85
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
74
86
|
ahub status | logs [-f] | doctor | kill
|
|
87
|
+
ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
|
|
88
|
+
ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
|
|
89
|
+
ahub turns [peer] [--limit N] recent turns and the files each changed (a git work tree only)
|
|
90
|
+
ahub undo <turn> [--yes] [--context] put back the files a turn changed; refuses files changed since.
|
|
91
|
+
Without --yes it only lists them; --context also drops a Codex turn from its conversation
|
|
75
92
|
ahub doctor --orphans [--kill] list registrations whose project root is gone; --kill stops their
|
|
76
93
|
daemons (SIGTERM, then SIGKILL) only after the process identity checks out`;
|
|
77
94
|
|
|
@@ -279,6 +296,22 @@ async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
|
279
296
|
return res.text;
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
|
|
300
|
+
function turnRecords<T>(read: (turns: Turns) => T, none: T): T {
|
|
301
|
+
const db = join(stateDir, "hub.db");
|
|
302
|
+
if (!existsSync(db)) return none;
|
|
303
|
+
let turns: Turns | undefined;
|
|
304
|
+
try {
|
|
305
|
+
turns = new Turns(db, true);
|
|
306
|
+
return read(turns);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (/no such table/.test((error as Error).message)) return none;
|
|
309
|
+
throw error;
|
|
310
|
+
} finally {
|
|
311
|
+
turns?.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
282
315
|
/** `--flag value` pairs pulled out of an argument list; the rest keeps its order. */
|
|
283
316
|
function takeFlags(argv: string[], single: string[], repeated: string[]) {
|
|
284
317
|
const one: Record<string, string> = {};
|
|
@@ -716,6 +749,64 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
716
749
|
},
|
|
717
750
|
|
|
718
751
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
752
|
+
export: () => {
|
|
753
|
+
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
754
|
+
},
|
|
755
|
+
report: () => {
|
|
756
|
+
const r = summarize(readEvents(join(stateDir, "events.jsonl"), since()));
|
|
757
|
+
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
|
|
758
|
+
},
|
|
759
|
+
turns: () => {
|
|
760
|
+
const { one, rest } = takeFlags(args, ["--limit"], []);
|
|
761
|
+
const rows = turnRecords((t) => t.list(rest[0], Number(one["--limit"]) || 20), []);
|
|
762
|
+
if (!rows.length) return console.log("no turns recorded (they need a git work tree and snapshots.enabled)");
|
|
763
|
+
for (const r of rows) {
|
|
764
|
+
const shown = r.changed.slice(0, 5).join(", ") + (r.changed.length > 5 ? ", ..." : "");
|
|
765
|
+
const files = r.changed.length ? `: ${shown}` : "";
|
|
766
|
+
let status = "";
|
|
767
|
+
if (!r.ended) status = " (running)";
|
|
768
|
+
else if (!r.end_tree) status = " (no end snapshot)";
|
|
769
|
+
console.log(`${r.id} ${new Date(r.started).toLocaleString()}${status} ${r.changed.length} files${files}`);
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
undo: async () => {
|
|
773
|
+
const id = args.find((a) => !a.startsWith("--")) ?? fail("usage: ahub undo <turn> [--yes] [--context]");
|
|
774
|
+
const turn = turnRecords((t) => t.get(id), undefined) ?? fail(`no turn ${id} recorded (ahub turns lists them)`);
|
|
775
|
+
if (!turn.ended) fail(`turn ${id} is still running`);
|
|
776
|
+
const { start_tree: startTree, end_tree: endTree } = turn;
|
|
777
|
+
if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
|
|
778
|
+
const repo = repoOf(cwd) ?? fail(`${cwd} is not in a git work tree`);
|
|
779
|
+
if (!hasTree(repo.top, startTree) || !hasTree(repo.top, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
|
|
780
|
+
// Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
|
|
781
|
+
const keep = Math.max(1, Number(projectConfig().snapshots.keep) || 20);
|
|
782
|
+
const overlapping = turnRecords((t) => t.overlapping(turn, keep), { paths: [] as string[], unknown: [] as string[] });
|
|
783
|
+
if (overlapping.unknown.length) fail(`refusing to undo ${id}: these turns of other peers ran at the same time and their changes are not known (still running, cut short, or not snapshotted): ${overlapping.unknown.join(", ")}`);
|
|
784
|
+
let reverted = false;
|
|
785
|
+
const plan = () => {
|
|
786
|
+
const p = planUndo(repo, { start_tree: startTree, end_tree: endTree, changed: turn.changed }, overlapping.paths);
|
|
787
|
+
if (p.undone) return p;
|
|
788
|
+
const why = [
|
|
789
|
+
p.changedSince.length ? `these files changed again after it ended, and restoring them would lose that work:\n ${p.changedSince.join("\n ")}` : "",
|
|
790
|
+
p.concurrent.length ? `these files were also changed by another peer's turn running at the same time, so the change may be theirs:\n ${p.concurrent.join("\n ")}` : "",
|
|
791
|
+
].filter(Boolean);
|
|
792
|
+
if (why.length) fail(`refusing to undo ${id}: ${why.join("\n")}${reverted ? "\nCodex's conversation was already reverted; restore the files later with ahub undo without --context" : ""}`);
|
|
793
|
+
return p;
|
|
794
|
+
};
|
|
795
|
+
const first = plan();
|
|
796
|
+
if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
|
|
797
|
+
console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
|
|
798
|
+
console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
|
|
799
|
+
if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
|
|
800
|
+
if (args.includes("--context")) {
|
|
801
|
+
console.log(await taskOp("turn_revert", { turn: id }));
|
|
802
|
+
reverted = true;
|
|
803
|
+
}
|
|
804
|
+
// A peer may have written meanwhile (the revert alone can take seconds): plan again right before restoring.
|
|
805
|
+
const second = plan();
|
|
806
|
+
if (second.restore.join("\0") !== first.restore.join("\0")) fail(`the project changed while undoing; nothing was restored, run ahub undo again${reverted ? " without --context (Codex's conversation was already reverted)" : ""}`);
|
|
807
|
+
restore(repo, startTree, second.restore);
|
|
808
|
+
if (second.restore.length) console.log(`restored to their state before ${id}`);
|
|
809
|
+
},
|
|
719
810
|
|
|
720
811
|
kill: async () => {
|
|
721
812
|
const registry = new Registry();
|
package/src/hub/board.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface TaskRefs {
|
|
|
11
11
|
commit?: string;
|
|
12
12
|
paths?: string[];
|
|
13
13
|
}
|
|
14
|
+
/** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
|
|
15
|
+
export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
|
|
16
|
+
export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
|
|
17
|
+
|
|
14
18
|
export interface HistoryEntry {
|
|
15
19
|
at: number;
|
|
16
20
|
by: PeerId;
|
|
@@ -26,6 +30,8 @@ export interface Task {
|
|
|
26
30
|
reviewer: PeerId | null;
|
|
27
31
|
state: TaskState;
|
|
28
32
|
refs: TaskRefs;
|
|
33
|
+
/** Absent only on tasks built outside the board (tests); the board always returns one, `{}` when none was given. */
|
|
34
|
+
plan?: TaskPlan;
|
|
29
35
|
signals: string[];
|
|
30
36
|
/** consecutive changes_requested verdicts */
|
|
31
37
|
rejections: number;
|
|
@@ -43,11 +49,16 @@ const MOVES: Record<TaskState, TaskState[]> = {
|
|
|
43
49
|
approved: [],
|
|
44
50
|
};
|
|
45
51
|
|
|
46
|
-
const JSON_COLS = ["refs", "signals", "history"] as const;
|
|
52
|
+
const JSON_COLS = ["refs", "plan", "signals", "history"] as const;
|
|
47
53
|
|
|
48
54
|
/** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
|
|
49
55
|
export class Board {
|
|
50
56
|
private readonly db: Database;
|
|
57
|
+
/** Every recorded change, as it lands (telemetry, issue #40). */
|
|
58
|
+
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
59
|
+
private changed(task: Task, entry: HistoryEntry): void {
|
|
60
|
+
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
61
|
+
}
|
|
51
62
|
|
|
52
63
|
constructor(path: string) {
|
|
53
64
|
this.db = new Database(path, { create: true });
|
|
@@ -56,15 +67,21 @@ export class Board {
|
|
|
56
67
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
|
|
57
68
|
owner TEXT, reviewer TEXT, state TEXT NOT NULL, refs TEXT NOT NULL DEFAULT '{}', signals TEXT NOT NULL DEFAULT '[]',
|
|
58
69
|
rejections INTEGER NOT NULL DEFAULT 0, history TEXT NOT NULL DEFAULT '[]', created INTEGER NOT NULL, updated INTEGER NOT NULL)`);
|
|
70
|
+
// Boards from before issue #31 have no plan column.
|
|
71
|
+
if (!(this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[]).some((c) => c.name === "plan")) {
|
|
72
|
+
this.db.run("ALTER TABLE tasks ADD COLUMN plan TEXT NOT NULL DEFAULT '{}'");
|
|
73
|
+
}
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; signals?: string[] }): Task {
|
|
76
|
+
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; signals?: string[] }): Task {
|
|
62
77
|
const now = Date.now();
|
|
63
78
|
const history: HistoryEntry[] = [{ at: now, by, event: "proposed" }];
|
|
64
79
|
const { lastInsertRowid } = this.db
|
|
65
|
-
.query("INSERT INTO tasks (title, detail, class, state, refs, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?)")
|
|
66
|
-
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
67
|
-
|
|
80
|
+
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?)")
|
|
81
|
+
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
82
|
+
const task = this.get(Number(lastInsertRowid))!;
|
|
83
|
+
this.changed(task, history[0]!);
|
|
84
|
+
return task;
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
get(id: number): Task | undefined {
|
|
@@ -84,7 +101,8 @@ export class Board {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
87
|
-
|
|
104
|
+
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
105
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string): Task {
|
|
88
106
|
const task = this.get(id);
|
|
89
107
|
if (!task) throw new Error(`no task #${id}`);
|
|
90
108
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
@@ -93,9 +111,11 @@ export class Board {
|
|
|
93
111
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
94
112
|
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}) }];
|
|
95
113
|
this.db
|
|
96
|
-
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
97
|
-
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
98
|
-
|
|
114
|
+
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
115
|
+
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
116
|
+
const updated = this.get(id)!;
|
|
117
|
+
this.changed(updated, history.at(-1)!);
|
|
118
|
+
return updated;
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
close(): void {
|
package/src/hub/budget.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface BudgetHooks {
|
|
|
55
55
|
/** Move the peer's open work. */
|
|
56
56
|
handoff(peer: PeerId, context: string | undefined): Promise<Moved[]>;
|
|
57
57
|
resumed(record: PauseRecord): void;
|
|
58
|
+
/** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
|
|
59
|
+
reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
|
|
58
60
|
notify(line: string): void;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -144,6 +146,7 @@ export class Budget {
|
|
|
144
146
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
145
147
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
146
148
|
if (EXEMPT.has(peer) || this.closed) return;
|
|
149
|
+
try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
147
150
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
148
151
|
this.readings.set(peer, mine);
|
|
149
152
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
package/src/hub/daemon.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { childEnv } from "./child-process.ts";
|
|
7
7
|
import { runCheck } from "./checks.ts";
|
|
8
8
|
import { stripUntrusted } from "./config-trust.ts";
|
|
9
|
+
import { eventLog, tokenDeltas } from "./events.ts";
|
|
9
10
|
import type { ServerWebSocket } from "bun";
|
|
10
11
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
11
12
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -40,6 +41,7 @@ import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
|
40
41
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
41
42
|
import { VERSION } from "../version.ts";
|
|
42
43
|
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
44
|
+
import { changedPaths, repoOf, snapshot, Turns } from "./snapshots.ts";
|
|
43
45
|
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
44
46
|
|
|
45
47
|
export interface HubConfig {
|
|
@@ -63,6 +65,8 @@ export interface HubConfig {
|
|
|
63
65
|
tasks: { release_after_min: number };
|
|
64
66
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
65
67
|
checks: { timeout_s: number; [cls: string]: string | number };
|
|
68
|
+
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
69
|
+
snapshots: { enabled: boolean; keep: number };
|
|
66
70
|
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
67
71
|
ignored?: string[];
|
|
68
72
|
}
|
|
@@ -85,6 +89,8 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
85
89
|
approvals: { timeout_s: 120, notify: false },
|
|
86
90
|
tasks: { release_after_min: 30 },
|
|
87
91
|
checks: { timeout_s: 600 },
|
|
92
|
+
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
93
|
+
snapshots: { enabled: false, keep: 20 },
|
|
88
94
|
};
|
|
89
95
|
|
|
90
96
|
export { stateDirFor };
|
|
@@ -95,7 +101,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
95
101
|
|
|
96
102
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
97
103
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
98
|
-
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "mlx"];
|
|
104
|
+
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "mlx"];
|
|
99
105
|
|
|
100
106
|
export function loadConfig(cwd: string): HubConfig {
|
|
101
107
|
const ignored: string[] = [];
|
|
@@ -138,6 +144,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
138
144
|
},
|
|
139
145
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
140
146
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
147
|
+
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
141
148
|
mlx,
|
|
142
149
|
...(ignored.length ? { ignored } : {}),
|
|
143
150
|
};
|
|
@@ -224,6 +231,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
224
231
|
const logFile = join(opts.stateDir, "hub.log");
|
|
225
232
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
226
233
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
234
|
+
const event = eventLog(join(opts.stateDir, "events.jsonl"));
|
|
227
235
|
|
|
228
236
|
// Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
|
|
229
237
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
@@ -359,6 +367,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
359
367
|
project: chain.at(-1)!,
|
|
360
368
|
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
361
369
|
notify,
|
|
370
|
+
recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
|
|
362
371
|
...(checksAllowed ? { check: (cls: string) => checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
|
|
363
372
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
364
373
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
@@ -378,6 +387,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
378
387
|
},
|
|
379
388
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
380
389
|
});
|
|
390
|
+
board.onChange = (t, h) => event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, pii: tasks.isPii(t) });
|
|
381
391
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
382
392
|
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
383
393
|
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
@@ -414,6 +424,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
414
424
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
415
425
|
canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
416
426
|
handoff: (peer, context) => tasks.reassignForPause(peer, context),
|
|
427
|
+
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
428
|
+
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
417
429
|
resumed: (record) => {
|
|
418
430
|
if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
|
|
419
431
|
const moved = record.moved.length ? `While you were paused these moved: ${record.moved.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : "Nothing was moved while you were paused.";
|
|
@@ -424,14 +436,47 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
424
436
|
budget.setRecoveryHold(recoveryActive());
|
|
425
437
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
426
438
|
startupCleanup.push(() => budget.close());
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
439
|
+
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
440
|
+
const runId = Date.now().toString(36);
|
|
441
|
+
let turnSeq = 0;
|
|
442
|
+
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean }>();
|
|
443
|
+
// Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
|
|
444
|
+
const repo = config.snapshots.enabled ? repoOf(opts.cwd) : undefined;
|
|
445
|
+
if (config.snapshots.enabled && !repo) log("snapshots: the project is not in a git work tree, so turns are not recorded");
|
|
446
|
+
const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
447
|
+
if (turnLog) startupCleanup.push(() => turnLog.close());
|
|
448
|
+
/** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
|
|
449
|
+
const holdsPii = (peer: PeerId) => board.list().some((t) => t.owner === peer && t.state !== "approved" && t.state !== "in_review" && tasks.isPii(t));
|
|
450
|
+
/**
|
|
451
|
+
* A snapshot that fails costs the undo record of that turn and nothing else. The peer's prompt waits for it (it
|
|
452
|
+
* runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
|
|
453
|
+
* in snapshots.ts.
|
|
454
|
+
*/
|
|
455
|
+
const snap = (what: string): { tree?: string; ms: number } => {
|
|
456
|
+
const t0 = performance.now();
|
|
457
|
+
let tree: string | undefined;
|
|
458
|
+
try {
|
|
459
|
+
tree = repo && snapshot(repo);
|
|
460
|
+
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
log(`snapshot failed at ${what}: ${(error as Error).message}`);
|
|
463
|
+
}
|
|
464
|
+
return { tree, ms: Math.round(performance.now() - t0) };
|
|
465
|
+
};
|
|
466
|
+
const delta = tokenDeltas();
|
|
467
|
+
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
468
|
+
const addTokens = (peer: PeerId, n: number): number => {
|
|
469
|
+
if (n > 0) {
|
|
470
|
+
event({ type: "tokens", peer, n });
|
|
471
|
+
const turn = turns.get(peer);
|
|
472
|
+
if (turn) turn.tokens += n;
|
|
473
|
+
}
|
|
474
|
+
return n;
|
|
475
|
+
};
|
|
476
|
+
const onKimiTokens = (total: number, session: string) => {
|
|
477
|
+
const n = addTokens("kimi", delta("kimi", total, session));
|
|
478
|
+
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
431
479
|
const now = Date.now();
|
|
432
|
-
const n = total >= kimiSessionTotal ? total - kimiSessionTotal : total; // a smaller total means a new session
|
|
433
|
-
kimiSessionTotal = total;
|
|
434
|
-
if (!n) return;
|
|
435
480
|
kimiTokens.push({ at: now, n });
|
|
436
481
|
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
437
482
|
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
@@ -478,6 +523,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
478
523
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
479
524
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
480
525
|
if (piiTurn && (op === "hub_remember" || op === "hub_task_propose")) throw new Error(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
526
|
+
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
527
|
+
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
528
|
+
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
529
|
+
const target = board.get(Number(a.id));
|
|
530
|
+
if (target && !tasks.isPii(target)) throw new Error(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
|
|
531
|
+
}
|
|
481
532
|
// Lists are redacted for everyone but the on-prem worker, and only when it calls from inside this process: over the
|
|
482
533
|
// control WS anyone holding the token can claim to be "local". A board on a shared screen is a leak too, so the
|
|
483
534
|
// console reads a PII task's text deliberately, with `ahub task show <id>`.
|
|
@@ -489,8 +540,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
489
540
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
490
541
|
return overlap ? `${line(t)}\n${overlap}` : line(t);
|
|
491
542
|
}
|
|
492
|
-
case "hub_task_accept":
|
|
493
|
-
|
|
543
|
+
case "hub_task_accept": {
|
|
544
|
+
const t = tasks.accept(by, a.id, a.plan);
|
|
545
|
+
const overlap = a.plan == null ? "" : tasks.overlaps(t);
|
|
546
|
+
return overlap ? `${line(t)}\n${overlap}` : line(t);
|
|
547
|
+
}
|
|
494
548
|
case "hub_task_decline":
|
|
495
549
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
496
550
|
case "hub_task_done": {
|
|
@@ -521,6 +575,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
521
575
|
return line(await tasks.escalate(USER, a.id));
|
|
522
576
|
case "route_explain":
|
|
523
577
|
return tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
|
|
578
|
+
case "turn_revert": {
|
|
579
|
+
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
580
|
+
const turn = turnLog?.get(String(a.turn));
|
|
581
|
+
if (!turn) throw new Error(`no turn ${a.turn} recorded`);
|
|
582
|
+
if (turn.peer !== "codex" || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
583
|
+
if (turnLog!.latest("codex")?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
584
|
+
const codex = bus.peers.get("codex");
|
|
585
|
+
if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
|
|
586
|
+
// Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
|
|
587
|
+
const held = bus.stateOf("codex") !== "paused";
|
|
588
|
+
if (held) bus.pause("codex");
|
|
589
|
+
try {
|
|
590
|
+
await codex.revert(turn.native);
|
|
591
|
+
} finally {
|
|
592
|
+
if (held && !manualPaused.has("codex") && !budget.record("codex")) bus.resume("codex");
|
|
593
|
+
}
|
|
594
|
+
log(`turn_revert ${turn.id}: Codex conversation reverted to before ${turn.native}`);
|
|
595
|
+
return `Codex's conversation no longer holds turn ${turn.id}`;
|
|
596
|
+
}
|
|
524
597
|
}
|
|
525
598
|
throw new Error(`unknown task operation ${op}`);
|
|
526
599
|
}
|
|
@@ -600,12 +673,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
600
673
|
return [id, row];
|
|
601
674
|
}));
|
|
602
675
|
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
603
|
-
|
|
676
|
+
// `prePlan`: the board as a hub from before the plan column (#31, 0.8.0) digested it. An empty plan is left out, so an
|
|
677
|
+
// upgrade from such a hub still verifies the board it was handed; a real plan never matches that shape.
|
|
678
|
+
const integrity = (prePlan = false) => {
|
|
604
679
|
const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
605
|
-
const
|
|
680
|
+
const tasks = board.list().sort((a, b) => a.id - b.id);
|
|
681
|
+
const boardState = prePlan ? tasks.map(({ plan, ...t }) => (plan && Object.keys(plan).length ? { ...t, plan } : t)) : tasks;
|
|
606
682
|
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
607
683
|
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
|
|
608
684
|
};
|
|
685
|
+
/** The integrity in the shape `expected` was recorded in: the current one unless only the pre-plan shape matches. */
|
|
686
|
+
const integrityAs = (expected: unknown) => {
|
|
687
|
+
const now = integrity();
|
|
688
|
+
if (!expected || JSON.stringify(expected) === JSON.stringify(now)) return now;
|
|
689
|
+
const old = integrity(true);
|
|
690
|
+
return JSON.stringify(expected) === JSON.stringify(old) ? old : now;
|
|
691
|
+
};
|
|
609
692
|
const status = () => ({
|
|
610
693
|
projectId,
|
|
611
694
|
instanceId,
|
|
@@ -672,12 +755,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
672
755
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
673
756
|
if (e.t === "state") {
|
|
674
757
|
log(`state ${e.peer} -> ${e.state}`);
|
|
758
|
+
event({ type: "state", peer: e.peer, state: e.state });
|
|
759
|
+
const open = turns.get(e.peer);
|
|
760
|
+
// The adapter's own state: a pause shows a busy peer as paused, and its turn goes on all the same.
|
|
761
|
+
const busy = (bus.peers.get(e.peer)?.state ?? e.state) === "busy";
|
|
762
|
+
if (busy && !open) {
|
|
763
|
+
const id = `${e.peer}#${runId}.${++turnSeq}`;
|
|
764
|
+
// A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
|
|
765
|
+
// gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
|
|
766
|
+
const pii = holdsPii(e.peer);
|
|
767
|
+
const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
768
|
+
try { turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
769
|
+
turns.set(e.peer, { id, start: Date.now(), tokens: 0, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
770
|
+
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
771
|
+
} else if (!busy && open) {
|
|
772
|
+
turns.delete(e.peer);
|
|
773
|
+
let files: number | undefined;
|
|
774
|
+
let snapshotMs = open.snapshotMs;
|
|
775
|
+
if (turnLog && (open.private || holdsPii(e.peer))) {
|
|
776
|
+
try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
777
|
+
} else if (turnLog) {
|
|
778
|
+
const end = snap(`the end of ${open.id}`);
|
|
779
|
+
const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
|
|
780
|
+
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
781
|
+
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
782
|
+
try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
783
|
+
}
|
|
784
|
+
event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
|
|
785
|
+
}
|
|
675
786
|
if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
|
|
676
787
|
else offlineSince.delete(e.peer);
|
|
677
788
|
}
|
|
678
|
-
else if (e.t === "undeliverable"
|
|
679
|
-
|
|
680
|
-
|
|
789
|
+
else if (e.t === "undeliverable" || e.t === "overflow") {
|
|
790
|
+
log(e.t === "undeliverable" ? `UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}` : `OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
791
|
+
event({ type: e.t, id: e.env.id, from: e.env.from, peer: e.peer });
|
|
792
|
+
} else {
|
|
793
|
+
log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
794
|
+
const env = e.env;
|
|
795
|
+
event({ type: "envelope", id: env.id, from: env.from, ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
|
|
796
|
+
}
|
|
681
797
|
if (!stopping) writeStatus();
|
|
682
798
|
});
|
|
683
799
|
|
|
@@ -839,6 +955,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
839
955
|
}
|
|
840
956
|
if (peer === "codex") {
|
|
841
957
|
const codex = new CodexPeer("codex", {
|
|
958
|
+
onTokens: (added) => void addTokens("codex", added),
|
|
959
|
+
onTurn: (native) => {
|
|
960
|
+
const open = turns.get("codex");
|
|
961
|
+
if (open) turnLog?.native(open.id, native);
|
|
962
|
+
},
|
|
842
963
|
appPort: opts.codexAppPort,
|
|
843
964
|
proxyPort: opts.codexProxyPort,
|
|
844
965
|
bin: config.codex_bin,
|
|
@@ -1089,7 +1210,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1089
1210
|
...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
|
|
1090
1211
|
...(permissions.size ? ["pending approvals"] : []),
|
|
1091
1212
|
],
|
|
1092
|
-
integrity: { current: integrity
|
|
1213
|
+
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
1093
1214
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
1094
1215
|
const now = recoveryPeers()[peer.id];
|
|
1095
1216
|
if (recoveryPhase === "restored" || recoveryPhase === "released") return [peer.id, now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }];
|
|
@@ -1187,7 +1308,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1187
1308
|
const missing = (recoveryPeerSnapshot ?? []).filter((saved) => saved.state !== "offline" && !manualPaused.has(saved.id) && !budget.record(saved.id) && (!current[saved.id] || current[saved.id]!.state === "offline"));
|
|
1188
1309
|
if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
|
|
1189
1310
|
if (!recoveryReady()) return recoveryError("required peers or approvals are not ready");
|
|
1190
|
-
if (!restored?.integrity || JSON.stringify(restored.integrity) !== JSON.stringify(integrity
|
|
1311
|
+
if (!restored?.integrity || JSON.stringify(restored.integrity) !== JSON.stringify(integrityAs(restored.integrity))) {
|
|
1191
1312
|
return recoveryError("queue, pause, task board or budget integrity changed during recovery");
|
|
1192
1313
|
}
|
|
1193
1314
|
archiveRestartSnapshot(opts.stateDir, op);
|
|
@@ -1425,6 +1546,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1425
1546
|
await piReceipts?.close();
|
|
1426
1547
|
budget.close();
|
|
1427
1548
|
board.close();
|
|
1549
|
+
turnLog?.close();
|
|
1428
1550
|
server.stop(true);
|
|
1429
1551
|
try { bus.closeJournal(); } catch { /* the state dir is gone; the journal went with it */ }
|
|
1430
1552
|
} finally {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
|
|
4
|
+
export const EVENTS_SCHEMA = 1;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One line of `events.jsonl`, the machine-readable twin of hub.log (issue #40). Never a message body: envelopes carry
|
|
8
|
+
* ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
|
|
9
|
+
*/
|
|
10
|
+
export type HubEvent =
|
|
11
|
+
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
12
|
+
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
13
|
+
| { type: "state"; peer: string; state: string }
|
|
14
|
+
| { type: "turn_start"; peer: string; turn: string }
|
|
15
|
+
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
16
|
+
| { type: "tokens"; peer: string; n: number }
|
|
17
|
+
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
18
|
+
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
19
|
+
| { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
|
|
20
|
+
|
|
21
|
+
export type StampedEvent = HubEvent & { v: number; at: string };
|
|
22
|
+
|
|
23
|
+
/** Whether the file's last byte is something other than a newline: a crash cut its last line short. */
|
|
24
|
+
function cutShort(file: string): boolean {
|
|
25
|
+
if (!existsSync(file)) return false;
|
|
26
|
+
const fd = openSync(file, "r");
|
|
27
|
+
try {
|
|
28
|
+
const { size } = fstatSync(fd);
|
|
29
|
+
if (!size) return false;
|
|
30
|
+
const last = Buffer.alloc(1);
|
|
31
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
32
|
+
return last[0] !== 0x0a;
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An appender that never throws: the state dir can vanish under a running hub, and telemetry must not take it down. */
|
|
39
|
+
export function eventLog(file: string): (e: HubEvent) => void {
|
|
40
|
+
let first = true;
|
|
41
|
+
return (e) => {
|
|
42
|
+
try {
|
|
43
|
+
// After a crash the first line goes on a line of its own, so only the cut event is lost.
|
|
44
|
+
const lead = first && cutShort(file) ? "\n" : "";
|
|
45
|
+
first = false;
|
|
46
|
+
appendFileSync(file, `${lead}${JSON.stringify({ v: EVENTS_SCHEMA, at: new Date().toISOString(), ...e })}\n`);
|
|
47
|
+
} catch {
|
|
48
|
+
// the state dir is gone; the watchdog is stopping the hub
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Kimi's running session totals to increments: only what was added since the last update counts; a new session
|
|
55
|
+
* starts from zero whatever its first total, and a smaller total in the same session means it was reset. (Codex
|
|
56
|
+
* differences its thread totals in the adapter, which knows whether a thread was started or resumed.)
|
|
57
|
+
*/
|
|
58
|
+
export function tokenDeltas(): (peer: string, total: number, session: string) => number {
|
|
59
|
+
const last = new Map<string, { session: string; total: number }>();
|
|
60
|
+
return (peer, total, session) => {
|
|
61
|
+
const prev = last.get(peer);
|
|
62
|
+
last.set(peer, { session, total });
|
|
63
|
+
return prev?.session === session && total >= prev.total ? total - prev.total : total;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Events at or after `since` (ms), skipping lines a crash cut short.
|
|
69
|
+
* ponytail: reads the whole file, which is never rotated; rotate by month or index by time when it gets large.
|
|
70
|
+
*/
|
|
71
|
+
export function readEvents(file: string, since = 0): StampedEvent[] {
|
|
72
|
+
if (!existsSync(file)) return [];
|
|
73
|
+
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
74
|
+
if (!line.trim()) return [];
|
|
75
|
+
try {
|
|
76
|
+
const e = JSON.parse(line) as StampedEvent;
|
|
77
|
+
return Date.parse(e.at) >= since ? [e] : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `7d`, `24h`, `90m` or an ISO date, as epoch ms; undefined when it is neither. */
|
|
85
|
+
export function parseSince(text: string, now = Date.now()): number | undefined {
|
|
86
|
+
const m = /^(\d+)([dhm])$/.exec(text);
|
|
87
|
+
if (m) return now - Number(m[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2] as "d" | "h" | "m"];
|
|
88
|
+
// ISO dates only: Date.parse also takes "7" (a year in 2001), which would silently mean everything.
|
|
89
|
+
const t = /^\d{4}-\d\d-\d\d/.test(text) ? Date.parse(text) : NaN;
|
|
90
|
+
return Number.isNaN(t) ? undefined : t;
|
|
91
|
+
}
|