1c5521cf2e
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
9.8 KiB
JavaScript
305 lines
9.8 KiB
JavaScript
/**
|
|
* @file The daily cost split of `GET /api/pricing/cost`: a session's tokens are
|
|
* booked to the local days its `token_usage_hourly` rows fall on (the client's
|
|
* `tz_offset` moving an hour across midnight), the rest of its total to its
|
|
* start date, and the days add up to the total. Also `TranscriptCache`'s
|
|
* `tokensByHour` — split by each message's timestamp, kept across an
|
|
* incremental read — and the hook writer filling the table from it.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
"use strict";
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
|
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "daily-cost-")));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp } = require("../index");
|
|
const { db, stmts } = require("../db");
|
|
const TranscriptCache = require("../lib/transcript-cache");
|
|
|
|
const M = 1_000_000;
|
|
let server;
|
|
let base;
|
|
|
|
function call(method, url, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
`${base}${url}`,
|
|
{ method, headers: body ? { "content-type": "application/json" } : {} },
|
|
(res) => {
|
|
let text = "";
|
|
res.on("data", (c) => (text += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function session(id, startedAt) {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, started_at, updated_at) VALUES (?, ?, 'completed', ?, ?)"
|
|
).run(id, id, startedAt, startedAt);
|
|
}
|
|
const total = (id, input) =>
|
|
stmts.setTokenUsage.run(
|
|
id,
|
|
"daily-model",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
input,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
const hour = (id, h, input) =>
|
|
stmts.upsertHourlyUsage.run(
|
|
id,
|
|
h,
|
|
"daily-model",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
input,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
const byDate = (r) => Object.fromEntries(r.body.daily_costs.map((d) => [d.date, d.cost]));
|
|
const line = (ts, id, input) =>
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
timestamp: ts,
|
|
message: {
|
|
id,
|
|
model: "daily-model",
|
|
role: "assistant",
|
|
usage: { input_tokens: input, output_tokens: 0 },
|
|
},
|
|
});
|
|
|
|
before(async () => {
|
|
server = http.createServer(createApp());
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
// $1 per million input tokens, so a day's cost reads as millions of tokens.
|
|
const put = await call("PUT", "/api/pricing", {
|
|
model_pattern: "daily-model%",
|
|
display_name: "Daily",
|
|
input_per_mtok: 1,
|
|
output_per_mtok: 0,
|
|
cache_read_per_mtok: 0,
|
|
cache_write_per_mtok: 0,
|
|
});
|
|
assert.equal(put.status, 200);
|
|
});
|
|
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("GET /api/pricing/cost daily split", () => {
|
|
it("books each hour to its local day and the rest to the start date", async () => {
|
|
session("multi-day", "2026-10-02T03:00:00.000Z");
|
|
total("multi-day", 7 * M); // 1 M recorded before hourly rows existed
|
|
hour("multi-day", "2026-10-03T05", 2 * M);
|
|
hour("multi-day", "2026-10-03T20", 1 * M); // 03:00 on the 4th at UTC+7
|
|
hour("multi-day", "2026-10-05T01", 3 * M);
|
|
|
|
const utc = byDate(await call("GET", "/api/pricing/cost?tz_offset=0"));
|
|
assert.deepEqual(utc, { "2026-10-02": 1, "2026-10-03": 3, "2026-10-05": 3 });
|
|
|
|
const ict = await call("GET", "/api/pricing/cost?tz_offset=-420");
|
|
assert.deepEqual(byDate(ict), {
|
|
"2026-10-02": 1,
|
|
"2026-10-03": 2,
|
|
"2026-10-04": 1,
|
|
"2026-10-05": 3,
|
|
});
|
|
assert.equal(ict.body.total_cost, 7);
|
|
|
|
// `date=` prices that one local day, breakdown included.
|
|
const day = await call("GET", "/api/pricing/cost?tz_offset=-420&date=2026-10-03");
|
|
assert.equal(day.body.total_cost, 2);
|
|
assert.equal(day.body.breakdown.length, 1);
|
|
assert.equal(day.body.breakdown[0].input_tokens, 2 * M);
|
|
assert.equal((await call("GET", "/api/pricing/cost?date=10/03")).status, 400);
|
|
});
|
|
|
|
it("scales hours above the total down to it, so the days still add up", async () => {
|
|
session("over", "2026-09-01T00:00:00.000Z");
|
|
total("over", 2 * M);
|
|
hour("over", "2026-09-02T00", 2 * M);
|
|
hour("over", "2026-09-03T00", 2 * M);
|
|
const r = await call("GET", "/api/pricing/cost?tz_offset=0");
|
|
const d = byDate(r);
|
|
assert.equal(d["2026-09-01"], undefined);
|
|
assert.equal(d["2026-09-02"], 1);
|
|
assert.equal(d["2026-09-03"], 1);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/pricing/cost?lane=", () => {
|
|
it("prices only the sessions a workspace owns, including one its run holds unstamped", async () => {
|
|
const lane = require("../lib/lanes").createLane({
|
|
title: "w",
|
|
cwd: fs.mkdtempSync(path.join(ROOT, "lane-")),
|
|
tool: "claude",
|
|
});
|
|
session("owned", "2026-08-01T00:00:00.000Z");
|
|
session("held", "2026-08-01T00:00:00.000Z");
|
|
session("other", "2026-08-01T00:00:00.000Z");
|
|
db.prepare("UPDATE sessions SET lane_id = ? WHERE id = 'owned'").run(lane.id);
|
|
db.prepare(
|
|
"INSERT INTO dashboard_runs (id, lane_id, session_id, cwd, status) VALUES ('run-held', ?, 'held', '/x', 'exited')"
|
|
).run(lane.id);
|
|
total("owned", 2 * M);
|
|
total("held", 1 * M);
|
|
total("other", 5 * M);
|
|
|
|
const r = await call("GET", `/api/pricing/cost?tz_offset=0&lane=${lane.id}`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.total_cost, 3);
|
|
assert.equal((await call("GET", "/api/pricing/cost?lane=x")).status, 400);
|
|
});
|
|
});
|
|
|
|
describe("TranscriptCache tokensByHour", () => {
|
|
it("splits by message hour, counts a message once, and survives an incremental read", () => {
|
|
const file = path.join(ROOT, "t.jsonl");
|
|
fs.writeFileSync(
|
|
file,
|
|
[
|
|
line("2026-10-02T10:15:00.000Z", "m1", 100),
|
|
line("2026-10-02T10:15:01.000Z", "m1", 100), // a second block line of m1
|
|
line("2026-10-02T10:40:00.000Z", "m2", 50),
|
|
].join("\n") + "\n"
|
|
);
|
|
const cache = new TranscriptCache();
|
|
const hours = (r) =>
|
|
Object.fromEntries(Object.values(r.tokensByHour).map((b) => [b.hour, b.input]));
|
|
assert.deepEqual(hours(cache.extract(file)), { "2026-10-02T10": 150 });
|
|
fs.appendFileSync(file, line("2026-10-04T23:59:00.000Z", "m3", 7) + "\n");
|
|
assert.deepEqual(hours(cache.extract(file)), { "2026-10-02T10": 150, "2026-10-04T23": 7 });
|
|
});
|
|
|
|
it("is written by the hook writer", async () => {
|
|
const file = path.join(ROOT, "hook.jsonl");
|
|
fs.writeFileSync(
|
|
file,
|
|
[
|
|
line("2026-10-02T10:00:00.000Z", "a", 1 * M),
|
|
line("2026-10-03T10:00:00.000Z", "b", 2 * M),
|
|
].join("\n") + "\n"
|
|
);
|
|
const r = await call("POST", "/api/hooks/event", {
|
|
hook_type: "Stop",
|
|
data: { session_id: "hooked", transcript_path: file },
|
|
});
|
|
assert.equal(r.status, 200);
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT hour, input_tokens FROM token_usage_hourly WHERE session_id = 'hooked' ORDER BY hour"
|
|
)
|
|
.all();
|
|
assert.deepEqual(
|
|
rows.map((x) => [x.hour, x.input_tokens]),
|
|
[
|
|
["2026-10-02T10", 1 * M],
|
|
["2026-10-03T10", 2 * M],
|
|
]
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("writeHourly skips unchanged hours", () => {
|
|
const { writeHourly, forgetHourly } = require("../lib/hourly-usage");
|
|
const bucket = (input) => ({
|
|
hour: "2026-10-05T08",
|
|
model: "claude-opus-4-1",
|
|
speed: "standard",
|
|
geo: "",
|
|
tier: "standard",
|
|
input,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
cacheWrite1h: 0,
|
|
webSearch: 0,
|
|
webFetch: 0,
|
|
codeExec: 0,
|
|
});
|
|
const rows = () =>
|
|
db
|
|
.prepare("SELECT input_tokens FROM token_usage_hourly WHERE session_id = 'skip-hourly'")
|
|
.all()
|
|
.map((r) => r.input_tokens);
|
|
|
|
it("writes a changed hour, skips an unchanged one, and writes again once forgotten", () => {
|
|
session("skip-hourly", "2026-10-05T08:00:00.000Z");
|
|
writeHourly("skip-hourly", { a: bucket(5) });
|
|
assert.deepEqual(rows(), [5]);
|
|
writeHourly("skip-hourly", { a: bucket(9) });
|
|
assert.deepEqual(rows(), [9]);
|
|
// Deleted behind the writer's back: an unchanged bucket is not re-sent…
|
|
db.prepare("DELETE FROM token_usage_hourly WHERE session_id = 'skip-hourly'").run();
|
|
writeHourly("skip-hourly", { a: bucket(9) });
|
|
assert.deepEqual(rows(), []);
|
|
// …which is why every path that deletes hourly rows forgets them.
|
|
forgetHourly(["skip-hourly"]);
|
|
writeHourly("skip-hourly", { a: bucket(9) });
|
|
assert.deepEqual(rows(), [9]);
|
|
});
|
|
});
|
|
|
|
describe("session list cost", () => {
|
|
it("equals the session's own cost, speed and server-tool fees included", async () => {
|
|
session("list-fast", "2026-10-01T00:00:00.000Z");
|
|
// A fast-mode bucket with web searches: the list once priced only model + 4 token columns.
|
|
stmts.setTokenUsage.run(
|
|
"list-fast",
|
|
"daily-model",
|
|
"fast",
|
|
"global",
|
|
"standard",
|
|
M,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
100,
|
|
0,
|
|
0
|
|
);
|
|
const own = await call("GET", "/api/pricing/cost/list-fast");
|
|
const list = await call("GET", "/api/sessions?q=list-fast");
|
|
const row = list.body.sessions.find((s) => s.id === "list-fast");
|
|
assert.ok(own.body.total_cost > 1, "fast + web search cost more than the plain input rate");
|
|
assert.equal(row.cost, own.body.total_cost);
|
|
const byPrice = await call("GET", "/api/sessions?q=list-fast&sort_by=price");
|
|
assert.equal(byPrice.body.sessions[0].cost, own.body.total_cost);
|
|
});
|
|
});
|