Two independent tools live in this repository, sharing only the spool / file
lock / log plumbing in src/core/store.mjs — nothing else:
jyl-usagereports token usage to the JianyueLab LLM portal, for Claude Code and the Antigravity CLI (agy).jyl-wakatimereports harness's coding activity — which files an agent run touched, and how many tokens it spent — to WakaTime.
They are separate tools on purpose, not two modes of one tool. jyl-usage's
promise to the people who install it is that only counts leave the machine;
WakaTime's required entity field is a file path. Folding the second into
the first would mean anyone who already trusted jyl-usage starts shipping
file paths after an update they never read the notes for — that is a
different consent story, not a config flag on the old one.
One reporter, two hosts. jyl-usage reports token usage to the JianyueLab
LLM portal (llm-web) for both
Claude Code and the Antigravity CLI (agy) — install it into either, or
both; each host reports its own usage independently, through the same core
code.
The portal meters everything that goes through its /v1 proxy. Neither host
usually does: Claude Code talks to Anthropic directly and bills an Anthropic
subscription, agy talks to Google's own Code Assist protocol and bills a
Gemini subscription, so from the portal's side that spend is invisible either
way. jyl-usage closes that gap — after every turn it reads whatever local
record that host already keeps (Claude Code's session transcript, agy's
per-conversation SQLite database), extracts the token counts, and POSTs them
to /v1/usage/ingest. The portal stores them beside gateway traffic, tagged
source = 'claude-code' or source = 'antigravity'.
Only counts leave the machine. Model id, timestamp, request id and token totals. Never prompts, completions, file contents, file paths or project names.
Imported usage never eats your gateway allowance. Those tokens were billed
outside the gateway — an Anthropic subscription for Claude Code, a Gemini
subscription for agy — and counting them against the portal's monthly limit
would charge them twice. They show up in every report and in the leaderboard,
and the limit stays gateway-only.
/plugin marketplace add JianyueLab/claude-plugin
/plugin install jyl-usage@jianyuelab-claudeRequires bun or node on PATH (set JYL_USAGE_RUNTIME to an absolute path
if neither resolves inside Claude Code's environment). There is nothing to
build: the plugin is the source.
Same repository, no separate checkout: the two manifests sit side by side at
the root (.claude-plugin/plugin.json for Claude Code, plugin.json for
agy), so installing either host copies the whole thing and the manifests do
not interfere with each other.
What is actually verified, from a checkout of this repo:
$ agy plugin validate .
[ok] .
✔ skills : 1 processed
- agents : skipped (not found)
✔ commands : 1 processed (converted to skills)
- mcpServers : skipped (not found)
✔ hooks : 1 processed
$ agy plugin install "$PWD"
[ok] jyl-usage
✔ skills : 1 processed
- agents : skipped (not found)
✔ commands : 1 processed (converted to skills)
- mcpServers : skipped (not found)
✔ hooks : 1 processedA bare absolute path is the route that works — agy plugin --help lists
install <target>, and no agy plugin import from claude and no
marketplace-file dance was needed.
The install copies a full local snapshot of the checkout, .git included —
it does not link to it and does not fetch from origin. agy plugin list
shows the installed copy by name; it lands wherever agy's own customization
root keeps plugins (~/.gemini/config/plugins/<name> at the time this was
verified). Because it is a snapshot, a later local change — including a
commit — is invisible to agy until agy plugin install "$PWD" is run again;
re-running it in place is safe and just refreshes the copy.
Requires bun or node on PATH for the launcher itself (as above), plus —
specifically for reading agy's conversation databases — one of bun's
built-in SQLite, Node's node:sqlite (22.5+), or the sqlite3 binary.
./scripts/run --host antigravity --status says which one it found.
Two values: the portal origin and a portal API key (jyl-…, from the
portal's API keys page). Either export them —
export JYL_USAGE_BASE_URL="https://llm.jianyuelab.net"
export JYL_API_KEY="jyl-…"— or write a config file. One file can serve both hosts:
{
"baseUrl": "https://llm.jianyuelab.net",
"apiKey": "jyl-…"
}Checked in this order, first match wins: the environment, then
~/.config/jyl-usage/config.json (shared — write here once and both hosts
read it), then the host's own file — ~/.claude/jyl-usage/config.json for
Claude Code, ~/.gemini/jyl-usage/config.json for the Antigravity CLI. The
environment always wins, so a shell that already exports JYL_API_KEY for
Claude Code's apiKeyHelper needs no second copy of the secret on disk; an
existing ~/.claude/jyl-usage/config.json keeps working exactly as before —
the shared file is optional, and only worth adding once you also want the
Antigravity CLI reporting without a second copy of the key on disk.
baseUrlis the origin, not the…/v1base every SDK wants — this posts to<origin>/v1/usage/ingest. A trailing/v1is stripped rather than silently 404ing.
Optional keys in the config file:
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
false turns the plugin off without uninstalling it (JYL_USAGE_DISABLED=1 does the same). |
reportGatewayTraffic |
false |
Claude Code only — include even transcripts observed under a portal-routed hook. Imported rows do not debit gateway allowance. |
excludedTranscripts |
[] |
Absolute transcript paths to exclude from hook reporting, sweeps and backfills. Explicit exclusion wins. Paths stay local. |
includedTranscripts |
[] |
Absolute transcript paths to include despite an observed gateway exclusion. Paths stay local. |
Until it is configured the plugin does nothing at all, silently. An install without a key is inert, not noisy.
Day to day there is nothing to run on either host — the hooks do the work. When you do want to check in:
Claude Code — run /jyl-usage:
/jyl-usage |
Where it reports, whether it is configured, how many events are waiting to retry, recent log lines. |
/jyl-usage flush |
Retry events queued by earlier failed uploads. |
/jyl-usage backfill [days] |
Scan every transcript touched in the last N days (default 30) and report anything not sent yet. Safe to repeat. |
Or call the script directly: "$CLAUDE_PLUGIN_ROOT/scripts/run" --status.
Antigravity CLI — ask the jyl-usage skill the same three things, or call
the script directly with --host antigravity, which every invocation needs
on this host (the reporter defaults to Claude Code):
./scripts/run --host antigravity --status
./scripts/run --host antigravity --flush
./scripts/run --host antigravity --backfill [days]Codex CLI — --host codex reads Codex's rollout files under
~/.codex/sessions/ and keeps its state in ~/.codex/jyl-usage/. Events use
source: "codex", one of the portal's accepted sources. This repository ships
no Codex hook manifest, so run it by hand or from your own hook:
./scripts/run --host codex --status
./scripts/run --host codex --flush
./scripts/run --host codex --backfill [days]Without --host the reporter uses Claude Code. An unknown host name is an
error: the reporter prints jyl-usage: unknown host "<name>". Known hosts: claude-code, antigravity, codex. and exits 2 without reading anything.
Two instruction files carry these same three verbs, and that split is
deliberate, not duplication: commands/jyl-usage.md is Claude Code's slash
command, skills/jyl-usage/SKILL.md is agy's skill — each host reads its
own directory for this. agy additionally converts any commands/*.md it
finds into a skill of its own (agy plugin validate . reports it: "commands
: 1 processed (converted to skills)"), so commands/jyl-usage.md is written
to stay correct if Antigravity surfaces that converted copy too — it resolves
the script's path itself when ${CLAUDE_PLUGIN_ROOT} is unset (which it will
be there) and says to add --host antigravity.
Claude Code turn ends
└─ Stop hook (async) → scripts/run → src/reporter.mjs
reads $transcript_path from the last recorded byte offset
includes $agent_transcript_path for separate subagent files
sweeps every other tracked transcript that has grown since
keeps assistant entries that carry message.usage
POST <portal>/v1/usage/ingest { source: "claude-code", events: [...] }
Hooks: SessionStart, Stop, SubagentStop, PreCompact, SessionEnd — all
declared async, so nothing ever waits on an upload. Any of them also drains
the spool and runs the catch-up sweep, so usage from a session that ended while
the portal was down — or that ended without a last hook at all — is sent by
whichever session next fires one.
agy turn ends
└─ Stop hook (blocking!) → scripts/run --detach → prints {} and forks
reads conversations/<id>.db rows after the recorded idx
walks each generation blob for model, request id and counts
POST <portal>/v1/usage/ingest { source: "antigravity", events: [...] }
Only one hook, Stop — agy has no PreCompact/SessionEnd equivalent, and
PreInvocation fires before every model call, which would multiply the work
for nothing. Unlike Claude Code's hooks, agy's are not async: a handler
runs synchronously and blocks the next turn until it returns. --detach is
how scripts/run copes — it reads the hook's stdin itself (the forked child
cannot, since the parent already owns it), forks the real read-and-report
work into the background with its stdout and stderr redirected away from the
hook's own, and replies {} in milliseconds so the agent loop never waits on
a slow read, a held lock, or a slow portal. The catch-up sweep covers what a
missed Stop would otherwise strand, same as on Claude Code.
State lives per host — ~/.claude/jyl-usage/ for Claude Code,
~/.gemini/jyl-usage/ for the Antigravity CLI — each holding the same five
files below and tracking only that host's own units (transcripts, or
conversation databases). Nothing is shared between the two: not the lock, not
the dedup window, not the spool.
| File | Holds |
|---|---|
state.json |
Per-unit cursor and observed gateway exclusion. Claude Code/Codex use byte offsets; Antigravity uses generation row indices. |
seen |
The dedup window — the last few thousand request ids sent, newest last. Its own file because state.json is touched every turn and this is the part that grows. |
spool.jsonl |
Up to 5,000 retry events. Unread overflow stays behind the source cursor for a later pass. |
log |
What happened, capped at 256 KB. |
lock |
Current process owner. Dead owners older than 60 s may be reclaimed; live paused owners are not stolen. |
lock-turns/ |
Unique contender tickets serialize acquisition and stale-lock reclamation. Dead-process tickets are removed. |
pending-units/ |
Immutable hook discoveries and observed exclusions, merged by the next lock holder. |
The portal accepts four imported sources: claude-code, grok-build,
antigravity and codex. A server that does not recognize a source returns
400 invalid_source; the reporter queues that response for retry.
agy's hooks block, so the work is detached. Unlike Claude Code's hooks,
agy has no async flag — a Stop handler runs synchronously and holds up
the next turn until it returns. scripts/run --detach forks the actual
read-and-report work into the background and replies to the hook immediately,
so a slow read, a held lock, or a slow portal never becomes a pause the user
feels. See How it works (agy) above.
The field mapping is inferred, not documented, so drift under-reports
rather than misreports. agy ships no schema for its
conversations/<id>.db protobuf blobs; the token fields were found by
inspecting real ones. Every row is checked against two invariants before it
is trusted — the output total must equal thinking-plus-text, and every field
number present in the counts message must be one this plugin has actually
seen — and a row that fails either is skipped and logged, never guessed at.
If a future agy renumbers these fields, --status and the log show fewer
events reported, not wrong numbers reported as if they were right.
Hooks retain discoveries even during contention. Main and separate subagent transcripts enter a local immutable inbox before lock acquisition. The next lock holder merges that inbox and sweeps tracked transcripts with unread work, newest first, capped at 25 additional eligible units per run. Excluded transcripts do not consume sweep slots, including after a Retry-After pause.
Repeated requests are deduplicated at both ends. Claude Code writes one
transcript entry per content block, so a single API request that produced text
and a tool call appears twice with the same requestId. A line written before
the response finished carries the output tokens counted so far, so the lines of
one request are merged into one event holding the largest count of each kind.
A request is reported once it is settled: a line of it has a stop_reason, the
next request has started, or the transcript has not been written for 30
minutes. Until then the cursor stays at its first line and the next run reads
it again whole.
Resuming a session goes further and copies its whole history into a new
transcript file. The plugin filters both against one local dedup window — per
transcript would not have recognised the fork. The portal claims a receipt
keyed by (source, user_aid, request_id) in the same transaction as the usage
event insert. The same request ID may be imported by different accounts or
sources; retries within that receipt scope do not create another event.
Retry delivery also depends on the queue and cursors. Receipt deduplication
does not recover an event discarded locally or a transcript never discovered.
Retained events are retried on subsequent runs. Permanent payload rejections
are logged and dropped.
Each pass reads at most the retry queue's free capacity. Source cursors stop
before unread overflow, including within a single transcript or conversation.
An HTTP 429 stops the remaining batches in that pass and persists its
Retry-After deadline. Hooks, backfills and flushes retain queued events and
discoveries without uploading before that time. The delay is bounded from one
second to one hour; an absent or malformed header defaults to one minute.
Run another hook, backfill or flush after the deadline to continue.
A wrong key is retried; a bad payload is not. 401/403/429/5xx spool
for later, because someone will fix the key — an unrecognised source (above)
gets the same treatment. Anything else is logged and dropped, because retrying
it forever would only grow the spool.
Observed portal-routed transcripts remain excluded — Claude Code only.
A hook whose ANTHROPIC_BASE_URL names the configured portal host records an
exclusion for its main and subagent transcripts. Later direct-context sweeps
and backfills honor it. "includedTranscripts" or "reportGatewayTraffic": true
can explicitly include them; "excludedTranscripts" always takes precedence.
Unknown historical transcripts cannot be classified from the current shell's environment. Backfill includes them unless explicitly excluded. Choose local inclusion/exclusion paths when their provenance is known. An observed routing host does not prove which account received a real gateway charge. Duplicate aggregate usage is possible only when the same turns were metered for the same portal account. Imported usage never consumes gateway allowance.
Locally fabricated messages are ignored. Claude Code writes
model: "<synthetic>" entries for API errors and interrupts; no request was
made, so there is nothing to report.
Cache tokens are split by TTL, because the vendor prices them differently
(5-minute writes at 1.25× input, 1-hour writes at 2×, reads at 0.1×). The portal
applies those multipliers against the model's input rate; older transcripts that
carry only a cache-creation total are attributed to the cheaper 5-minute bucket.
This is Claude-specific pricing; Gemini's implicit caching has no separately
priced write, so Antigravity events always carry 0 in both write buckets.
POST /v1/usage/ingest, authenticated with a portal key as
Authorization: Bearer or x-api-key, like every other /v1 route. Same
route, same event shape, for both hosts — only source and client differ:
Response — per-event, so one malformed entry never blocks its neighbours:
{ "received": 5, "accepted": 2, "duplicates": 1, "rejected": [{ "index": 3, "reason": "too_old" }] }Max 500 events per request (a larger batch is rejected, not truncated).
The portal accepts claude-code, grok-build, antigravity and codex.
An unrecognised source gets 400 invalid_source, which the plugin queues for
retry. Deduplication uses receipts scoped to (source, user_aid, request_id).
The route is
otherwise deliberately ungated: it spends nothing, so refusing a report
because the reporter is over its allowance would only throw away the evidence
that they are.
Recalls JianyueLab memory at session start and distils each session into it when the session ends. Separate from jyl-usage: this plugin sends memory content, not counts.
What leaves the machine: distilled memories only, written through the memory MCP server into your personal project. Session text goes only to the model of the host you are already using (claude -p, codex exec, omp -p), with tool output dropped and known secret shapes redacted.
Prerequisite: the memory MCP server is configured and logged in for each host (/mcp in Claude Code, codex mcp login memory).
- Claude Code:
/plugin marketplace add JianyueLab/harness-plugin, then/plugin install jyl-memory@jianyuelab-claude. - Codex:
codex plugin marketplace add JianyueLab/harness-plugin,codex plugin add jyl-memory@jianyuelab-claude, then trust both hooks in/hooks. - omp: link
plugins/jyl-memory/hooks/post/jyl-memory.tsinto~/.omp/agent/hooks/post/(omp loadshooks/post/*.ts; it ignoreshooks/hooks.json), or run omp with--plugin-dir plugins/jyl-memory.
This repo's .agents/plugins/marketplace.json takes precedence over .claude-plugin/marketplace.json for Codex, so Codex lists only jyl-memory from this repo.
JYL_MEMORY_RUNTIME may name an absolute node or bun binary; otherwise the launcher uses bun, then node, from PATH.
- Session start: a short prompt tells the agent to search memory before working. In omp, it is appended to the system prompt.
- Session end: the hook hands the transcript to a background worker and returns. The worker takes messages not distilled yet, skips sessions with fewer than 3 new user messages or under an excluded directory, and runs the host CLI with only the memory MCP tools:
- Claude Code: built-in tools off.
- Codex: shell, plugins, apps, hooks and every other configured MCP server off.
- omp: always-ask approval, with only the five memory tools allowed.
- In omp,
/new, fork, resume and branch switches also distil the session left behind. - The host writes only to your personal project. It searches existing memories first and updates them instead of adding duplicates; at most 5 writes per session.
- Progress is recorded only when the host exits 0 and its last line is
done. - Each distillation costs one host model run.
~/.config/jyl-memory/plugin.json, optional:
| Key | Default | |
|---|---|---|
enabled |
true |
false turns the plugin off |
exclude_paths |
[] |
sessions under these directories are not distilled |
min_user_messages |
3 |
fewer new user messages: skip |
max_chars |
200000 |
text cap per run (newest messages kept) |
mcp_server |
memory |
name of the memory MCP server in each host |
models |
{"claude-code":"haiku","codex":null,"omp":"@smol"} |
model per host; null uses the host default |
State and log: ~/.local/state/jyl-memory/ (plugin-state.json, plugin.log).
jyl-wakatime turns harness's RunEnd hook payload — one line of JSON, fired
once per finished agent run — into WakaTime heartbeats: a file heartbeat
for every tool call that named a path, plus one app heartbeat per run that
carries the run's token counts.
harness RunEnd hook (stdin, one JSON line per finished run)
└─ scripts/wakatime --detach forks the real work, returns in ms
└─ src/wakatime/main.mjs
resolve api key / api url (below)
map payload -> heartbeats, throttle repeats
POST <api_url>/users/current/heartbeats.bulk, 25 at a time
What it sends off this machine — stated here, not in a footnote below the
install instructions: absolute file paths, each touched file's timestamp
and whether the tool call that touched it was a write or a read
(is_write), the git project name and branch, a best-effort
programming-language guess from the file extension, input and output token
counts (fresh input and cache-served input reported separately),
the prompt length in characters, the model id, and the session id — not
one this tool invents per run, but the id harness generates once for its
own process and stamps into every run's payload, so it is a single value
shared by everything that process reports, not a fresh one per run. The
request itself also carries a User-Agent identifying this tool, harness, the
model and the machine, e.g.
wakatime/1.0.0 (darwin-27.0.0-arm64) go0.0.0 claude-opus-5 harness/27.0.17 harness-wakatime/0.1.0
— OS, kernel release, CPU architecture, the model, harness's version and this
tool's own. It never sends prompts, completions, file contents, tool
arguments, command lines, or tool results — harness's RunEnd payload does
not carry any of those to begin with, so this is enforced upstream, not by
this tool's restraint.
The model id is on that list, and an earlier version of this README said it
was not. The reason given was "WakaTime's heartbeat has no model field, so
there is nowhere to put it". The first half is true — a heartbeat posted with a
model field comes back stored without it, checked against the live API — but
the conclusion was wrong. WakaTime keeps the model in the User-Agent, in the
claude-opus-5 slot above, which it parses out and uses for the AI-model cost
and line-change breakdowns on your dashboard. Every other AI tool reporting into
WakaTime already fills that slot.
On your dashboard the model shows up as Claude-Opus-5 — the whole model
name, one row per exact model, with no version. It is sent as a single token on
purpose: WakaTime buckets AI-model cost by name only (the version it parses
out appears in none of the cost, line-change or breakdown structures), so a
claude/opus-5-style token would put every Claude model — opus, sonnet, haiku —
into one Claude bucket. Worse, in a real account that bucket is not "the Claude
model" at all: it is Claude Code's own ClaudeCode/<app version> token, which
WakaTime misreads as a model, so harness's spend would vanish into another tool's
row and inflate its numbers.
The trade-off is that harness's Opus time will not merge with Claude Code's
Opus row. That is deliberate: there is no consistent model naming across
WakaTime integrations to merge with anyway, and slicing your time by tool is what
the Editors breakdown is for, where Harness is already its own row.
Leaving it empty was not the neutral choice it looked like: WakaTime's parser is
positional, and with the slot empty it took the User-Agent's own leading
wakatime/1.0.0 token as the model — so the dashboard grew a model called
"Wakatime" with real dollar figures against it. The go0.0.0 token above is
what holds that position; its value is ignored and deliberately fake, because
this tool has no Go version to report and a hardcoded one would go stale while
still reading as authoritative.
Why this trade is fine: a model id is far less sensitive than what this tool
already sends on purpose — your absolute file paths and project names. It says
which model did the work, not what the work was. If a model id is more than you
want to share, the file paths already are too; use hide_file_names, or do not
install this.
One kind of model name never reaches the dashboard: one written outside printable ASCII. Two different reasons, worth separating because they affect different people:
- A character above U+00FF — a gateway alias in Chinese, say. The model slot
lives in the User-Agent, and an HTTP header genuinely cannot carry those:
fetchrefuses the whole request. Reporting no model is what stops that from turning every send into a "network error" that respools forever. - An accented latin1 name like
café-5— which the header layer would accept; this was measured, not assumed. It is dropped anyway, because this token's whole job is to be a stable bucket name in your dashboard, and a byte sequence whose reading depends on how the server decodes it is not one.
Either way the run still reports everything else — files, project, branch, tokens — and the AI-model slot is simply empty rather than wrong.
One caveat about those token counts: when a run's outcome is "cancelled"
or "error" instead of "ok", harness may not have finished accumulating
that run's last turn of usage before it returned — the failing path exits
before that turn's tokens are added in. This is a deliberate choice on
harness's side (outcome is exactly how a consumer is meant to recognise an
incomplete run), not a bug here — but jyl-wakatime does not forward outcome
to WakaTime at all, so nothing on the heartbeat itself distinguishes a
complete run's counts from a possibly-short one. Treat ai_input_tokens /
ai_cached_input_tokens / ai_output_tokens as a lower bound, not a
guaranteed-exact count, on any run
that did not end "ok".
Step 0, before the hook config: get a checkout and a key. Unlike
jyl-usage's Claude Code route above, there is no marketplace install here —
harness's command needs a real absolute path, so clone or copy this repo
somewhere that will not move (git submodule update detaching HEAD, or the
directory getting renamed, both break the path silently). The launcher also
needs bun or node on PATH at the moment harness execs it — set
JYL_WAKATIME_RUNTIME to an absolute interpreter path if neither resolves in
harness's environment (harness inherits its own full environment into the
hook, so this is usually the same PATH your shell has). If no runtime is
found, scripts/wakatime writes a line to stderr and, since harness
discards a hook's stderr and never runs --status for you, to
~/.config/jyl-wakatime/log — the one thing that keeps this specific failure
from being completely invisible (see "Is it working?" below). The same line
is written, naming the case, for the two other ways this can break before the
JS runs: a moved or half-copied checkout (entry point missing at …, the
failure the "somewhere that will not move" warning above is about) and a
JYL_WAKATIME_RUNTIME that exists and is executable but is not a working JS
runtime (JYL_WAKATIME_RUNTIME exited N). And the API
key itself comes from WakaTime, not this repo: your account's API key
settings page, or the
equivalent settings page on your self-hosted wakapi/hakatime instance.
Then add a hook in harness's config.toml:
[[hooks]]
event = "RunEnd"
command = "/absolute/path/to/harness-plugin/scripts/wakatime"
args = ["--detach"]--detach is not optional decoration. scripts/wakatime reads the payload
itself, forks the actual read-and-send work into a background process that
is not a child of harness at all, and returns within milliseconds. Without
it, the network round trip to WakaTime runs as harness's own direct child,
inside harness's hook timeout (10 seconds by default) — a slow send can be
killed mid-flight, and because the only copy of that run's heartbeats ever
existed on stdin, that batch is simply gone (see "There is no --backfill"
below).
Two values, resolved in this order — first match wins:
api key: WAKATIME_API_KEY
-> ~/.wakatime.cfg [settings] api_key
-> ~/.wakatime.cfg [settings] api_key_vault_cmd (output of the command)
-> ~/.config/jyl-wakatime/config.json { "apiKey": … }
api url: WAKATIME_API_URL
-> ~/.wakatime.cfg [settings] api_url
-> ~/.config/jyl-wakatime/config.json { "apiUrl": … }
-> https://api.wakatime.com/api/v1
~/.config/jyl-wakatime always means $HOME/.config/jyl-wakatime, literally.
This tool does not honour $XDG_CONFIG_HOME — that is the existing
convention in this repo (jyl-usage's own state directories are the same
kind of hardcoded path), not something decided fresh here. If that ever
changes, it has to change in exactly one place
(STATE_DIR/JSON_CONFIG_FILE in src/wakatime/cfg.mjs) and the
scripts/wakatime fallback that mirrors it for when no runtime is on PATH
(see "Is it working?" below) has to move with it — the two are not allowed
to drift, since nothing else would notice if they did.
~/.wakatime.cfg is checked before this tool's own config, deliberately:
anyone who has ever installed a WakaTime editor plugin already has a key
there, and asking for a second copy of the same secret would be asking for
it to drift. Only [settings]'s api_key, api_key_vault_cmd, api_url and
hide_file_names are read from that file; everything else in it (exclude,
include, proxy, …) is wakatime-cli's business, and unrecognised lines
are ignored rather than rejected — a future WakaTime release adding a line
this tool has never heard of will not break it.
api_key_vault_cmd is for a key kept in a password manager instead of in
plaintext — the same option wakatime-cli itself supports, so an existing
editor plugin's config Just Works here too. Its value is a command; this
tool runs it and treats whatever it prints on stdout, trimmed, as the key.
Splitting that value into a command and arguments mirrors wakatime-cli's
own approach (quotes and backslash escapes are understood), but nothing
else is interpreted: no pipes, no $VAR expansion, no ; chaining, no
` or $(...) substitution — it is never handed to an actual shell. If
both api_key and api_key_vault_cmd are present, api_key wins and the
command never runs.
Expanding a leading ~/ or bare ~ to your home directory is this
tool's own addition, not part of wakatime-cli's behaviour — real config
values commonly need it, and without it such a value simply failed to find
the command at all. It is not shell-faithful, though, and deliberately so:
a real shell skips tilde expansion for a quoted or escaped ~, but this
splitter doesn't track which characters were quoted, so "~", '~', and
\~ all expand exactly like a bare ~. The consequence is real: there
is no way to put a literal ~ in this command's arguments. Teaching this
one character shell-accurate quoting, when every other character in this
value is already knowingly not shell, wasn't worth it for a case nothing
has needed.
The command gets 5 seconds — sized against harness's own 10-second default
hook timeout mentioned above, with headroom to spare, so this tool's own
timeout fires and gets logged well before harness would kill an undetached
hook process out from under it (a hung command is killed outright, not
asked nicely — but only the command itself, not anything it may have
forked off in the meantime). If the command is missing, exits non-zero,
times out, produces too much output, or prints nothing at all, it never
yields a key and never crashes — but unlike an outright absent config,
--status and the log say which of those happened
(api_key_vault_cmd: command not found, : exited 9, : timed out,
: output too large, or : produced no output), so it doesn't read
identically to "you never configured a key." What is never logged, on any
of those paths, is the command itself or anything it printed: a failing
vault command's stderr is exactly the kind of place a key, a passphrase
prompt, or other secret-shaped text could leak, so it is discarded unread
rather than risk that. When it does succeed, --status attributes the key
to api_key_vault_cmd, not to ~/.wakatime.cfg's path, so a key resolved
this way is visibly different from one written in plaintext (see below).
If --status says api_key_vault_cmd: command not found for a command
you know exists, check PATH before anything else: the hook runs with
whatever environment harness itself inherited, which on macOS is often not
your interactive shell's PATH (a GUI- or launchd-started harness commonly
gets a bare /usr/bin:/bin) — exactly where a password-manager CLI
installed via Homebrew (/opt/homebrew/bin/…) or a language-specific
installer would be missing. An absolute path in api_key_vault_cmd sidesteps
this entirely.
Self-hosted wakapi or hakatime: point api_url at it, in either file
above. A trailing slash is trimmed.
[settings] hide_file_names = true (or "hideFileNames": true in the JSON
config) is honoured: file entities are replaced with an obfuscated
placeholder that keeps only the file's own extension (agent.go becomes
HIDDEN.go; a.test.ts becomes HIDDEN.ts; Makefile, .env and
.gitignore become HIDDEN). Directory names never contribute. The branch
is not sent. The project name is still reported. This matches wakatime-cli,
except that it sends HIDDEN.env for a dotfile. hide_branch_names and
hide_project_names are not read.
Multiline hide_file_names regex lists hide matching paths case-insensitively
and suppress the branch on those file heartbeats. JSON hideFileNames also
accepts an array of patterns and keeps JavaScript's case-sensitive matching.
Both use JavaScript regex syntax; malformed expressions, unsupported POSIX
character classes and unsupported values stop reporting with a configuration
error. They never silently disable privacy.
No API key anywhere means this tool quietly does nothing. Not a crash,
not a log line on every single run — one line logged the first time, then
silence until the config is fixed. (JYL_WAKATIME_DISABLED=1, or
"enabled": false in the JSON config, turns it off the same way, on
purpose.) An install with no key configured is inert, exactly like
jyl-usage.
jyl-wakatime never exits non-zero and never throws, on any path it can
reach — a bad config, a network failure, an unwritable state directory, no
runtime on PATH, a JYL_WAKATIME_RUNTIME that fails, a checkout missing
src/wakatime/main.mjs: none of it may turn a harness run red. (The one
exception is not reachable from inside the script: if no sh can be found for
scripts/wakatime's #!/usr/bin/env sh line, env reports 127 before a single
line of it runs. Nothing in a shell script can catch its own interpreter going
missing.) That agrees with harness's own side: a hook failure
there is a notice, never an error that fails the run. The cost is silence —
a broken reporter fails exactly as quietly as a working one, on every
surface except this one:
$ ./scripts/wakatime --status
jyl-wakatime 0.1.0
api url https://api.wakatime.com/api/v1
api key (unset) (from nowhere)
spool 0 heartbeat(s)
last send never
auth fails 0
PROBLEM no API key configured(that is real output, from a freshly configured $HOME with nothing set up
yet.) Once a key resolves, the PROBLEM line disappears and last send
reports what the most recent attempt actually did — for example:
api key waka…cdef (from /Users/you/.wakatime.cfg)
last send 2026-09-12T06:14:02.483Z accepted 12, failed 0
| Line | What it means |
|---|---|
PROBLEM |
present at all → nothing is being sent right now; its text says why |
spool |
heartbeats that failed on their most recent send attempt — a network error, a 429, a 5xx, or a 401/403 all land here — waiting for the next retry, either the next hook run or --flush (below). Should trend toward 0 across runs, not up. Capped at 5000 — past that, the oldest are dropped, logged as spool overflow: dropped N oldest events |
last send |
accepted N, failed M from the most recent attempt; failed staying above 0 across several runs means something is wrong, not a fluke. A third number, rejected K, appears only when it is non-zero: those heartbeats were refused outright and dropped for good, so they are neither accepted nor coming back via the spool. Any rejected at all is worth a look at the log — a partially-refused run is otherwise indistinguishable from a perfectly healthy one |
auth fails |
counts consecutive runs that had something to send and got a 401/403 back; a run that sends successfully, or fails for a different reason, resets it to 0 — but a run with nothing queued at all does neither, so a stale nonzero value can persist through a quiet gap. Nonzero means the key WakaTime saw the last time this tool actually tried to send was wrong or revoked |
accepted 0 with an empty spool and no PROBLEM line |
the dangerous healthy-looking reading. Two ways to get here, both meaning "everything went in the bin". The whole request was refused with a non-retryable 4xx (anything but 401/403/429) — send.mjs drops that batch for good, so it never reaches the spool. Not hypothetical: a mistyped path on a self-hosted wakapi/hakatime api_url produces exactly this. Or the request was accepted and each heartbeat inside it was refused — WakaTime answers 202 with a status per heartbeat, and a heartbeat rejected on content is dropped for the same reason. Either way rejected N now appears on the last send line — that is the tell, and it is the reason to read the log next: wakatime rejected 3 heartbeat(s), dropping: 400 bad request for the whole-request case, wakatime rejected 3 of 3 heartbeat(s) individually, dropping: 400 x3 for the per-item one. What accepted counts is heartbeats WakaTime said it kept — never heartbeats merely handed over — which is what makes this reading a signal at all |
The full log behind that summary is ~/.config/jyl-wakatime/log, capped at
256 KB — the same rotation jyl-usage uses, from the src/core/store.mjs
the two tools share. ./scripts/wakatime --flush retries whatever is
currently spooled right away, reading no stdin — worth running immediately
after fixing a bad key or a wakapi outage rather than waiting for harness's
next run to pick the spool back up (which happens automatically too, on
every hook invocation).
jyl-usage can rescan transcripts because a transcript is a file that keeps
existing after the fact. jyl-wakatime has nothing like it: its only input is
one JSON line on stdin, read once, by a process that then exits. A heartbeat
that failed to send is retried from the spool; a heartbeat that was never
produced at all — because the hook never fired, or the process reading stdin
was killed first — is simply gone. There is no flag for this because there
is nothing a flag could do about it.
harness and jyl-wakatime live in separate repositories and agree on one JSON
shape with no build step checking that agreement. tests/fixtures/runend.json
in this repo and hook/testdata/runend.json in JianyueLab/harness must
stay byte-for-byte identical:
diff tests/fixtures/runend.json ../harness/hook/testdata/runend.jsonNo output means the two still agree. Changing the payload — a renamed field, a new property on a tool call — means changing both repositories in the same change; neither side's test suite will notice that the other one drifted.
{ "source": "claude-code", // or "antigravity", "codex" "client": "claude-code-usage-reporter/27.1.1", // or "antigravity-usage-reporter/27.1.1", "codex-usage-reporter/27.1.1" "events": [ { "requestId": "req_011Ce…", // dedup key; falls back to messageId "messageId": "msg_011Ce…", "ts": "2026-08-20T07:07:50.232Z", "model": "claude-opus-5", "inputTokens": 2, // uncached input "outputTokens": 389, "cacheWrite5mTokens": 0, "cacheWrite1hTokens": 18259, "cacheReadTokens": 22141 } ] }