toknmtr — self-hostable Claude Code usage & analytics dashboard

Server (SvelteKit + SQLite Docker container) + per-machine agent that parses
Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript
view + search gated behind SHOW_TRANSCRIPTS (hidden by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-07-08 19:33:55 +01:00
commit 71a60ab054
74 changed files with 15613 additions and 0 deletions

66
agent/cursor.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* toknmtr agent per-file byte-offset cursor.
*
* Persists, per transcript file, the byte offset up to which we've already parsed and
* pushed events, so re-runs only emit NEW bytes appended since last time. Stored as a
* single JSON file at ~/.toknmtr/cursors.json (one process at a time is assumed there's
* no file locking).
*
* Handles truncation/rotation: if a file's current size is smaller than the recorded
* offset, the file was truncated or replaced (e.g. a session id got reused, or the file
* was edited out from under us) getOffset() resets to 0 so the whole file is reparsed.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
export const CURSOR_FILE = join(homedir(), '.toknmtr', 'cursors.json');
interface CursorEntry {
offset: number;
size: number;
}
type CursorMap = Record<string, CursorEntry>;
let cache: CursorMap | null = null;
function load(): CursorMap {
if (cache) return cache;
try {
const parsed = JSON.parse(readFileSync(CURSOR_FILE, 'utf8')) as unknown;
cache = parsed && typeof parsed === 'object' ? (parsed as CursorMap) : {};
} catch {
cache = {};
}
return cache;
}
/** Persist the in-memory cursor map to disk. Call once after a batch of setOffset() calls. */
export function save(): void {
const map = load();
mkdirSync(dirname(CURSOR_FILE), { recursive: true });
writeFileSync(CURSOR_FILE, JSON.stringify(map, null, 2));
}
/**
* Byte offset to resume reading `filePath` from, given its current size on disk.
* Returns 0 (full reparse) if there's no recorded cursor, or if the file shrank since
* last run (truncated/rotated).
*/
export function getOffset(filePath: string, currentSize: number): number {
const entry = load()[filePath];
if (!entry) return 0;
if (entry.offset > currentSize) return 0;
return entry.offset;
}
/** Record the new offset/size for `filePath` in memory. Call save() to persist. */
export function setOffset(filePath: string, offset: number, size: number): void {
load()[filePath] = { offset, size };
}
/** Forget a file's cursor entirely (forces a full reparse on next run). */
export function resetOffset(filePath: string): void {
delete load()[filePath];
}

74
agent/hooks/toknmtr-capture.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# toknmtr-capture.sh — Claude Code hook that triggers an incremental toknmtr agent sweep.
#
# Registered (by ops/install-hook.sh) under the Claude Code 'Stop' hook event, so it fires
# at the end of every assistant turn — see ops/install-hook.sh for the Stop-vs-SessionEnd
# tradeoff writeup.
#
# FAIL-OPEN CONTRACT — this script must NEVER block, slow down, or fail the Claude Code
# session it's attached to:
# - The actual agent run (`node agent/run.ts --once`) is launched with `nohup ... &` and
# disowned, with stdio redirected to a log file (never inherited from the hook's own
# stdio), so this script returns to Claude Code in a handful of milliseconds — it does
# NOT wait for the parse+push to finish.
# - `timeout` wraps the backgrounded agent run so a hung/unreachable server can never
# leave an orphaned process running forever.
# - Every prerequisite (project dir, config file, node, timeout) is individually checked;
# any miss just skips capture for this turn silently.
# - This script prints NOTHING to its own stdout/stderr (Claude Code parses Stop-hook
# stdout as potential JSON — e.g. {"decision":"block"} would force the session to keep
# going — so silence here is required, not just polite). All diagnostic output goes to
# $LOG_FILE instead.
# - Always exits 0, intentionally not using `set -e`: every step below is already
# individually guarded, so a failure anywhere means "skip capture this turn", never
# "fail the hook" / block the Stop event.
#
# Config: ~/.toknmtr/env (TOKNMTR_URL, TOKNMTR_TOKEN — see ops/README.md). Kept out of
# ~/.claude/settings.json so secrets aren't sitting in a file that's more likely to be
# shared, synced, or dumped for support/debugging.
#
# Override knobs (env, all optional):
# TOKNMTR_PROJECT_DIR path to the toknmtr repo checkout (default: ~/claude/projects/toknmtr)
# TOKNMTR_ENV_FILE path to the config file (default: ~/.toknmtr/env)
# TOKNMTR_LOG_FILE where backgrounded output is logged (default: ~/.toknmtr/capture.log)
# TOKNMTR_HOOK_TIMEOUT_S max seconds the backgrounded sweep may run (default: 25)
PROJECT_DIR="${TOKNMTR_PROJECT_DIR:-$HOME/claude/projects/toknmtr}"
CONFIG_FILE="${TOKNMTR_ENV_FILE:-$HOME/.toknmtr/env}"
LOG_FILE="${TOKNMTR_LOG_FILE:-$HOME/.toknmtr/capture.log}"
TIMEOUT_S="${TOKNMTR_HOOK_TIMEOUT_S:-25}"
# Claude Code hooks receive a JSON payload on stdin describing the event. We don't need its
# contents (the agent re-walks transcripts itself from disk), but drain it anyway so we
# never leave the pipe half-read.
cat >/dev/null 2>&1 || true
# Bail out quietly (still exit 0) if any prerequisite is missing — never surface a hook
# failure to the session over a merely-unconfigured machine.
[ -d "$PROJECT_DIR" ] || exit 0
[ -f "$PROJECT_DIR/agent/run.ts" ] || exit 0
[ -f "$CONFIG_FILE" ] || exit 0
command -v node >/dev/null 2>&1 || exit 0
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || exit 0
# Build the backgrounded command as a single string for `bash -c` so it can `cd`, source
# the config file (exporting TOKNMTR_URL/TOKNMTR_TOKEN into its own environment), and then
# exec node — all inside the detached child, never the foreground hook process.
read -r -d '' INNER_CMD <<EOF || true
cd "$PROJECT_DIR" || exit 0
set -a
. "$CONFIG_FILE"
set +a
exec node --experimental-strip-types agent/run.ts --once
EOF
if command -v timeout >/dev/null 2>&1; then
nohup timeout "${TIMEOUT_S}s" bash -c "$INNER_CMD" >>"$LOG_FILE" 2>&1 &
else
nohup bash -c "$INNER_CMD" >>"$LOG_FILE" 2>&1 &
fi
disown 2>/dev/null || true
exit 0

384
agent/parse.ts Normal file
View file

@ -0,0 +1,384 @@
/**
* toknmtr agent JSONL parser.
*
* Walks ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl and turns every line
* into a typed event the server can ingest. NOT bundled by Vite this runs
* standalone via the capture hook + cron (e.g. `node --experimental-strip-types
* agent/run.ts`).
*
* Real JSONL line shape (see CLAUDE.md / the shared agent contract):
* - Not every physical line is a trackable event: `mode`, `file-history-snapshot`,
* `attachment`, `ai-title`, `last-prompt`, etc. carry no top-level `uuid` and are
* skipped (parseLine returns null), but they (and every other line) may still carry
* `sessionId`/`cwd`/`gitBranch`/`version`/`entrypoint`/`timestamp`, which we scan for
* session metadata regardless of whether the line itself becomes an event.
* - Assistant turns STREAM: the same `message.id` repeats across several consecutive
* physical lines (each with its own unique top-level `uuid`, chained via `parentUuid`),
* each carrying a different slice of `message.content` (one block per line in
* practice: a `thinking` line, then a `text` line, then one `tool_use` line per tool
* call). Every physical line is still stored as its own `events` row.
* - `message.content` is either a plain string (typed user prompt) or an array of
* blocks: `{type:'text', text}`, `{type:'thinking', thinking}`,
* `{type:'tool_use', id, name, input}`, and on USER lines
* `{type:'tool_result', tool_use_id, content, is_error}` (content is a string or a
* content-block array).
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
export const TRANSCRIPT_ROOT = join(homedir(), '.claude', 'projects');
export interface ParsedEvent {
host: string;
session_id: string;
uuid: string;
parent_uuid: string | null;
ts_utc: string;
type: string;
role: string | null;
model: string | null;
request_id: string | null;
message_id: string | null;
is_sidechain: boolean;
/** True for the one row per (session_id, message_id, request_id) group that should
* count toward token totals the final/max-output_tokens streamed line. */
is_usage_canonical: boolean;
stop_reason: string | null;
/** ms between this line's timestamp and its parent line's timestamp (assistant lines
* only, when the parent is known within the parsed chunk). */
latency_ms: number | null;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
web_search_requests: number | null;
web_fetch_requests: number | null;
/** Flattened visible text (joined `text` content blocks, or the raw string content of
* a plain-string user message). Does NOT include `thinking` or `tool_result` content. */
text: string | null;
}
/** Mirrors the `tool_calls` table. */
export interface ToolCall {
host: string;
session_id: string;
tool_use_id: string;
event_uuid: string | null;
tool_name: string;
input_json: string | null;
is_error: boolean | null;
result_bytes: number | null;
duration_ms: number | null;
ts_utc: string | null;
}
/** Mirrors the `sessions` table. */
export interface SessionMeta {
host: string;
session_id: string;
project: string | null;
git_branch: string | null;
cc_version: string | null;
entrypoint: string | null;
started_at: string | null;
ended_at: string | null;
}
interface ContentBlock {
type?: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
}
function asContentBlocks(content: unknown): ContentBlock[] {
return Array.isArray(content) ? (content as ContentBlock[]) : [];
}
/** Join all `text`-type content blocks, or return a plain-string message as-is. */
function flattenText(content: unknown): string | null {
if (typeof content === 'string') return content.length > 0 ? content : null;
const texts = asContentBlocks(content)
.filter(
(b): b is ContentBlock & { text: string } => b.type === 'text' && typeof b.text === 'string'
)
.map((b) => b.text);
return texts.length > 0 ? texts.join('\n\n') : null;
}
/** Byte length of tool_result content, which may be a string or a content-block array. */
function byteLengthOf(content: unknown): number {
if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
if (content === undefined || content === null) return 0;
try {
return Buffer.byteLength(JSON.stringify(content), 'utf8');
} catch {
return 0;
}
}
interface ParsedToolUse {
tool_use_id: string;
tool_name: string;
input_json: string;
}
interface ParsedToolResult {
tool_use_id: string;
is_error: boolean;
result_bytes: number;
}
export interface LineParseResult {
event: ParsedEvent;
toolUses: ParsedToolUse[];
toolResults: ParsedToolResult[];
}
/**
* Parse a single JSONL line into a ParsedEvent (plus any tool_use/tool_result blocks it
* carries), or null if the line isn't a trackable transcript event (no `type`/`uuid`) or
* isn't valid JSON at all (workflow journal files, a truncated in-flight line, etc.).
*/
export function parseLine(host: string, line: string): LineParseResult | null {
const trimmed = line.trim();
if (!trimmed) return null;
let d: Record<string, unknown>;
try {
d = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return null;
}
const type = d.type as string | undefined;
if (!type || typeof d.uuid !== 'string') return null;
const message = (d.message ?? {}) as Record<string, unknown>;
const usage = (message.usage ?? {}) as Record<string, unknown>;
const serverTool = (usage.server_tool_use ?? {}) as Record<string, unknown>;
const content = message.content;
const toolUses: ParsedToolUse[] = [];
const toolResults: ParsedToolResult[] = [];
for (const block of asContentBlocks(content)) {
if (
block.type === 'tool_use' &&
typeof block.id === 'string' &&
typeof block.name === 'string'
) {
toolUses.push({
tool_use_id: block.id,
tool_name: block.name,
input_json: JSON.stringify(block.input ?? {})
});
} else if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
toolResults.push({
tool_use_id: block.tool_use_id,
is_error: Boolean(block.is_error),
result_bytes: byteLengthOf(block.content)
});
}
}
const numOrNull = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) ? v : null;
const event: ParsedEvent = {
host,
session_id: (d.sessionId as string) ?? '',
uuid: d.uuid,
parent_uuid: typeof d.parentUuid === 'string' ? d.parentUuid : null,
ts_utc: typeof d.timestamp === 'string' ? d.timestamp : '',
type,
role: typeof message.role === 'string' ? message.role : null,
model: typeof message.model === 'string' ? message.model : null,
request_id: typeof d.requestId === 'string' ? d.requestId : null,
message_id: typeof message.id === 'string' ? message.id : null,
is_sidechain: Boolean(d.isSidechain),
is_usage_canonical: false, // set by parseTranscript, which sees the whole batch
stop_reason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
latency_ms: null, // set by parseTranscript, which has sibling-line context
input_tokens: numOrNull(usage.input_tokens),
output_tokens: numOrNull(usage.output_tokens),
cache_creation_tokens: numOrNull(usage.cache_creation_input_tokens),
cache_read_tokens: numOrNull(usage.cache_read_input_tokens),
web_search_requests: numOrNull(serverTool.web_search_requests),
web_fetch_requests: numOrNull(serverTool.web_fetch_requests),
text: flattenText(content)
};
return { event, toolUses, toolResults };
}
export interface ParseResult {
events: ParsedEvent[];
toolCalls: ToolCall[];
session: SessionMeta;
}
/**
* Parse a chunk of JSONL text (a whole file, or just the bytes appended since the last
* cursor position see cursor.ts/run.ts) into events, tool calls, and session metadata.
*
* `sessionIdFallback` should be the session id derived from the filename (transcripts are
* named `<session-id>.jsonl`); it's used for lines that for some reason omit `sessionId`,
* and as the session's id before any line with `sessionId` has been seen.
*
* Session metadata (project/branch/version/entrypoint/started_at/ended_at) is scanned
* across ALL lines in the chunk, including ones that aren't trackable events themselves
* (e.g. the leading `mode`/`file-history-snapshot` lines carry `cwd`/`gitBranch`). On an
* incremental (non-backfill) chunk that doesn't include the start of the file, some of
* these fields may come back null the ingest server COALESCEs session fields so that's
* safe (an earlier sweep's non-null values are preserved).
*/
export function parseTranscript(
host: string,
sessionIdFallback: string,
text: string
): ParseResult {
const events: ParsedEvent[] = [];
const toolCalls: ToolCall[] = [];
const tsByUuid = new Map<string, string>();
const pendingToolCalls = new Map<string, ToolCall>();
const usageGroups = new Map<string, ParsedEvent[]>();
let sessionId = sessionIdFallback;
let project: string | null = null;
let gitBranch: string | null = null;
let ccVersion: string | null = null;
let entrypoint: string | null = null;
let minTs: string | null = null;
let maxTs: string | null = null;
for (const rawLine of text.split('\n')) {
const trimmed = rawLine.trim();
if (!trimmed) continue;
let d: Record<string, unknown>;
try {
d = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue; // malformed/truncated line — skip gracefully
}
// Pull session metadata from every line that has it, not just trackable events.
if (typeof d.sessionId === 'string' && d.sessionId.length > 0) sessionId = d.sessionId;
if (typeof d.cwd === 'string') project = d.cwd;
if (typeof d.gitBranch === 'string') gitBranch = d.gitBranch;
if (typeof d.version === 'string') ccVersion = d.version;
if (typeof d.entrypoint === 'string') entrypoint = d.entrypoint;
if (typeof d.timestamp === 'string' && d.timestamp.length > 0) {
if (!minTs || d.timestamp < minTs) minTs = d.timestamp;
if (!maxTs || d.timestamp > maxTs) maxTs = d.timestamp;
}
const parsed = parseLine(host, trimmed);
if (!parsed) continue;
const { event, toolUses, toolResults } = parsed;
if (!event.session_id) event.session_id = sessionId;
tsByUuid.set(event.uuid, event.ts_utc);
if (event.type === 'assistant' && event.parent_uuid && event.ts_utc) {
const parentTs = tsByUuid.get(event.parent_uuid);
if (parentTs) {
const dt = Date.parse(event.ts_utc) - Date.parse(parentTs);
if (Number.isFinite(dt) && dt >= 0) event.latency_ms = dt;
}
}
events.push(event);
if (event.message_id) {
const key = `${event.session_id}${event.message_id}${event.request_id ?? ''}`;
const group = usageGroups.get(key);
if (group) group.push(event);
else usageGroups.set(key, [event]);
}
for (const tu of toolUses) {
pendingToolCalls.set(tu.tool_use_id, {
host,
session_id: event.session_id,
tool_use_id: tu.tool_use_id,
event_uuid: event.uuid,
tool_name: tu.tool_name,
input_json: tu.input_json,
is_error: null,
result_bytes: null,
duration_ms: null,
ts_utc: event.ts_utc || null
});
}
for (const tr of toolResults) {
const call = pendingToolCalls.get(tr.tool_use_id);
if (call) {
call.is_error = tr.is_error;
call.result_bytes = tr.result_bytes;
if (call.ts_utc && event.ts_utc) {
const dt = Date.parse(event.ts_utc) - Date.parse(call.ts_utc);
if (Number.isFinite(dt) && dt >= 0) call.duration_ms = dt;
}
pendingToolCalls.delete(tr.tool_use_id);
toolCalls.push(call);
} else {
// The matching tool_use was outside this chunk (e.g. parsed in a previous
// incremental sweep). Still record what we know — the server merges
// tool_calls rows by (host, session_id, tool_use_id) via COALESCE, so this
// won't blank out the tool_name/input_json captured earlier.
toolCalls.push({
host,
session_id: event.session_id,
tool_use_id: tr.tool_use_id,
event_uuid: null,
tool_name: 'unknown',
input_json: null,
is_error: tr.is_error,
result_bytes: tr.result_bytes,
duration_ms: null,
ts_utc: event.ts_utc || null
});
}
}
}
// tool_use blocks still awaiting their result at the end of this chunk — record them
// now (result_bytes/is_error/duration_ms stay null; a later sweep fills them in).
for (const call of pendingToolCalls.values()) toolCalls.push(call);
// Usage dedup: exactly one row per (session_id, message_id, request_id) group is
// canonical — the max-output_tokens row, tie-broken toward the later (final) line.
for (const group of usageGroups.values()) {
let best: ParsedEvent | null = null;
for (const ev of group) {
if (
!best ||
(ev.output_tokens ?? -1) > (best.output_tokens ?? -1) ||
((ev.output_tokens ?? -1) === (best.output_tokens ?? -1) && ev.ts_utc >= best.ts_utc)
) {
best = ev;
}
}
if (best) best.is_usage_canonical = true;
}
const session: SessionMeta = {
host,
session_id: sessionId,
project,
git_branch: gitBranch,
cc_version: ccVersion,
entrypoint,
started_at: minTs,
ended_at: maxTs
};
return { events, toolCalls, session };
}

111
agent/push.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* toknmtr agent pusher.
*
* POSTs parsed batches to the server's /api/ingest. The ingest endpoint
* (src/routes/api/ingest/+server.ts) accepts `{ host?, events, toolCalls?, sessions? }`
* events are the documented minimal contract; toolCalls/sessions are additional top-level
* keys the ingest agent reads to populate the tool_calls/sessions tables. Idempotent
* upsert on the server means re-pushing (e.g. retrying after a failed request) is always
* safe. Config via env: TOKNMTR_URL, TOKNMTR_TOKEN.
*/
import type { ParsedEvent, SessionMeta, ToolCall } from './parse.ts';
import type { UsageGauges } from './usage.ts';
const URL_BASE = process.env.TOKNMTR_URL ?? 'http://localhost:3001';
const TOKEN = process.env.TOKNMTR_TOKEN ?? '';
/** Max events per ingest request keeps request bodies (and the server's single
* transaction per request) a reasonable size for large backfills. Events carry raw
* prompt/response text, so a batch of 500 can run into many MB; 200 keeps each POST
* comfortably under the server's BODY_SIZE_LIMIT (see ops/README.md set it generously,
* e.g. BODY_SIZE_LIMIT=64M, on the deployed container). */
const CHUNK_SIZE = 200;
export interface PushBatch {
host?: string;
events: ParsedEvent[];
toolCalls?: ToolCall[];
sessions?: SessionMeta[];
}
export interface PushResult {
ok: boolean;
received: number;
requests: number;
}
interface IngestResponse {
ok: boolean;
events?: number;
tool_calls?: number;
sessions?: number;
received?: number; // older/stub server shape
}
async function postChunk(body: Record<string, unknown>): Promise<number> {
const res = await fetch(`${URL_BASE}/api/ingest`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`ingest failed: ${res.status} ${await res.text()}`);
const parsed = (await res.json()) as IngestResponse;
return parsed.events ?? parsed.received ?? 0;
}
/**
* Push a full batch (events + tool calls + session metadata), chunking `events` into
* groups of CHUNK_SIZE per request. toolCalls/sessions (typically much smaller) are sent
* once, attached to the first request.
*/
export async function pushBatch(batch: PushBatch): Promise<PushResult> {
const { host, events, toolCalls = [], sessions = [] } = batch;
if (events.length === 0 && toolCalls.length === 0 && sessions.length === 0) {
return { ok: true, received: 0, requests: 0 };
}
let received = 0;
let requests = 0;
const chunkCount = events.length > 0 ? Math.ceil(events.length / CHUNK_SIZE) : 1;
for (let i = 0; i < chunkCount; i++) {
const chunk = events.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const body: Record<string, unknown> = { events: chunk };
if (host) body.host = host;
if (i === 0) {
if (toolCalls.length > 0) body.toolCalls = toolCalls;
if (sessions.length > 0) body.sessions = sessions;
}
received += await postChunk(body);
requests++;
}
return { ok: true, received, requests };
}
/** Convenience wrapper for pushing just events (no tool calls / session metadata). */
export async function pushEvents(
events: ParsedEvent[]
): Promise<{ ok: boolean; received: number }> {
const result = await pushBatch({ events });
return { ok: result.ok, received: result.received };
}
/**
* POST a single subscription-usage gauge reading to the server's /api/usage endpoint.
* Returns true on a 2xx. Caller decides whether/when to scrape (see agent/usage.ts) this
* just ships whatever it's handed. Idempotent on the server (upsert by host + ts_utc).
*/
export async function pushUsageGauges(gauges: UsageGauges): Promise<boolean> {
const res = await fetch(`${URL_BASE}/api/usage`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(gauges)
});
if (!res.ok) throw new Error(`usage push failed: ${res.status} ${await res.text()}`);
return true;
}

220
agent/run.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* toknmtr agent CLI entry point.
*
* Walks TRANSCRIPT_ROOT recursively for *.jsonl transcripts (including subagent
* transcripts under `/subagents/`, which carry real Task/workflow sub-agent usage
* often Sonnet/Haiku attributed to their parent session; only workflow `journal.jsonl`
* orchestration files are skipped), parses only the bytes appended since the last
* run (per-file cursor, see cursor.ts), and pushes the resulting events/tool calls/session
* metadata to the server in one sweep.
*
* Usage:
* node --experimental-strip-types agent/run.ts [--once] [--backfill] [--interval=<ms>]
*
* --once Run a single sweep and exit (default behavior is a continuous loop that
* sweeps every --interval ms until killed).
* --backfill Ignore stored cursors and reparse every transcript from byte 0 (cursors
* are still updated afterward, so later normal runs resume incrementally).
* Implies a single sweep (the point of a backfill is one full catch-up
* pass, not a repeating one). Safe to re-run ingest is idempotent.
* --interval Sweep interval in ms for continuous mode (default 60000).
*
* Env: TOKNMTR_URL, TOKNMTR_TOKEN (see push.ts). host = os.hostname().
*/
import { closeSync, openSync, readSync, readdirSync, statSync, type Dirent } from 'node:fs';
import { hostname } from 'node:os';
import { basename, join } from 'node:path';
import { getOffset, save as saveCursors, setOffset } from './cursor.ts';
import {
parseTranscript,
TRANSCRIPT_ROOT,
type ParsedEvent,
type SessionMeta,
type ToolCall
} from './parse.ts';
import { pushBatch, pushUsageGauges } from './push.ts';
import { fetchUsageGauges } from './usage.ts';
const HOST = hostname();
const JOURNAL_NAME = 'journal.jsonl';
const DEFAULT_INTERVAL_MS = 60_000;
/** How often the subscription-% gauge scrape runs in continuous mode (it's slow + spawns a
* real `claude` session, so it must NOT run every sweep mirrors the widget's 5-min cadence). */
const USAGE_INTERVAL_MS = 5 * 60_000;
interface Args {
backfill: boolean;
once: boolean;
intervalMs: number;
}
function parseArgs(argv: string[]): Args {
const backfill = argv.includes('--backfill');
const once = argv.includes('--once');
const intervalArg = argv.find((a) => a.startsWith('--interval='));
const parsedInterval = intervalArg ? Number(intervalArg.slice('--interval='.length)) : NaN;
const intervalMs =
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval : DEFAULT_INTERVAL_MS;
return { backfill, once, intervalMs };
}
function walk(dir: string, out: string[]): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return; // root doesn't exist yet (fresh machine, no transcripts) — nothing to do
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
}
}
/**
* All transcript files under TRANSCRIPT_ROOT. Includes subagent transcripts
* (`.../subagents/agent-<id>.jsonl`) real Task/workflow sub-agent usage that attributes
* to the parent session via each line's own `sessionId`. Only workflow `journal.jsonl`
* files are excluded: they carry orchestration bookkeeping (`type:"started"` lines with no
* `uuid`), not message events, so they'd yield zero events anyway.
*/
function listTranscripts(): string[] {
const out: string[] = [];
walk(TRANSCRIPT_ROOT, out);
return out.filter((f) => basename(f) !== JOURNAL_NAME);
}
interface NewBytes {
text: string;
newOffset: number;
}
/**
* Read the byte range [offset, size) of `path` and trim it to the last complete line
* (i.e. up to and including the last `\n`), so a file mid-write never has a partial JSON
* line handed to the parser. The trailing partial line (if any) is left for next time
* newOffset stays before it.
*/
function readNewLines(path: string, offset: number, size: number): NewBytes {
if (offset >= size) return { text: '', newOffset: offset };
const length = size - offset;
const buf = Buffer.alloc(length);
const fd = openSync(path, 'r');
try {
readSync(fd, buf, 0, length, offset);
} finally {
closeSync(fd);
}
const lastNewline = buf.lastIndexOf(0x0a); // '\n'
if (lastNewline === -1) return { text: '', newOffset: offset };
return {
text: buf.subarray(0, lastNewline + 1).toString('utf8'),
newOffset: offset + lastNewline + 1
};
}
interface CursorUpdate {
path: string;
offset: number;
size: number;
}
async function sweep(backfill: boolean): Promise<void> {
const files = listTranscripts();
const events: ParsedEvent[] = [];
const toolCalls: ToolCall[] = [];
const sessions: SessionMeta[] = [];
const cursorUpdates: CursorUpdate[] = [];
let filesWithNewData = 0;
for (const path of files) {
let size: number;
try {
size = statSync(path).size;
} catch {
continue; // file disappeared mid-walk — skip
}
const offset = backfill ? 0 : getOffset(path, size);
const { text, newOffset } = readNewLines(path, offset, size);
if (!text) continue;
const sessionIdFallback = basename(path, '.jsonl');
const result = parseTranscript(HOST, sessionIdFallback, text);
events.push(...result.events);
toolCalls.push(...result.toolCalls);
if (result.events.length > 0 || result.toolCalls.length > 0) sessions.push(result.session);
cursorUpdates.push({ path, offset: newOffset, size });
filesWithNewData++;
}
const pushResult = await pushBatch({ host: HOST, events, toolCalls, sessions });
// Only commit cursors after a successful push, so a failed/unreachable-server push
// gets retried (nothing is silently lost) on the next sweep. This runs even after a
// --backfill sweep (which ignored the *old* cursor values when deciding where to start
// reading) so subsequent normal runs pick up incrementally from here rather than
// re-walking full history every time.
for (const u of cursorUpdates) setOffset(u.path, u.offset, u.size);
if (cursorUpdates.length > 0) saveCursors();
console.log(
`toknmtr agent: scanned ${files.length} transcript(s), ${filesWithNewData} with new data, ` +
`pushed ${pushResult.received} event(s) in ${pushResult.requests} request(s) ` +
`(${toolCalls.length} tool call(s), ${sessions.length} session(s)).`
);
}
/**
* Best-effort subscription-% gauge scrape + push. Never throws a missing PTY driver, no
* `claude` on PATH, or an unreachable server must not break the transcript sweep. Disabled
* entirely by setting TOKNMTR_NO_USAGE=1 (e.g. on a headless container with no `claude`).
*/
async function sweepUsage(): Promise<void> {
if (process.env.TOKNMTR_NO_USAGE === '1') return;
try {
const gauges = await fetchUsageGauges();
if (!gauges) return;
await pushUsageGauges(gauges);
console.log(
`toknmtr agent: pushed usage gauges (session=${gauges.session_pct ?? ''}% ` +
`week=${gauges.week_all_pct ?? ''}% sonnet=${gauges.week_sonnet_pct ?? ''}%).`
);
} catch (err) {
console.error('toknmtr agent: usage gauge scrape/push failed (non-fatal):', err);
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// A backfill is a pure historical-transcript catch-up: skip the (slow, live-only) gauge scrape.
if (args.backfill) {
await sweep(true);
return;
}
if (args.once) {
await sweep(false);
await sweepUsage();
return;
}
// Continuous mode: sweep transcripts every --interval, scrape usage gauges on a slower cadence.
let lastUsageAt = 0;
for (;;) {
await sweep(false);
if (Date.now() - lastUsageAt >= USAGE_INTERVAL_MS) {
await sweepUsage();
lastUsageAt = Date.now();
}
await new Promise((resolve) => setTimeout(resolve, args.intervalMs));
}
}
main().catch((err: unknown) => {
console.error('toknmtr agent failed:', err);
process.exitCode = 1;
});

235
agent/usage.ts Normal file
View file

@ -0,0 +1,235 @@
/**
* toknmtr agent `claude /usage` PTY-driven scraper.
*
* Ports `claude-usage-widget`'s `src-tauri/src/cli_usage.rs` (PTY-drive sequence + bar
* parsing) to pure Node, without the `node-pty` native dependency. We don't have a true
* PTY API in plain Node, so we shell out to a PTY-shim helper `script` (util-linux,
* preferred) or `unbuffer` (expect) and drive `claude` through it. Both make `claude`
* believe it's attached to a real terminal, which it needs in order to render the
* interactive `/usage` TUI instead of falling back to a non-interactive mode.
*
* Sequence (mirrors the Rust reference): spawn wait ~3.5s for the TUI to finish its
* startup render send "/usage\r" drain output until it goes quiet for >1.2s (or a
* 20s deadline trips) send "/exit\r" as a best-effort clean shutdown kill the child.
* The captured bytes are then ANSI-stripped and the three rendered bars ("Current
* session", "Current week (all models)", "Current week (Sonnet only)") are parsed out
* with the same `NN% used` regex the Rust version uses.
*
* ASSUMPTIONS / things to watch if this breaks:
* - Verified against a live `script -qfc "claude" /dev/null` capture (Claude Code
* v2.1.197) on 2026-07-01: the rendered frame uses bare `\r` + a cursor-down escape
* (`\x1b[1B`) in place of `\n` for most line breaks, *not* `\r\n`. Once the CSI
* sequences are stripped, only the bare `\r` survives so `stripAnsiCollapse` here
* splits on `\r\n`, lone `\r`, *and* `\n` (the Rust version relies on Rust's
* `str::lines()`, which only recognizes `\n`/`\r\n` that's fine over there because
* `portable-pty` apparently surfaces real `\n`s; it would NOT be fine against the
* `script`-captured stream this file actually sees, so don't port that exact
* behavior back unmodified).
* - The "Current week (Sonnet only)" section is genuinely optional (absent entirely in
* the live capture above) `week_sonnet_pct` legitimately being `null` is expected,
* not a parse failure.
* - `unbuffer` (expect) is supported as a fallback driver per the task spec, but was
* NOT available in the dev sandbox to test against; only `script` was exercised live.
* - This is brittle to Anthropic changing the rendered `/usage` output, same caveat as
* the widget. See that project's memory.md for prior gotchas.
*
* Standalone test: `node --experimental-strip-types agent/usage.ts`
*/
import { spawn, spawnSync } from 'node:child_process';
import { hostname } from 'node:os';
export interface UsageGauges {
host: string;
ts_utc: string;
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
}
const STARTUP_DELAY_MS = 3500; // let the TUI finish its startup render before typing
const POLL_INTERVAL_MS = 700; // how often we check for new output while draining
const QUIET_PERIOD_MS = 1200; // no new bytes for this long => render is done
const TOTAL_TIMEOUT_MS = 20000; // hard cap on the whole drain phase
const EXIT_DRAIN_MS = 500; // grace period after sending /exit before we kill
function commandExists(name: string): boolean {
try {
return spawnSync('which', [name], { stdio: 'ignore' }).status === 0;
} catch {
return false;
}
}
/** Pick a PTY-shim driver for `claude`. Returns argv, or null if neither is installed. */
function pickDriver(): string[] | null {
if (commandExists('script')) {
// util-linux script: -q quiet (no "Script started/done" banner), -f flush output as
// written, -c <command> run this under a pty. Typescript log target is /dev/null —
// we only care about the copy `script` also streams to its own stdout.
return ['script', '-qfc', 'claude', '/dev/null'];
}
if (commandExists('unbuffer')) {
return ['unbuffer', 'claude'];
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Spawn the driver, type "/usage", capture the raw (ANSI-laden) bytes, then kill it. */
async function driveClaudeUsage(argv: string[]): Promise<Buffer> {
const [cmd, ...args] = argv;
const child = spawn(cmd, args, {
env: { ...process.env, TERM: 'xterm-256color' }
});
let out = Buffer.alloc(0);
let lastGrowth = Date.now();
const onData = (chunk: Buffer) => {
out = Buffer.concat([out, chunk]);
lastGrowth = Date.now();
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
// Swallow spawn-time errors here (e.g. ENOENT racing past commandExists) — the
// caller's try/catch around fetchUsageGauges is the real backstop, but an
// unhandled 'error' event on the child would otherwise crash the process.
child.on('error', () => {});
try {
// 1. Let the TUI finish its startup render.
await sleep(STARTUP_DELAY_MS);
// 2. Send /usage.
child.stdin.write('/usage\r');
// 3. Drain until output goes quiet or we hit the deadline.
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
for (;;) {
await sleep(POLL_INTERVAL_MS);
if (Date.now() - lastGrowth > QUIET_PERIOD_MS) break;
if (Date.now() > deadline) break;
}
// 4. Best-effort clean exit, then a short grace drain.
child.stdin.write('/exit\r');
await sleep(EXIT_DRAIN_MS);
} finally {
child.stdout.off('data', onData);
child.stderr.off('data', onData);
child.kill('SIGKILL');
}
return out;
}
// CSI: ESC [ ... <terminator>; OSC: ESC ] ... BEL; DCS/SOS/PM/APC: ESC P|X|^|_ ... ESC \
// eslint-disable-next-line no-control-regex -- matching raw ANSI control bytes is the point
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\x1b[PX^_][\s\S]*?\x1b\\/g;
/**
* Strip ANSI escapes and collapse repeated/blank lines (TUIs redraw the same content
* many times). See the file-header note on why this splits on bare `\r` too, not just
* `\r\n`/`\n`.
*/
export function stripAnsiCollapse(raw: Buffer): string {
let text = raw.toString('utf8').replace(CSI_RE, '');
// Drop stray BEL / lone ESC bytes left over from any sequence the regex didn't match.
// eslint-disable-next-line no-control-regex -- matching raw ANSI control bytes is the point
text = text.replace(/[\x07\x1b]/g, '');
const deduped: string[] = [];
for (const rawLine of text.split(/\r\n|\r|\n/)) {
const trimmed = rawLine.replace(/\s+$/, '');
if (deduped.length > 0 && deduped[deduped.length - 1] === trimmed) continue;
deduped.push(trimmed);
}
const compressed: string[] = [];
let prevBlank = false;
for (const line of deduped) {
const blank = line.length === 0;
if (blank && prevBlank) continue;
compressed.push(line);
prevBlank = blank;
}
return compressed.join('\n');
}
const PCT_RE = /(\d{1,3})\s*%\s*used/;
/** Find `label`'s heading line, then look at the next few lines for "NN% used". */
function findSectionPct(lines: string[], label: string): number | null {
const normLabel = label.replace(/\s+/g, '');
const idx = lines.findIndex((l) => {
const t = l.trim();
return t === label || t.replace(/\s+/g, '') === normLabel;
});
if (idx === -1) return null;
for (const line of lines.slice(idx + 1, idx + 7)) {
const m = PCT_RE.exec(line);
if (m) {
const n = Number.parseInt(m[1], 10);
return Number.isFinite(n) ? n : null;
}
}
return null;
}
function parseUsageText(stripped: string): {
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
} {
const lines = stripped.split('\n');
return {
session_pct: findSectionPct(lines, 'Current session'),
week_all_pct: findSectionPct(lines, 'Current week (all models)'),
week_sonnet_pct: findSectionPct(lines, 'Current week (Sonnet only)')
};
}
/**
* Fetch the three subscription-usage gauges by PTY-driving `claude /usage`.
*
* Best-effort: returns null (never throws) if no PTY-shim driver is installed, if
* `claude` isn't reachable, or if the output couldn't be parsed at all. This must never
* break the main agent run.
*/
export async function fetchUsageGauges(): Promise<UsageGauges | null> {
try {
const argv = pickDriver();
if (!argv) return null;
const raw = await driveClaudeUsage(argv);
if (raw.length === 0) return null;
const stripped = stripAnsiCollapse(raw);
const { session_pct, week_all_pct, week_sonnet_pct } = parseUsageText(stripped);
if (session_pct === null && week_all_pct === null && week_sonnet_pct === null) {
return null;
}
return {
host: hostname(),
ts_utc: new Date().toISOString(),
session_pct,
week_all_pct,
week_sonnet_pct
};
} catch {
return null;
}
}
async function main(): Promise<void> {
const result = await fetchUsageGauges();
console.log(JSON.stringify(result, null, 2));
}
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === `file://${entryPoint}`) {
void main();
}