Claude Code's plugin reports each session's token usage from its own transcript
For g1t to estimate what a session cost, something has to count its tokens, and only the machine it ran on has its transcript. On Stop, SubagentStop and SessionEnd the background half reads the session's own transcript (a subagent's agent_transcript_path, which its parent's leaves out, so nothing counts twice), sums message.usage of the assistant lines per model, once per response by message.id and requestId, with cache writes split by their lifetime and claude-haiku-5-5's long prompts apart, and sends the counts with a heartbeat. It reads on from where it stopped, never sends what the transcript says, and gives the usage only to a CLI of 0.4.1 or later: the CLI contract says so with since, and the stand-in CLI refuses it from an older one. The plugin is 0.3.1. Part of #157
- G1t-Session:
- ses_01m573jm0ze1q9tram0b4dcva1
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t work items from Claude Code: records your sessions on g1t, loads a work item's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.3.0", | |
| 10 | + | "version": "0.3.1", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 104 | 104 | | Claude writes a file, makes a branch or a commit, or claims a work item | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--item`) | What it touched: the path from the repository's root, the branch, the commit, the work item. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and work items these lead to, and tells two sessions that touch the same file. | | |
| 105 | 105 | | A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` and `g1t session touch --commit SHA --branch BRANCH` | The commit you pushed and its branch, so the session's entries line up with the code and it is tied to the branch's pull request. | | |
| 106 | 106 | | Claude starts a subagent | `g1t session start --parent SESSION --agent-type TYPE --task TASK --prompt PROMPT` | A session of its own for the subagent, under this one, with its kind and the task it was given (its prompt's first 4,000 characters). See [Subagents](#subagents). | | |
| 107 | − | | A subagent finishes | `g1t session end --result MESSAGE` | Its final message, up to 8,000 characters: what it reported to Claude. | | |
| 108 | − | | Claude finishes a turn | `g1t session heartbeat` | That the session is still active, which also renews its claims. | | |
| 109 | − | | The session ends | `g1t session end --reason REASON` | That it ended, and why: you exited, cleared it or logged out. | | |
| 107 | + | | A subagent finishes | `g1t session heartbeat --usage-json USAGE`, then `g1t session end --result MESSAGE` | The tokens it used, from its own transcript (see [What a session cost](#what-a-session-cost)), then its final message, up to 8,000 characters: what it reported to Claude. | | |
| 108 | + | | Claude finishes a turn | `g1t session heartbeat --usage-json USAGE` | That the session is still active, which also renews its claims, and the tokens it used so far. | | |
| 109 | + | | The session ends | `g1t session heartbeat --usage-json USAGE`, then `g1t session end --reason REASON` | The tokens it used, then that it ended, and why: you exited, cleared it or logged out. | | |
| 110 | 110 | ||
| 111 | 111 | It never records: | |
| 112 | 112 | ||
| ⋯ | |||
| 115 | 115 | for its subagent's final message and id), nor the turns Claude Code | |
| 116 | 116 | starts itself to hand Claude a background task's output; | |
| 117 | 117 | - Claude's replies or reasoning; | |
| 118 | − | - your files, environment or transcript. | |
| 118 | + | - your files, environment or transcript: of the transcript, only the token | |
| 119 | + | counts are sent. | |
| 119 | 120 | ||
| 120 | 121 | A session that stops sending heartbeats reads as idle after 15 minutes and | |
| 121 | 122 | ended after 30 minutes, so a closed laptop does not leave work looking | |
| ⋯ | |||
| 135 | 136 | works in the same working copy. A command run before the session's id | |
| 136 | 137 | arrives, in its first second, uses the working copy's session. | |
| 137 | 138 | ||
| 139 | + | ## What a session cost | |
| 140 | + | ||
| 141 | + | When Claude finishes a turn, when a subagent finishes and when the session | |
| 142 | + | ends, the plugin counts the tokens the session used so far from its | |
| 143 | + | transcript, the JSONL file Claude Code keeps of it (`transcript_path`, and | |
| 144 | + | for a subagent its own, `agent_transcript_path`), and sends the counts with | |
| 145 | + | the heartbeat. g1t estimates what the session cost from them at | |
| 146 | + | Anthropic's list price and shows it on the session's page; see | |
| 147 | + | [What a session cost](/guides/sessions/#what-a-session-cost). | |
| 148 | + | ||
| 149 | + | - **What is counted**: each model's input tokens, output tokens, cache | |
| 150 | + | reads, and cache writes for 5 minutes and for 1 hour, from the `usage` | |
| 151 | + | of each response Claude Code recorded (`message.usage` of the assistant | |
| 152 | + | lines). Claude Code writes several lines for one response, each with the | |
| 153 | + | same usage, so each response counts once, by `message.id` and | |
| 154 | + | `requestId`. | |
| 155 | + | - **Only the counts leave your machine**: per model, the five numbers. | |
| 156 | + | Nothing Claude or you wrote in the transcript is sent. | |
| 157 | + | - **A subagent counts on its own session.** Its parent's transcript leaves | |
| 158 | + | out what the subagent used, and the subagent reports its own, so nothing | |
| 159 | + | is counted twice. | |
| 160 | + | - The plugin reads the transcript from where it stopped last time, in the | |
| 161 | + | background, so a long session does not slow a hook down. If the | |
| 162 | + | transcript cannot be read, the heartbeat goes without usage. | |
| 163 | + | ||
| 164 | + | The usage is sent to a g1t CLI of 0.4.1 or later; with an older one, | |
| 165 | + | everything else is still recorded. | |
| 166 | + | ||
| 138 | 167 | ## The context Claude starts with | |
| 139 | 168 | ||
| 140 | 169 | When a session starts, the plugin looks for the work item it is working | |
| ⋯ | |||
| 274 | 303 | ||
| 275 | 304 | If `g1t` is not installed, not signed in, or older than 0.4.0 (the oldest | |
| 276 | 305 | that takes every command the hooks run), the hooks record nothing and | |
| 277 | − | Claude Code says so once. The plugin never installs anything itself. | |
| 306 | + | Claude Code says so once. A CLI older than 0.4.1 records everything but | |
| 307 | + | what the session cost. The plugin never installs anything itself. | |
| 278 | 308 | Install the CLI or run `g1t auth login`, and the next session is recorded. | |
| 279 | 309 | ||
| 280 | 310 | To update the CLI, run `g1t upgrade` (0.3.4 and later). For an older CLI, | |
| 1 | 1 | { | |
| 2 | 2 | "name": "g1t", | |
| 3 | − | "version": "0.3.0", | |
| 3 | + | "version": "0.3.1", | |
| 4 | 4 | "description": "Work on g1t work items from Claude Code: records your sessions on g1t, loads a work item's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 5 | 5 | "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" }, | |
| 6 | 6 | "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 27 | 27 | // finished, its final message | |
| 28 | 28 | // SubagentStart g1t session start --parent SESSION --agent-type TYPE …: | |
| 29 | 29 | // each subagent is a session of its own, under this one | |
| 30 | − | // SubagentStop g1t session end --result "its final message" for it | |
| 31 | − | // Stop g1t session heartbeat | |
| 32 | − | // SessionEnd g1t session end --reason REASON | |
| 30 | + | // SubagentStop g1t session heartbeat --usage-json USAGE, then | |
| 31 | + | // g1t session end --result "its final message" for it | |
| 32 | + | // Stop g1t session heartbeat --usage-json USAGE | |
| 33 | + | // SessionEnd g1t session heartbeat --usage-json USAGE, then | |
| 34 | + | // g1t session end --reason REASON | |
| 33 | 35 | // | |
| 36 | + | // USAGE is the session's token usage so far, per model, counted from its | |
| 37 | + | // own transcript (a subagent's from its own; see transcriptUsage): only | |
| 38 | + | // the counts, never what the transcript says. g1t estimates the session's | |
| 39 | + | // cost from it at list price. It is sent to a CLI of USAGE_CLI or later. | |
| 40 | + | // | |
| 34 | 41 | // What a tool returned (tool_response) is never sent, with one exception | |
| 35 | 42 | // that is not a tool's output: an Agent call's answer, which is its | |
| 36 | 43 | // subagent's final message (as SubagentStop gives it) and its id. Prompts | |
| ⋯ | |||
| 85 | 92 | export const ACK_CLI = "0.3.3"; | |
| 86 | 93 | /** The oldest g1t CLI with `g1t upgrade`. */ | |
| 87 | 94 | export const UPGRADE_CLI = "0.3.4"; | |
| 95 | + | /** The oldest g1t CLI with `g1t session heartbeat --usage-json` (test/cli-contract.json's since). */ | |
| 96 | + | export const USAGE_CLI = "0.4.1"; | |
| 88 | 97 | /** How to update the CLI: its installer, run again. */ | |
| 89 | 98 | const INSTALL = "`curl -fsSL https://g1t.sh/install | sh` (on Windows, `irm https://g1t.sh/install.ps1 | iex`)"; | |
| 90 | 99 | ||
| ⋯ | |||
| 290 | 299 | } | |
| 291 | 300 | ||
| 292 | 301 | /** | |
| 302 | + | * A heartbeat that reports the token usage counted from `transcript`. | |
| 303 | + | * `only`: sent only with usage to report (before the session ends); | |
| 304 | + | * otherwise a plain heartbeat when there is none. | |
| 305 | + | */ | |
| 306 | + | function usageStep(transcript, { only = false } = {}) { | |
| 307 | + | return { args: ["session", "heartbeat"], usage: String(transcript), ...(only ? { only: true } : {}) }; | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /** | |
| 293 | 311 | * What a hook event asks for: the CLI calls to run in the background | |
| 294 | 312 | * (`steps`), whether to start the session first, and for a subagent's | |
| 295 | 313 | * events, which subagent (`agent`). Pure, so the tests can check it | |
| ⋯ | |||
| 328 | 346 | return job; | |
| 329 | 347 | case "SubagentStop": { | |
| 330 | 348 | if (!job.agent) return null; | |
| 349 | + | // Its own transcript: its parent's leaves out what it used. | |
| 350 | + | const transcript = event.agent_transcript_path ?? subagentPaths(event.transcript_path, event.agent_id)?.transcript; | |
| 351 | + | if (transcript) job.steps.push(usageStep(transcript, { only: true })); | |
| 331 | 352 | const args = ["session", "end", "--reason", "done"]; | |
| 332 | 353 | const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : ""; | |
| 333 | 354 | // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value. | |
| ⋯ | |||
| 402 | 423 | return job; | |
| 403 | 424 | } | |
| 404 | 425 | case "Stop": | |
| 405 | − | job.steps.push({ args: ["session", "heartbeat"] }); | |
| 426 | + | job.steps.push(event.transcript_path ? usageStep(event.transcript_path) : { args: ["session", "heartbeat"] }); | |
| 406 | 427 | return job; | |
| 407 | 428 | case "SessionEnd": | |
| 429 | + | if (event.transcript_path) job.steps.push(usageStep(event.transcript_path, { only: true })); | |
| 408 | 430 | job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] }); | |
| 409 | 431 | job.end = true; | |
| 410 | 432 | return job; | |
| ⋯ | |||
| 555 | 577 | shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`), | |
| 556 | 578 | unrecorded: join(dir, `${safe}.unrecorded.json`), | |
| 557 | 579 | agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`), | |
| 580 | + | usage: (transcript) => join(dir, `${safe}.usage.${createHash("sha256").update(String(transcript)).digest("hex").slice(0, 16)}.json`), | |
| 558 | 581 | }; | |
| 559 | 582 | } | |
| 560 | 583 | ||
| ⋯ | |||
| 715 | 738 | return agent; | |
| 716 | 739 | } | |
| 717 | 740 | ||
| 741 | + | // --- what a session used ------------------------------------------------ | |
| 742 | + | ||
| 743 | + | /** The prompt size above which claude-haiku-5-5 is priced as its `long` tier. */ | |
| 744 | + | export const LONG_PROMPT_TOKENS = 100000; | |
| 745 | + | /** How many messages are remembered to tell a message written again from a new one. */ | |
| 746 | + | const RECENT_MESSAGES = 500; | |
| 747 | + | /** How much of a transcript is read at once. */ | |
| 748 | + | const USAGE_CHUNK = 1 << 20; | |
| 749 | + | ||
| 750 | + | const COUNTS = ["input_tokens", "output_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "cache_read_tokens"]; | |
| 751 | + | ||
| 752 | + | const whole = (n) => (Number.isSafeInteger(n) && n > 0 ? n : 0); | |
| 753 | + | ||
| 754 | + | /** One transcript entry's usage, or null for one that is not an assistant message with usage. */ | |
| 755 | + | export function entryUsage(entry) { | |
| 756 | + | const message = entry?.type === "assistant" ? entry.message : null; | |
| 757 | + | const usage = message?.usage; | |
| 758 | + | const model = typeof message?.model === "string" ? message.model.trim() : ""; | |
| 759 | + | if (!usage || typeof usage !== "object" || !model || model === "<synthetic>") return null; | |
| 760 | + | const split = usage.cache_creation && typeof usage.cache_creation === "object" ? usage.cache_creation : null; | |
| 761 | + | const counts = { | |
| 762 | + | input_tokens: whole(usage.input_tokens), | |
| 763 | + | output_tokens: whole(usage.output_tokens), | |
| 764 | + | // Without the split, every cache write is taken as the 5-minute kind. | |
| 765 | + | cache_write_5m_tokens: split ? whole(split.ephemeral_5m_input_tokens) : whole(usage.cache_creation_input_tokens), | |
| 766 | + | cache_write_1h_tokens: split ? whole(split.ephemeral_1h_input_tokens) : 0, | |
| 767 | + | cache_read_tokens: whole(usage.cache_read_input_tokens), | |
| 768 | + | }; | |
| 769 | + | const id = typeof message.id === "string" ? `${message.id}|${typeof entry.requestId === "string" ? entry.requestId : ""}` : null; | |
| 770 | + | return { id, model, counts }; | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | /** The tier a message is priced at: `long` for a claude-haiku-5-5 prompt over LONG_PROMPT_TOKENS. */ | |
| 774 | + | export function usageTier(model, counts) { | |
| 775 | + | const prompt = counts.input_tokens + counts.cache_read_tokens + counts.cache_write_5m_tokens + counts.cache_write_1h_tokens; | |
| 776 | + | return /claude-haiku-5-5/i.test(model) && prompt > LONG_PROMPT_TOKENS ? "long" : null; | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | function addTo(totals, model, counts, sign) { | |
| 780 | + | const tier = usageTier(model, counts); | |
| 781 | + | const key = `${model}\u0000${tier ?? ""}`; | |
| 782 | + | const row = (totals[key] ??= { model, ...(tier ? { tier } : {}), ...Object.fromEntries(COUNTS.map((name) => [name, 0])) }); | |
| 783 | + | for (const name of COUNTS) row[name] = Math.max(0, row[name] + sign * counts[name]); | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /** | |
| 787 | + | * Counts one transcript line into `state` ({ totals, recent }). Claude | |
| 788 | + | * Code writes a line for each part of one API response, each with the | |
| 789 | + | * response's usage: a message seen again (by message.id and requestId) | |
| 790 | + | * counts once, at the most each count was given. | |
| 791 | + | */ | |
| 792 | + | export function countLine(state, line) { | |
| 793 | + | if (!line.includes('"usage"')) return; | |
| 794 | + | let entry; | |
| 795 | + | try { | |
| 796 | + | entry = JSON.parse(line); | |
| 797 | + | } catch { | |
| 798 | + | return; | |
| 799 | + | } | |
| 800 | + | const found = entryUsage(entry); | |
| 801 | + | if (!found) return; | |
| 802 | + | const { id, model, counts } = found; | |
| 803 | + | const before = id ? state.recent[id] : null; | |
| 804 | + | if (before) { | |
| 805 | + | addTo(state.totals, before.model, before.counts, -1); | |
| 806 | + | for (const name of COUNTS) counts[name] = Math.max(counts[name], before.counts[name]); | |
| 807 | + | delete state.recent[id]; | |
| 808 | + | } | |
| 809 | + | addTo(state.totals, model, counts, 1); | |
| 810 | + | if (id) { | |
| 811 | + | state.recent[id] = { model, counts }; | |
| 812 | + | const ids = Object.keys(state.recent); | |
| 813 | + | for (const old of ids.slice(0, Math.max(0, ids.length - RECENT_MESSAGES))) delete state.recent[old]; | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /** The usage counted so far, as `g1t session heartbeat --usage-json` takes it: models with tokens. */ | |
| 818 | + | export function usageList(state) { | |
| 819 | + | return Object.values(state.totals).filter((row) => COUNTS.some((name) => row[name] > 0)); | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | /** | |
| 823 | + | * A transcript's token usage so far, per model and tier: what it has | |
| 824 | + | * counted before (kept at `statePath`, with how far it read), and the | |
| 825 | + | * lines written since, read a chunk at a time. Only complete lines are | |
| 826 | + | * counted. A transcript that got shorter is counted again from the top. | |
| 827 | + | * Null when it cannot be read; never throws. | |
| 828 | + | */ | |
| 829 | + | export function transcriptUsage(transcript, statePath) { | |
| 830 | + | let fd; | |
| 831 | + | try { | |
| 832 | + | const size = statSync(transcript).size; | |
| 833 | + | let state = statePath ? readJson(statePath, null) : null; | |
| 834 | + | if (!state || state.path !== transcript || !(state.offset <= size) || typeof state.totals !== "object" || typeof state.recent !== "object") { | |
| 835 | + | state = { path: transcript, offset: 0, totals: {}, recent: {} }; | |
| 836 | + | } | |
| 837 | + | fd = openSync(transcript, "r"); | |
| 838 | + | const buffer = Buffer.alloc(USAGE_CHUNK); | |
| 839 | + | let rest = Buffer.alloc(0); | |
| 840 | + | let at = state.offset; | |
| 841 | + | while (at < size) { | |
| 842 | + | const read = readSync(fd, buffer, 0, Math.min(USAGE_CHUNK, size - at), at); | |
| 843 | + | if (read <= 0) break; | |
| 844 | + | at += read; | |
| 845 | + | const chunk = Buffer.concat([rest, buffer.subarray(0, read)]); | |
| 846 | + | const end = chunk.lastIndexOf(10); | |
| 847 | + | rest = chunk; | |
| 848 | + | if (end < 0) continue; | |
| 849 | + | for (const line of chunk.subarray(0, end).toString("utf8").split("\n")) countLine(state, line); | |
| 850 | + | // Counted up to and with the last newline; the rest is read again with what follows. | |
| 851 | + | state.offset = at - (chunk.length - end - 1); | |
| 852 | + | rest = chunk.subarray(end + 1); | |
| 853 | + | } | |
| 854 | + | if (statePath) write(statePath, JSON.stringify(state)); | |
| 855 | + | return usageList(state); | |
| 856 | + | } catch { | |
| 857 | + | return null; | |
| 858 | + | } finally { | |
| 859 | + | if (fd !== undefined) { | |
| 860 | + | try { | |
| 861 | + | closeSync(fd); | |
| 862 | + | } catch {} | |
| 863 | + | } | |
| 864 | + | } | |
| 865 | + | } | |
| 866 | + | ||
| 718 | 867 | // --- saying so once ---------------------------------------------------- | |
| 719 | 868 | ||
| 720 | 869 | const NOT_RECORDED = "g1t: this session is not being recorded on g1t, because"; | |
| ⋯ | |||
| 894 | 1043 | opts.env.G1T_SESSION = session; | |
| 895 | 1044 | for (const step of job.steps) { | |
| 896 | 1045 | if (step.args) { | |
| 897 | − | const args = step.key ? [...step.args.slice(0, -2), `--key=${step.key}`, ...step.args.slice(-2)] : step.args; | |
| 1046 | + | let args = step.key ? [...step.args.slice(0, -2), `--key=${step.key}`, ...step.args.slice(-2)] : step.args; | |
| 1047 | + | if (step.usage) { | |
| 1048 | + | // Only a CLI that takes it is given usage: the counts, never what the transcript says. | |
| 1049 | + | const usage = version && !older(version, USAGE_CLI) ? transcriptUsage(step.usage, files.usage(step.usage)) : null; | |
| 1050 | + | if (usage?.length) args = [...args, `--usage-json=${JSON.stringify(usage)}`]; | |
| 1051 | + | else if (step.only) continue; | |
| 1052 | + | } | |
| 898 | 1053 | const out = cli(bin, args, opts); | |
| 899 | 1054 | if (out.status === EXIT_AUTH) return setProblem(env, "signed_out"); | |
| 900 | 1055 | if (out.status === EXIT_USAGE) return setProblem(env, "refused", step.args.slice(0, 2).join(" ")); | |
| 1 | 1 | { | |
| 2 | − | "about": "Every g1t CLI invocation the plugin's hooks (hooks/g1t.mjs) can make: each command, the flags it passes and the values they take. The hook tests' stand-in CLI (test/fake-g1t.mjs) refuses anything not here, and crates/g1t's tests parse every command here, with every value, with the real CLI's parser, so the hooks never send what the CLI rejects. minimum_version is the oldest CLI that accepts all of it: the hooks say so when the installed CLI is older. Types: text, number, switch, json (--json, optionally with fields), or a list of the values accepted.", | |
| 2 | + | "about": "Every g1t CLI invocation the plugin's hooks (hooks/g1t.mjs) can make: each command, the flags it passes and the values they take. The hook tests' stand-in CLI (test/fake-g1t.mjs) refuses anything not here, and crates/g1t's tests parse every command here, with every value, with the real CLI's parser, so the hooks never send what the CLI rejects. minimum_version is the oldest CLI that accepts all of it: the hooks say so when the installed CLI is older. Types: text, number, switch, json (--json, optionally with fields), or a list of the values accepted. A flag in a command's since is newer than minimum_version: the hooks pass it only to a CLI of that version or later.", | |
| 3 | 3 | "minimum_version": "0.4.0", | |
| 4 | 4 | "commands": [ | |
| 5 | 5 | { "command": [], "flags": { "--version": "switch" } }, | |
| ⋯ | |||
| 33 | 33 | "one_of": ["--file", "--commit", "--branch", "--item"] | |
| 34 | 34 | }, | |
| 35 | 35 | { "command": ["session", "end"], "flags": { "--reason": "text", "--result": "text" } }, | |
| 36 | − | { "command": ["session", "heartbeat"], "flags": { "--json": "json" } }, | |
| 36 | + | { | |
| 37 | + | "command": ["session", "heartbeat"], | |
| 38 | + | "flags": { "--usage-json": "text", "--json": "json" }, | |
| 39 | + | "since": { "--usage-json": "0.4.1" } | |
| 40 | + | }, | |
| 37 | 41 | { "command": ["session", "attach"], "flags": { "--title": "text" }, "required": ["--title"] }, | |
| 38 | 42 | { "command": ["work", "view"], "flags": {}, "positionals": ["number"] }, | |
| 39 | 43 | { | |
| 13 | 13 | ||
| 14 | 14 | export const CONTRACT = JSON.parse(readFileSync(new URL("./cli-contract.json", import.meta.url), "utf8")); | |
| 15 | 15 | ||
| 16 | − | /** Why the real CLI would refuse `args`, or null when it takes them. */ | |
| 17 | − | export function refusal(args, contract = CONTRACT) { | |
| 16 | + | /** Whether version a (1.2.3) is older than b. */ | |
| 17 | + | const olderThan = (a, b) => { | |
| 18 | + | const [pa, pb] = [a, b].map((v) => String(v).split(".").map(Number)); | |
| 19 | + | for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0); | |
| 20 | + | return false; | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * Why the real CLI would refuse `args`, or null when it takes them. A CLI | |
| 25 | + | * of `version` refuses a flag its command's `since` says is newer; with no | |
| 26 | + | * version, it is the newest. | |
| 27 | + | */ | |
| 28 | + | export function refusal(args, contract = CONTRACT, version = null) { | |
| 18 | 29 | const command = contract.commands | |
| 19 | 30 | .filter((c) => c.command.every((word, i) => args[i] === word)) | |
| 20 | 31 | .sort((a, b) => b.command.length - a.command.length)[0]; | |
| ⋯ | |||
| 40 | 51 | const flag = eq > 0 ? token.slice(0, eq) : token; | |
| 41 | 52 | const type = command.flags[flag]; | |
| 42 | 53 | if (type === undefined) return `${name} takes no ${flag}`; | |
| 54 | + | if (version && command.since?.[flag] && olderThan(version, command.since[flag])) return `${name} takes no ${flag} (the CLI is ${version})`; | |
| 43 | 55 | if (seen.has(flag) && !(command.repeatable ?? []).includes(flag)) return `${flag} given twice`; | |
| 44 | 56 | let value = null; | |
| 45 | 57 | if (eq > 0) { | |
| ⋯ | |||
| 78 | 90 | // `touch` only when the caller left the CLI to guess touched files from the whole working copy. | |
| 79 | 91 | const guesses = process.env.G1T_TOUCH === "0" ? {} : { touch: process.env.G1T_TOUCH ?? "unset" }; | |
| 80 | 92 | appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null, ...guesses })}\n`); | |
| 81 | − | const refused = refusal(args); | |
| 93 | + | const refused = refusal(args, CONTRACT, process.env.FAKE_G1T_VERSION ?? CONTRACT.minimum_version); | |
| 82 | 94 | if (refused) { | |
| 83 | 95 | process.stderr.write(`g1t: ${refused} See --help.\n`); | |
| 84 | 96 | process.exit(2); | |
| 23 | 23 | oldNode, | |
| 24 | 24 | older, | |
| 25 | 25 | typedSkill, | |
| 26 | + | transcriptUsage, | |
| 27 | + | USAGE_CLI, | |
| 28 | + | LONG_PROMPT_TOKENS, | |
| 26 | 29 | isG1tRemote, | |
| 27 | 30 | isGitCommit, | |
| 28 | 31 | isGitPush, | |
| ⋯ | |||
| 48 | 51 | ||
| 49 | 52 | const base = { session_id: "abc-123", cwd: "/work/repo", transcript_path: "/t.jsonl", permission_mode: "default" }; | |
| 50 | 53 | ||
| 54 | + | /** An assistant line of a transcript, as Claude Code writes one for each part of an API response. */ | |
| 55 | + | function assistant(id, model, usage, requestId = `req_${id}`) { | |
| 56 | + | return JSON.stringify({ type: "assistant", requestId, message: { id, model, role: "assistant", content: [{ type: "text", text: "the secret plan" }], usage } }); | |
| 57 | + | } | |
| 58 | + | ||
| 51 | 59 | describe("plan", () => { | |
| 52 | 60 | test("SessionStart starts the session", () => { | |
| 53 | 61 | const job = plan({ ...base, hook_event_name: "SessionStart", source: "startup" }, {}); | |
| ⋯ | |||
| 112 | 120 | assert.equal(job.item, 42); | |
| 113 | 121 | }); | |
| 114 | 122 | ||
| 115 | − | test("Stop sends a heartbeat and SessionEnd ends the session with its reason", () => { | |
| 116 | − | assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"] }]); | |
| 123 | + | test("Stop sends a heartbeat with the transcript's usage, and SessionEnd reports it and ends the session with its reason", () => { | |
| 124 | + | assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"], usage: "/t.jsonl" }]); | |
| 125 | + | const { transcript_path, ...untracked } = base; | |
| 126 | + | assert.deepEqual(plan({ ...untracked, hook_event_name: "Stop" }, {}).steps, [{ args: ["session", "heartbeat"] }]); | |
| 117 | 127 | const end = plan({ ...base, hook_event_name: "SessionEnd", reason: "prompt_input_exit" }, {}); | |
| 118 | − | assert.deepEqual(end.steps, [{ args: ["session", "end", "--reason", "prompt_input_exit"] }]); | |
| 128 | + | assert.deepEqual(end.steps, [ | |
| 129 | + | { args: ["session", "heartbeat"], usage: "/t.jsonl", only: true }, | |
| 130 | + | { args: ["session", "end", "--reason", "prompt_input_exit"] }, | |
| 131 | + | ]); | |
| 119 | 132 | assert.equal(end.end, true); | |
| 120 | 133 | }); | |
| 121 | 134 | ||
| ⋯ | |||
| 194 | 207 | assert.equal(subagentHarnessId("abc-123", "def456"), "abc-123/agent/def456", "the Agent tool's id names the same subagent"); | |
| 195 | 208 | }); | |
| 196 | 209 | ||
| 197 | − | test("SubagentStop ends it with its final message, cut to the limit", () => { | |
| 210 | + | test("SubagentStop reports its own transcript's usage and ends it with its final message, cut to the limit", () => { | |
| 198 | 211 | const job = plan(fixtures.subagentStop, {}); | |
| 199 | 212 | assert.deepEqual(job.agent, { id: "def456", type: "Explore" }); | |
| 200 | − | assert.deepEqual(job.steps, [{ args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."] }]); | |
| 213 | + | assert.deepEqual(job.steps, [ | |
| 214 | + | { args: ["session", "heartbeat"], usage: "/t/abc-123/subagents/agent-def456.jsonl", only: true }, | |
| 215 | + | { args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."] }, | |
| 216 | + | ]); | |
| 217 | + | // Without agent_transcript_path, its own beside its parent's. | |
| 218 | + | const { agent_transcript_path, ...noPath } = fixtures.subagentStop; | |
| 219 | + | assert.equal(plan(noPath, {}).steps[0].usage, join("/t", "subagents", "agent-def456.jsonl")); | |
| 201 | 220 | const long = plan({ ...fixtures.subagentStop, last_assistant_message: "z".repeat(RESULT_LIMIT * 2) }, {}); | |
| 202 | − | assert.equal(long.steps[0].args.at(-1).length, "--result=".length + RESULT_LIMIT); | |
| 221 | + | assert.equal(long.steps.at(-1).args.at(-1).length, "--result=".length + RESULT_LIMIT); | |
| 203 | 222 | const dash = plan({ ...fixtures.subagentStop, last_assistant_message: "- Found 3 issues" }, {}); | |
| 204 | − | assert.equal(dash.steps[0].args.at(-1), "--result=- Found 3 issues", "a message that starts with a dash is still a value"); | |
| 205 | − | assert.equal(refusal(dash.steps[0].args), null); | |
| 223 | + | assert.equal(dash.steps.at(-1).args.at(-1), "--result=- Found 3 issues", "a message that starts with a dash is still a value"); | |
| 224 | + | assert.equal(refusal(dash.steps.at(-1).args), null); | |
| 206 | 225 | }); | |
| 207 | 226 | ||
| 208 | 227 | test("an Agent call gives its subagent the task, and once finished its final message, and nothing else of its answer", () => { | |
| ⋯ | |||
| 741 | 760 | assert.match(refusal(["work", "view", "abc"]), /not abc/); | |
| 742 | 761 | assert.match(refusal(["session", "pause"]), /takes 0 argument/); | |
| 743 | 762 | assert.equal(refusal(["api", "update_session", "-f", "id=ses_1", "-f", "head_commit=abc", "--silent"]), null); | |
| 763 | + | // --usage-json is newer than the oldest CLI the hooks take: only a CLI that has it is given it. | |
| 764 | + | const usage = ["session", "heartbeat", '--usage-json=[{"model":"claude-opus-5-5","input_tokens":1}]']; | |
| 765 | + | assert.equal(refusal(usage, CONTRACT, USAGE_CLI), null); | |
| 766 | + | assert.match(refusal(usage, CONTRACT, MIN_CLI), /takes no --usage-json/); | |
| 767 | + | assert.equal(CONTRACT.commands.find((c) => c.command.join(" ") === "session heartbeat").since["--usage-json"], USAGE_CLI); | |
| 768 | + | }); | |
| 769 | + | ||
| 770 | + | test("a session's usage is reported from its own transcript, to a CLI that takes it, and only the counts", () => { | |
| 771 | + | const transcript = join(dir, "t.jsonl"); | |
| 772 | + | writeFileSync(transcript, `${JSON.stringify({ type: "user", message: { role: "user", content: "the secret plan" } })}\n${assistant("msg_1", "claude-opus-5-5", { input_tokens: 10, output_tokens: 20 })}\n`); | |
| 773 | + | const subagent = join(dir, "agent-def456.jsonl"); | |
| 774 | + | writeFileSync(subagent, `${assistant("msg_2", "claude-haiku-4-5", { input_tokens: 7 })}\n`); | |
| 775 | + | const newer = { ...env, FAKE_G1T_VERSION: USAGE_CLI }; | |
| 776 | + | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, newer); | |
| 777 | + | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, newer); | |
| 778 | + | work(hook({ ...base, transcript_path: transcript, cwd: dir, hook_event_name: "Stop" }, newer).job, newer); | |
| 779 | + | work(hook({ ...fixtures.subagentStop, transcript_path: transcript, agent_transcript_path: subagent, cwd: dir }, newer).job, newer); | |
| 780 | + | const heartbeats = calls().filter((c) => c.args[1] === "heartbeat"); | |
| 781 | + | const usage = (call) => JSON.parse(call.args.find((a) => a.startsWith("--usage-json=")).slice("--usage-json=".length)); | |
| 782 | + | assert.equal(heartbeats[0].session, "ses_fake01"); | |
| 783 | + | assert.deepEqual(usage(heartbeats[0]), [{ model: "claude-opus-5-5", input_tokens: 10, output_tokens: 20, cache_write_5m_tokens: 0, cache_write_1h_tokens: 0, cache_read_tokens: 0 }]); | |
| 784 | + | // The subagent's own, to its own session, before it ends; never its parent's. | |
| 785 | + | assert.equal(heartbeats[1].session, "ses_sub_def456"); | |
| 786 | + | assert.deepEqual(usage(heartbeats[1]).map((row) => [row.model, row.input_tokens]), [["claude-haiku-4-5", 7]]); | |
| 787 | + | const order = calls().filter((c) => c.session === "ses_sub_def456").map((c) => c.args[1]); | |
| 788 | + | assert.ok(order.indexOf("heartbeat") < order.indexOf("end"), String(order)); | |
| 789 | + | assert.ok(!allCalls().some((c) => c.args.join(" ").includes("secret plan")), "nothing of what the transcript says is sent"); | |
| 790 | + | // A CLI without --usage-json gets a plain heartbeat, and no extra one before a subagent ends. | |
| 791 | + | rmSync(log, { force: true }); | |
| 792 | + | rmSync(join(env.CLAUDE_PLUGIN_DATA, "cli.json"), { force: true }); | |
| 793 | + | work(hook({ ...base, transcript_path: transcript, cwd: dir, hook_event_name: "Stop" }, env).job, env); | |
| 794 | + | work(hook({ ...fixtures.subagentStop, transcript_path: transcript, agent_transcript_path: subagent, cwd: dir }, env).job, env); | |
| 795 | + | assert.deepEqual(calls().filter((c) => c.args[1] === "heartbeat").map((c) => c.args), [["session", "heartbeat"]]); | |
| 744 | 796 | }); | |
| 745 | 797 | ||
| 746 | 798 | test("PowerShell commands are read as Bash ones are, and a subagent's act as its session", () => { | |
| ⋯ | |||
| 921 | 973 | assert.equal(other.refreshUpdates, undefined); | |
| 922 | 974 | } finally { rmSync(dir, { recursive: true, force: true }); } | |
| 923 | 975 | }); | |
| 976 | + | ||
| 977 | + | describe("a transcript's usage", () => { | |
| 978 | + | let dir; | |
| 979 | + | beforeEach(() => { | |
| 980 | + | dir = mkdtempSync(join(tmpdir(), "g1t-usage-")); | |
| 981 | + | }); | |
| 982 | + | afterEach(() => rmSync(dir, { recursive: true, force: true })); | |
| 983 | + | ||
| 984 | + | const lines = [ | |
| 985 | + | JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }), | |
| 986 | + | // One response written as three lines, the last with its final output count. | |
| 987 | + | assistant("msg_1", "claude-opus-5-5", { input_tokens: 2, output_tokens: 6, cache_read_input_tokens: 100, cache_creation_input_tokens: 50, cache_creation: { ephemeral_5m_input_tokens: 30, ephemeral_1h_input_tokens: 20 } }), | |
| 988 | + | assistant("msg_1", "claude-opus-5-5", { input_tokens: 2, output_tokens: 6, cache_read_input_tokens: 100, cache_creation_input_tokens: 50, cache_creation: { ephemeral_5m_input_tokens: 30, ephemeral_1h_input_tokens: 20 } }), | |
| 989 | + | assistant("msg_1", "claude-opus-5-5", { input_tokens: 2, output_tokens: 40, cache_read_input_tokens: 100, cache_creation_input_tokens: 50, cache_creation: { ephemeral_5m_input_tokens: 30, ephemeral_1h_input_tokens: 20 } }), | |
| 990 | + | // Without the split, a cache write is the 5-minute kind. | |
| 991 | + | assistant("msg_2", "claude-opus-5-5", { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 9 }), | |
| 992 | + | // The same message id from another request is another response. | |
| 993 | + | assistant("msg_2", "claude-opus-5-5", { input_tokens: 1, output_tokens: 1 }, "req_other"), | |
| 994 | + | // A claude-haiku-5-5 prompt over 100,000 tokens is its long tier; one under is not. | |
| 995 | + | assistant("msg_3", "claude-haiku-5-5-20260101", { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: LONG_PROMPT_TOKENS }), | |
| 996 | + | assistant("msg_4", "claude-haiku-5-5-20260101", { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 99_000 }), | |
| 997 | + | JSON.stringify({ type: "assistant", message: { id: "msg_5", model: "<synthetic>", usage: { input_tokens: 0, output_tokens: 0 } } }), | |
| 998 | + | "not json", | |
| 999 | + | ]; | |
| 1000 | + | ||
| 1001 | + | test("counts each response once, per model and tier", () => { | |
| 1002 | + | const path = join(dir, "t.jsonl"); | |
| 1003 | + | writeFileSync(path, `${lines.join("\n")}\n`); | |
| 1004 | + | const usage = transcriptUsage(path, null); | |
| 1005 | + | assert.deepEqual(usage, [ | |
| 1006 | + | { model: "claude-opus-5-5", input_tokens: 4, output_tokens: 42, cache_write_5m_tokens: 39, cache_write_1h_tokens: 20, cache_read_tokens: 100 }, | |
| 1007 | + | { model: "claude-haiku-5-5-20260101", tier: "long", input_tokens: 10, output_tokens: 5, cache_write_5m_tokens: 0, cache_write_1h_tokens: 0, cache_read_tokens: LONG_PROMPT_TOKENS }, | |
| 1008 | + | { model: "claude-haiku-5-5-20260101", input_tokens: 10, output_tokens: 5, cache_write_5m_tokens: 0, cache_write_1h_tokens: 0, cache_read_tokens: 99_000 }, | |
| 1009 | + | ]); | |
| 1010 | + | }); | |
| 1011 | + | ||
| 1012 | + | test("reads on from where it stopped, a line written in half counted once it is whole", () => { | |
| 1013 | + | const path = join(dir, "t.jsonl"); | |
| 1014 | + | const state = join(dir, "state.json"); | |
| 1015 | + | const whole = `${lines.join("\n")}\n`; | |
| 1016 | + | const cut = whole.indexOf("msg_2") + 10; | |
| 1017 | + | writeFileSync(path, whole.slice(0, cut)); | |
| 1018 | + | const first = transcriptUsage(path, state); | |
| 1019 | + | assert.equal(first.find((row) => row.model === "claude-opus-5-5").output_tokens, 40); | |
| 1020 | + | writeFileSync(path, whole); | |
| 1021 | + | assert.deepEqual(transcriptUsage(path, state), transcriptUsage(path, null)); | |
| 1022 | + | // A transcript written again from the top is counted again. | |
| 1023 | + | writeFileSync(path, `${lines[4]}\n`); | |
| 1024 | + | assert.deepEqual(transcriptUsage(path, state).map((row) => row.input_tokens), [1]); | |
| 1025 | + | }); | |
| 1026 | + | ||
| 1027 | + | test("a transcript that cannot be read gives nothing, and never throws", () => { | |
| 1028 | + | assert.equal(transcriptUsage(join(dir, "missing.jsonl"), join(dir, "state.json")), null); | |
| 1029 | + | const path = join(dir, "empty.jsonl"); | |
| 1030 | + | writeFileSync(path, ""); | |
| 1031 | + | assert.deepEqual(transcriptUsage(path, null), []); | |
| 1032 | + | }); | |
| 1033 | + | }); | |
Comments
Sign in to comment.