Merge branch 'worktree-agent-a95086bc0feb8d286' into home-signal
34 files+929−422 files not counted0/34 viewed
| 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 | ] |
| 1396 | 1396 | ||
| 1397 | 1397 | [[package]] | |
| 1398 | 1398 | name = "g1t" | |
| 1399 | − | version = "0.4.0" | |
| 1399 | + | version = "0.4.1" | |
| 1400 | 1400 | dependencies = [ | |
| 1401 | 1401 | "anstyle-query", | |
| 1402 | 1402 | "base64 0.22.1", |
| 134 | 134 | pub fn description(self) -> &'static str { | |
| 135 | 135 | match self { | |
| 136 | 136 | CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the work item, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `item` and `pull` attach it to a work item of the repository's workspace and a pull request of the repository; a pull request for a work item attaches that work item too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's work item, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open work items.", | |
| 137 | − | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.", | |
| 137 | + | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. `usage` reports the session's token usage so far, per model, replacing what was reported before: g1t estimates `cost_usd` from it at Anthropic's list price (none when no model in it has a known price), with `cost_partial` true when some of it was for a model it has no price for. Returns the session; one ended with end_session is refused with `409`.", | |
| 138 | 138 | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the work item off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.", | |
| 139 | 139 | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may. With `workspace` instead of `repo`, the session is found in whichever repository of the workspace it runs in, and `repo` says which.", | |
| 140 | 140 | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may. With `workspace` instead of `repo`, every repository of the workspace you may read: see list_workspace_sessions.", | |
| ⋯ | |||
| 206 | 206 | ), | |
| 207 | 207 | &["repo", "harness"], | |
| 208 | 208 | ), | |
| 209 | − | CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]), | |
| 209 | + | CoordinationOp::UpdateSession => ( | |
| 210 | + | with( | |
| 211 | + | fields(json!({ | |
| 212 | + | "id": id, | |
| 213 | + | "result": result, | |
| 214 | + | "usage": { | |
| 215 | + | "type": "array", | |
| 216 | + | "description": "The session's token usage so far, one entry per model: `model`, `tier` (`long` for a claude-haiku-5-5 prompt over 100,000 tokens), `input_tokens`, `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and `cache_read_tokens`, each a whole number. Cumulative: it replaces the usage given before. Up to 50 models. g1t estimates the session's `cost_usd` from it at Anthropic's list price.", | |
| 217 | + | "items": { | |
| 218 | + | "type": "object", | |
| 219 | + | "properties": { | |
| 220 | + | "model": { "type": "string" }, | |
| 221 | + | "tier": { "type": "string" }, | |
| 222 | + | "input_tokens": { "type": "integer" }, | |
| 223 | + | "output_tokens": { "type": "integer" }, | |
| 224 | + | "cache_write_5m_tokens": { "type": "integer" }, | |
| 225 | + | "cache_write_1h_tokens": { "type": "integer" }, | |
| 226 | + | "cache_read_tokens": { "type": "integer" }, | |
| 227 | + | }, | |
| 228 | + | "required": ["model"], | |
| 229 | + | }, | |
| 230 | + | }, | |
| 231 | + | })), | |
| 232 | + | subagent.clone(), | |
| 233 | + | ), | |
| 234 | + | &["repo", "id"], | |
| 235 | + | ), | |
| 210 | 236 | CoordinationOp::EndSession => ( | |
| 211 | 237 | json!({ | |
| 212 | 238 | "repo": repo, | |
| ⋯ | |||
| 471 | 497 | g1t_kit::call(work, "start_session", &args).await | |
| 472 | 498 | } | |
| 473 | 499 | CoordinationOp::UpdateSession => { | |
| 500 | + | let usage = match &input["usage"] { | |
| 501 | + | Value::Null => None, | |
| 502 | + | given => match g1t_contracts::model_prices::parse_usage(given) { | |
| 503 | + | Ok(usage) => Some(usage), | |
| 504 | + | Err(message) => return invalid(message), | |
| 505 | + | }, | |
| 506 | + | }; | |
| 474 | 507 | let args = UpdateSessionArgs { | |
| 475 | 508 | actor: actor(), | |
| 476 | 509 | repo, | |
| ⋯ | |||
| 485 | 518 | prompt: text(input, "prompt"), | |
| 486 | 519 | model: text(input, "model"), | |
| 487 | 520 | result: text(input, "result"), | |
| 521 | + | usage, | |
| 488 | 522 | }; | |
| 489 | 523 | g1t_kit::call(work, "update_session", &args).await | |
| 490 | 524 | } | |
Binary or large file; its contents are not shown.
| 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 | title: Recording sessions | |
| 3 | − | description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, work before there is a work item, and have g1t start sessions on your Anthropic account. | |
| 3 | + | description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, report what a session cost, work before there is a work item, and have g1t start sessions on your Anthropic account. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A [session](/work/sessions/) is one run of your harness on a repository: | |
| ⋯ | |||
| 125 | 125 | [Claude Code plugin](/guides/claude-code-plugin/#subagents) does all of | |
| 126 | 126 | this for Claude Code's subagents. | |
| 127 | 127 | ||
| 128 | + | ## What a session cost | |
| 129 | + | ||
| 130 | + | A harness can say how many tokens a session used, and g1t estimates what | |
| 131 | + | that cost at Anthropic's list price. The session's page and the | |
| 132 | + | workspace's **Sessions** list show it as "≈ $4.12 at list price". It is | |
| 133 | + | an estimate, not a bill: it is what the tokens cost at Anthropic's | |
| 134 | + | published per-token prices, whatever your plan or discounts. | |
| 135 | + | ||
| 136 | + | 1. **Report** the session's usage so far with `session` `update`, | |
| 137 | + | `g1t session heartbeat --usage-json '…'`, or | |
| 138 | + | [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/) | |
| 139 | + | with `usage`: one entry per model, with its `input_tokens`, | |
| 140 | + | `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and | |
| 141 | + | `cache_read_tokens`. Usage is cumulative: each report replaces the | |
| 142 | + | last, so send the whole session's each time. | |
| 143 | + | ||
| 144 | + | ```json | |
| 145 | + | { | |
| 146 | + | "usage": [ | |
| 147 | + | { | |
| 148 | + | "model": "claude-opus-5-5", | |
| 149 | + | "input_tokens": 1840, | |
| 150 | + | "output_tokens": 52310, | |
| 151 | + | "cache_write_5m_tokens": 210400, | |
| 152 | + | "cache_write_1h_tokens": 0, | |
| 153 | + | "cache_read_tokens": 4102300 | |
| 154 | + | } | |
| 155 | + | ] | |
| 156 | + | } | |
| 157 | + | ``` | |
| 158 | + | ||
| 159 | + | 2. **g1t prices it** as it records it: each model's tokens at its list | |
| 160 | + | price, cache writes at 1.25 times the input price for 5 minutes and | |
| 161 | + | twice it for 1 hour. A model id is matched without its date | |
| 162 | + | (`-20251001`), a `[1m]` suffix or a provider prefix | |
| 163 | + | (`us.anthropic.`). For claude-haiku-5-5, a prompt over 100,000 tokens | |
| 164 | + | is priced at its long-prompt rate: give those tokens in an entry of | |
| 165 | + | their own with `tier` `long`. | |
| 166 | + | 3. **The session shows `cost_usd`**, the sum over the models g1t has a | |
| 167 | + | price for. A model it has no price for, another provider's included, | |
| 168 | + | is left out, and `cost_partial` says some usage was priced as unknown | |
| 169 | + | ("some usage priced as unknown" on the page). With no model priced, | |
| 170 | + | `cost_usd` is null. | |
| 171 | + | ||
| 172 | + | A subagent reports its own usage, to its own session: its parent's | |
| 173 | + | transcript leaves out what the subagent used, so nothing is counted twice. | |
| 174 | + | Only the workspace's members see what a session cost, as with the rest of | |
| 175 | + | what is inside it ([who sees what](/work/sessions/#who-sees-what)). Up to | |
| 176 | + | 50 models a session, each count a whole number of tokens. | |
| 177 | + | ||
| 178 | + | The [Claude Code plugin](/guides/claude-code-plugin/#what-a-session-cost) | |
| 179 | + | counts it from the session's transcript and sends only the counts. The | |
| 180 | + | Codex plugin does not report usage yet: it records sessions through g1t's | |
| 181 | + | MCP tools, with no hooks to read Codex's session files from. | |
| 182 | + | ||
| 128 | 183 | ## Start a session on your Anthropic account | |
| 129 | 184 | ||
| 130 | 185 | g1t runs no model. Work on a work item happens in your own harness, with | |
| 939 | 939 | g1t session heartbeat [OPTIONS] | |
| 940 | 940 | ``` | |
| 941 | 941 | ||
| 942 | + | | Argument or flag | | | |
| 943 | + | | --- | --- | | |
| 944 | + | | `--usage-json <JSON>` | The session's token usage so far, per model, as JSON; - reads it from stdin. | | |
| 945 | + | ||
| 946 | + | ```text | |
| 947 | + | --usage-json reports the session's token usage so far, which g1t prices at Anthropic's list price: | |
| 948 | + | a JSON list with one entry per model, each {model, tier?, input_tokens, output_tokens, | |
| 949 | + | cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens}. It is cumulative, so it replaces | |
| 950 | + | what the session reported before. - reads it from stdin. | |
| 951 | + | ``` | |
| 952 | + | ||
| 942 | 953 | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 943 | 954 | ||
| 944 | 955 | ### g1t session log |
| 430 | 430 | | Action | What it does | Required | Scope | | |
| 431 | 431 | | --- | --- | --- | --- | | |
| 432 | 432 | | [`start`](/reference/api/sessions/start-session/) | Start a session: `harness` (`claude_code`, `claude_managed_agents`, `codex`, `cursor`, `copilot` or `other`), and `harness_session_id`, which makes starting again return the same session. `item` (a work item of the repository's workspace), `pull`, `branch`, `title` and `head_commit` as you know them. A subagent gives `parent_id`, `agent_type`, `task` and `prompt`, and starts on its parent's work item and branch. | `repo`, `harness` | `pull_requests:write` | | |
| 433 | − | | [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`. | `repo`, `id` | `pull_requests:write` | | |
| 433 | + | | [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`; `usage` reports its token usage so far, per model, from which g1t estimates `cost_usd`. | `repo`, `id` | `pull_requests:write` | | |
| 434 | 434 | | [`record`](/reference/api/sessions/record-session-entries/) | Record up to 200 `entries`, each with `kind` and `text` (`skill` for a skill used, by name), `tool` for tools, and a `key` that makes recording it again a no-op; and `touched`, what it touched (`file`, `commit`, `branch`, `pull`, `item`), which ties it to pull requests and work items and tells sessions on the same files of each other. | `repo`, `id` | `pull_requests:write` | | |
| 435 | 435 | | [`end`](/reference/api/sessions/end-session/) | End it, with an optional `reason` and `result` (what it reported). Its subagents still going end with it. | `repo`, `id` | `pull_requests:write` | | |
| 436 | 436 | | [`get`](/reference/api/sessions/get-session/) | A session and its entries (`after` pages through them), its subagents (`children`), what they touched, the work items and pull requests they worked on (`links`) and the skills used. With [`workspace`](/reference/api/sessions/get-workspace-session/) and `id` instead of `repo`, a session of any of the workspace's repositories, with its `repo`. | `repo` or `workspace`, `id` | `pull_requests:read` | |
| 223 | 223 | | Who | Sees | | |
| 224 | 224 | | --- | --- | | |
| 225 | 225 | | Whoever ran it | All of it: its prompts, tool calls, what it touched and reported, its pending updates. | | |
| 226 | − | | A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. | | |
| 227 | − | | Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch or model. On a pull request's session, only g1t's own notes. | | |
| 226 | + | | A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, what it cost ([estimated at list price](/guides/sessions/#what-a-session-cost)), its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. | | |
| 227 | + | | Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch, model or cost. On a pull request's session, only g1t's own notes. | | |
| 228 | 228 | ||
| 229 | 229 | g1t's own agent's sessions are listed only for members. | |
| 230 | 230 |
Binary or large file; its contents are not shown.
| 24 | 24 | import { ItemIcon, PullIcon } from "./work-icons"; | |
| 25 | 25 | import { cn } from "../lib/cn"; | |
| 26 | 26 | import { HARNESS_NAMES, SESSION_STATES } from "../lib/coordination"; | |
| 27 | − | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; | |
| 27 | + | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionCost, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; | |
| 28 | 28 | import { itemPath, sessionsPath } from "../lib/work-items"; | |
| 29 | 29 | ||
| 30 | 30 | const BRANDS: Partial<Record<Harness, { path: string; title: string }>> = { | |
| ⋯ | |||
| 121 | 121 | ); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | /** What a harness session cost, quietly, when it reported usage. */ | |
| 125 | + | function Cost({ session }: { session: WorkspaceSession }) { | |
| 126 | + | const cost = sessionCost(session.session); | |
| 127 | + | if (!cost) return null; | |
| 128 | + | return ( | |
| 129 | + | <Hint label="Estimated from the tokens its transcript recorded, at Anthropic's list price. Its subagents count on their own sessions."> | |
| 130 | + | <span className="text-xs text-faint tabular-nums">{cost}</span> | |
| 131 | + | </Hint> | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | ||
| 124 | 135 | /** | |
| 125 | 136 | * A live session: its state, what it is called, who runs it and in what, | |
| 126 | 137 | * where, the work item and pull request it is on, its subagents and when | |
| ⋯ | |||
| 153 | 164 | <Where session={session} workspace={workspace} /> | |
| 154 | 165 | </div> | |
| 155 | 166 | {session.step && <p className="line-clamp-1 text-xs text-muted">{session.step}</p>} | |
| 167 | + | <Cost session={session} /> | |
| 156 | 168 | {(session.item != null || session.pull != null || session.subagents > 0) && ( | |
| 157 | 169 | <div className="flex flex-wrap items-center gap-2"> | |
| 158 | 170 | {session.item != null && <ItemChip workspace={workspace} number={session.item} title={session.itemTitle} />} | |
| ⋯ | |||
| 194 | 206 | </span> | |
| 195 | 207 | <Where session={session} workspace={workspace} /> | |
| 196 | 208 | {session.subagents > 0 && <span>{session.subagents} subagents</span>} | |
| 209 | + | <Cost session={session} /> | |
| 197 | 210 | </div> | |
| 198 | 211 | </div> | |
| 199 | 212 | <span className="shrink-0 text-xs text-faint"> | |
| 3 | 3 | ||
| 4 | 4 | import type { WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { narrowed, peopleIn, repoName, runsIn, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 6 | + | import { narrowed, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 9 | 9 | return { | |
| ⋯ | |||
| 86 | 86 | assert.equal(repoName("acme/web", "Acme"), "web"); | |
| 87 | 87 | assert.equal(repoName("other/web", "acme"), "other/web"); | |
| 88 | 88 | }); | |
| 89 | + | ||
| 90 | + | test("a session's cost reads as an estimate at list price, and says when some usage is priced as unknown", () => { | |
| 91 | + | assert.equal(sessionCost({ costUsd: 4.1234 }), "≈ $4.12 at list price"); | |
| 92 | + | assert.equal(sessionCost({ costUsd: 1234.5, costPartial: true }), "≈ $1,234.50 at list price, some usage priced as unknown"); | |
| 93 | + | assert.equal(sessionCost({ costUsd: 0.004 }), "≈ under $0.01 at list price"); | |
| 94 | + | assert.equal(sessionCost({ costUsd: 0 }), "≈ $0.00 at list price"); | |
| 95 | + | assert.equal(sessionCost({ costUsd: null, costPartial: true }), "Usage priced as unknown"); | |
| 96 | + | assert.equal(sessionCost({ costUsd: null }), null); | |
| 97 | + | assert.equal(sessionCost({}), null); | |
| 98 | + | assert.equal(sessionCost(null), null); | |
| 99 | + | }); | |
| 3 | 3 | * and where it opens, the filters Sessions keeps in its address, and live | |
| 4 | 4 | * ones apart from the rest. Pure, so it is tested under Node. | |
| 5 | 5 | */ | |
| 6 | − | import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts"; | |
| 6 | + | import type { Harness, SessionKind, WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import { HARNESS_NAMES, sessionTitle } from "./coordination.ts"; | |
| 9 | 9 | import { sessionPath } from "./work-items.ts"; | |
| ⋯ | |||
| 106 | 106 | return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name); | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | /** | |
| 110 | + | * What a session cost, in a few words: "≈ $4.12 at list price", with | |
| 111 | + | * "some usage priced as unknown" when some of it was for models g1t has no | |
| 112 | + | * price for. Null when it reported no usage. | |
| 113 | + | */ | |
| 114 | + | export function sessionCost(session: Pick<WorkSession, "costUsd" | "costPartial"> | null | undefined): string | null { | |
| 115 | + | if (!session) return null; | |
| 116 | + | const cost = session.costUsd; | |
| 117 | + | if (cost == null || !Number.isFinite(cost)) return session.costPartial ? "Usage priced as unknown" : null; | |
| 118 | + | const amount = cost > 0 && cost < 0.01 ? "under $0.01" : `$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| 119 | + | return `≈ ${amount} at list price${session.costPartial ? ", some usage priced as unknown" : ""}`; | |
| 120 | + | } | |
| 121 | + | ||
| 109 | 122 | /** `acme/web` as the workspace `acme` names it: `web`. */ | |
| 110 | 123 | export function repoName(path: string, workspace: string): string { | |
| 111 | 124 | const [owner, name] = path.split("/"); | |
| 35 | 35 | viaLabel, | |
| 36 | 36 | } from "../../../../lib/coordination"; | |
| 37 | 37 | import { notFound } from "../../../../lib/not-found.server"; | |
| 38 | + | import { sessionCost } from "../../../../lib/sessions"; | |
| 38 | 39 | import { repoPages } from "../../../../lib/repo-live"; | |
| 39 | 40 | import { agentSessions, work } from "../../../../lib/services.server"; | |
| 40 | 41 | import { assertSameOrigin, getViewer, requireUser } from "../../../../lib/session.server"; | |
| ⋯ | |||
| 341 | 342 | <SessionStateBadge state={session.state} /> | |
| 342 | 343 | <TimeAgo at={session.startedAt} /> | |
| 343 | 344 | <span suppressHydrationWarning>· {duration(session, now)}</span> | |
| 345 | + | {sessionCost(session) && <span className="tabular-nums">· {sessionCost(session)}</span>} | |
| 344 | 346 | </span> | |
| 345 | 347 | </div> | |
| 346 | 348 | <CollapsibleContent className="mt-3 space-y-4 pl-6"> | |
| ⋯ | |||
| 390 | 392 | // Its own subagents: those whose parent is in this tree (children lists every descendant). | |
| 391 | 393 | const tree = sessionTree([session, ...children]).find((node) => node.session.id === session.id)?.children ?? []; | |
| 392 | 394 | const managed = session.harness === MANAGED || root?.harness === MANAGED; | |
| 395 | + | const cost = sessionCost(session); | |
| 393 | 396 | ||
| 394 | 397 | // The skills, summed by name across the tree. | |
| 395 | 398 | const skillUses = new Map<string, number>(); | |
| ⋯ | |||
| 479 | 482 | · {session.state === "ended" ? "ended" : "last heard from"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} /> | |
| 480 | 483 | {session.endReason && ` (${endReasonText(session.endReason)})`} | |
| 481 | 484 | </span> | |
| 485 | + | {cost && ( | |
| 486 | + | <Hint label="Estimated from the tokens its transcript recorded, at Anthropic's list price. Its subagents count on their own sessions."> | |
| 487 | + | <span className="tabular-nums">· {cost}</span> | |
| 488 | + | </Hint> | |
| 489 | + | )} | |
| 482 | 490 | {session.branch && ( | |
| 483 | 491 | <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg"> | |
| 484 | 492 | · <GitBranch size={12} /> | |
| 33 | 33 | pub mod live; | |
| 34 | 34 | pub mod integrations; | |
| 35 | 35 | pub mod members; | |
| 36 | + | pub mod model_prices; | |
| 36 | 37 | mod ids; | |
| 37 | 38 | mod names; | |
| 38 | 39 | mod outcome; |
| 1 | + | //! What a model's tokens cost at list price, for estimating what a | |
| 2 | + | //! harness session cost from the token usage it reports. | |
| 3 | + | //! | |
| 4 | + | //! The prices are Anthropic's first-party list prices in US dollars per | |
| 5 | + | //! million tokens, from the pricing page of Anthropic's API documentation, | |
| 6 | + | //! checked on 2026-10-06. Writing to the prompt cache costs 1.25 times the input price | |
| 7 | + | //! for the 5-minute lifetime and twice the input price for the 1-hour one, | |
| 8 | + | //! on every model. Any other model, another provider's included, is priced | |
| 9 | + | //! as unknown: its cost is not estimated. | |
| 10 | + | //! | |
| 11 | + | //! A session reports its usage as a list of [`ModelUsage`], one per model | |
| 12 | + | //! (and tier) it used, cumulative for the session. [`parse_usage`] reads | |
| 13 | + | //! and checks one as given; [`estimate`] prices it. | |
| 14 | + | ||
| 15 | + | use serde::{Deserialize, Serialize}; | |
| 16 | + | use serde_json::Value; | |
| 17 | + | ||
| 18 | + | /// A model's list prices, in US dollars per million tokens. | |
| 19 | + | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 20 | + | pub struct ModelPrice { | |
| 21 | + | pub input: f64, | |
| 22 | + | pub output: f64, | |
| 23 | + | /// Reading from the prompt cache. | |
| 24 | + | pub cache_read: f64, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl ModelPrice { | |
| 28 | + | /// Writing to the prompt cache for 5 minutes: 1.25 times input. | |
| 29 | + | pub fn cache_write_5m(&self) -> f64 { | |
| 30 | + | self.input * 1.25 | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Writing to the prompt cache for 1 hour: twice input. | |
| 34 | + | pub fn cache_write_1h(&self) -> f64 { | |
| 35 | + | self.input * 2.0 | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const fn price(input: f64, output: f64, cache_read: f64) -> ModelPrice { | |
| 40 | + | ModelPrice { input, output, cache_read } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// The tier of claude-haiku-5-5 for a request whose prompt (input, cache | |
| 44 | + | /// read and cache write tokens) is over [`LONG_PROMPT_TOKENS`]. | |
| 45 | + | pub const LONG_TIER: &str = "long"; | |
| 46 | + | ||
| 47 | + | /// The prompt size above which claude-haiku-5-5 is priced as [`LONG_TIER`]. | |
| 48 | + | pub const LONG_PROMPT_TOKENS: u64 = 100_000; | |
| 49 | + | ||
| 50 | + | /// Every model priced, by its id: from Anthropic's pricing documentation, | |
| 51 | + | /// checked on 2026-10-06. claude-haiku-5-5 has a second row for its | |
| 52 | + | /// [`LONG_TIER`]. | |
| 53 | + | pub const MODEL_PRICES: &[(&str, Option<&str>, ModelPrice)] = &[ | |
| 54 | + | ("claude-fable-5-1", None, price(10.0, 50.0, 0.25)), | |
| 55 | + | ("claude-mythos-5-1", None, price(10.0, 50.0, 0.25)), | |
| 56 | + | ("claude-fable-5", None, price(10.0, 50.0, 1.00)), | |
| 57 | + | ("claude-mythos-5", None, price(10.0, 50.0, 1.00)), | |
| 58 | + | ("claude-opus-5-5", None, price(4.0, 20.0, 0.20)), | |
| 59 | + | ("claude-opus-5", None, price(5.0, 25.0, 0.50)), | |
| 60 | + | ("claude-opus-4-8", None, price(5.0, 25.0, 0.50)), | |
| 61 | + | ("claude-opus-4-7", None, price(5.0, 25.0, 0.50)), | |
| 62 | + | ("claude-opus-4-6", None, price(5.0, 25.0, 0.50)), | |
| 63 | + | ("claude-sonnet-5-5", None, price(2.0, 10.0, 0.20)), | |
| 64 | + | ("claude-sonnet-5", None, price(2.0, 10.0, 0.20)), | |
| 65 | + | ("claude-sonnet-4-6", None, price(3.0, 15.0, 0.30)), | |
| 66 | + | ("claude-haiku-5-5", None, price(0.10, 0.50, 0.01)), | |
| 67 | + | ("claude-haiku-5-5", Some(LONG_TIER), price(0.50, 2.50, 0.05)), | |
| 68 | + | ("claude-haiku-4-5", None, price(1.0, 5.0, 0.10)), | |
| 69 | + | ]; | |
| 70 | + | ||
| 71 | + | /// A model id as the price table names it: lowercase, without a provider | |
| 72 | + | /// prefix (`anthropic.`, `us.anthropic.`), a context suffix (`[1m]`), a | |
| 73 | + | /// version suffix (`-v1:0`) or a date (`-20251001`). | |
| 74 | + | pub fn model_key(model: &str) -> String { | |
| 75 | + | let mut key = model.trim().to_ascii_lowercase(); | |
| 76 | + | if let Some(at) = key.find("anthropic.") { | |
| 77 | + | key = key[at + "anthropic.".len()..].to_owned(); | |
| 78 | + | } | |
| 79 | + | if let Some(at) = key.find('[') { | |
| 80 | + | key.truncate(at); | |
| 81 | + | } | |
| 82 | + | if let Some(at) = key.find(':') { | |
| 83 | + | key.truncate(at); | |
| 84 | + | } | |
| 85 | + | loop { | |
| 86 | + | let Some((head, tail)) = key.rsplit_once('-') else { break }; | |
| 87 | + | let date = tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit()); | |
| 88 | + | let version = tail.len() >= 2 && tail.starts_with('v') && tail[1..].bytes().all(|b| b.is_ascii_digit()); | |
| 89 | + | if !(date || version) { | |
| 90 | + | break; | |
| 91 | + | } | |
| 92 | + | key = head.to_owned(); | |
| 93 | + | } | |
| 94 | + | key | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// A model's list prices for `tier`, or none for a model not in the table. | |
| 98 | + | /// A tier the model has no row for is priced as its base row. | |
| 99 | + | pub fn model_price(model: &str, tier: Option<&str>) -> Option<ModelPrice> { | |
| 100 | + | let key = model_key(model); | |
| 101 | + | let rows = || MODEL_PRICES.iter().filter(|(id, _, _)| *id == key); | |
| 102 | + | rows() | |
| 103 | + | .find(|(_, row_tier, _)| tier.is_some() && *row_tier == tier) | |
| 104 | + | .or_else(|| rows().find(|(_, row_tier, _)| row_tier.is_none())) | |
| 105 | + | .map(|(_, _, price)| *price) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// What one model (and tier) of a session used, in tokens, as its harness | |
| 109 | + | /// reported it. | |
| 110 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 111 | + | pub struct ModelUsage { | |
| 112 | + | pub model: String, | |
| 113 | + | /// `long` for a claude-haiku-5-5 prompt over 100,000 tokens; none otherwise. | |
| 114 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 115 | + | pub tier: Option<String>, | |
| 116 | + | #[serde(default)] | |
| 117 | + | pub input_tokens: u64, | |
| 118 | + | #[serde(default)] | |
| 119 | + | pub output_tokens: u64, | |
| 120 | + | #[serde(default)] | |
| 121 | + | pub cache_write_5m_tokens: u64, | |
| 122 | + | #[serde(default)] | |
| 123 | + | pub cache_write_1h_tokens: u64, | |
| 124 | + | #[serde(default)] | |
| 125 | + | pub cache_read_tokens: u64, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | impl ModelUsage { | |
| 129 | + | /// What it cost at list price in US dollars, or none for a model not | |
| 130 | + | /// in the table. | |
| 131 | + | pub fn cost_usd(&self) -> Option<f64> { | |
| 132 | + | let price = model_price(&self.model, self.tier.as_deref())?; | |
| 133 | + | let tokens = |count: u64, per_million: f64| count as f64 * per_million; | |
| 134 | + | Some( | |
| 135 | + | (tokens(self.input_tokens, price.input) | |
| 136 | + | + tokens(self.output_tokens, price.output) | |
| 137 | + | + tokens(self.cache_write_5m_tokens, price.cache_write_5m()) | |
| 138 | + | + tokens(self.cache_write_1h_tokens, price.cache_write_1h()) | |
| 139 | + | + tokens(self.cache_read_tokens, price.cache_read)) | |
| 140 | + | / 1_000_000.0, | |
| 141 | + | ) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | fn tokens(&self) -> u64 { | |
| 145 | + | self.input_tokens | |
| 146 | + | .saturating_add(self.output_tokens) | |
| 147 | + | .saturating_add(self.cache_write_5m_tokens) | |
| 148 | + | .saturating_add(self.cache_write_1h_tokens) | |
| 149 | + | .saturating_add(self.cache_read_tokens) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// A session's usage priced: the sum over the models priced (none when no | |
| 154 | + | /// model is), and whether some of it was for models priced as unknown. | |
| 155 | + | #[derive(Clone, Copy, Debug, Default, PartialEq)] | |
| 156 | + | pub struct CostEstimate { | |
| 157 | + | pub cost_usd: Option<f64>, | |
| 158 | + | pub partial: bool, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Prices a session's usage. A model with no tokens counts for nothing. | |
| 162 | + | pub fn estimate(usage: &[ModelUsage]) -> CostEstimate { | |
| 163 | + | let mut out = CostEstimate::default(); | |
| 164 | + | for one in usage.iter().filter(|one| one.tokens() > 0) { | |
| 165 | + | match one.cost_usd() { | |
| 166 | + | Some(cost) => out.cost_usd = Some(out.cost_usd.unwrap_or(0.0) + cost), | |
| 167 | + | None => out.partial = true, | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | out | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// The most models one session's usage names. | |
| 174 | + | pub const MAX_USAGE_MODELS: usize = 50; | |
| 175 | + | /// The longest model id and tier kept. | |
| 176 | + | pub const MAX_USAGE_MODEL_CHARS: usize = 100; | |
| 177 | + | pub const MAX_USAGE_TIER_CHARS: usize = 20; | |
| 178 | + | /// The most tokens one count may be: far beyond any session. | |
| 179 | + | pub const MAX_USAGE_TOKENS: u64 = 1_000_000_000_000; | |
| 180 | + | ||
| 181 | + | const COUNTS: [&str; 5] = ["input_tokens", "output_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "cache_read_tokens"]; | |
| 182 | + | ||
| 183 | + | /// A session's usage as given (a list of `{ model, tier?, input_tokens, | |
| 184 | + | /// output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, | |
| 185 | + | /// cache_read_tokens }`), checked: at most [`MAX_USAGE_MODELS`] models, | |
| 186 | + | /// each named, every count a whole number from 0 to [`MAX_USAGE_TOKENS`]. | |
| 187 | + | /// The same model and tier given twice are added together. | |
| 188 | + | pub fn parse_usage(value: &Value) -> Result<Vec<ModelUsage>, String> { | |
| 189 | + | let Value::Array(rows) = value else { | |
| 190 | + | return Err("usage is a list of { model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }.".to_owned()); | |
| 191 | + | }; | |
| 192 | + | if rows.len() > MAX_USAGE_MODELS { | |
| 193 | + | return Err(format!("usage names at most {MAX_USAGE_MODELS} models.")); | |
| 194 | + | } | |
| 195 | + | let mut out: Vec<ModelUsage> = Vec::new(); | |
| 196 | + | for row in rows { | |
| 197 | + | let Value::Object(fields) = row else { | |
| 198 | + | return Err("Each usage is an object with model and its token counts.".to_owned()); | |
| 199 | + | }; | |
| 200 | + | let model = fields.get("model").and_then(Value::as_str).map(str::trim).unwrap_or_default(); | |
| 201 | + | if model.is_empty() || model.chars().count() > MAX_USAGE_MODEL_CHARS { | |
| 202 | + | return Err(format!("Each usage names its model, in up to {MAX_USAGE_MODEL_CHARS} characters.")); | |
| 203 | + | } | |
| 204 | + | let tier = match fields.get("tier") { | |
| 205 | + | None | Some(Value::Null) => None, | |
| 206 | + | Some(Value::String(tier)) if tier.trim().is_empty() => None, | |
| 207 | + | Some(Value::String(tier)) if tier.trim().chars().count() <= MAX_USAGE_TIER_CHARS => Some(tier.trim().to_owned()), | |
| 208 | + | Some(_) => return Err(format!("A usage's tier is text, up to {MAX_USAGE_TIER_CHARS} characters.")), | |
| 209 | + | }; | |
| 210 | + | let mut counts = [0u64; 5]; | |
| 211 | + | for (count, name) in counts.iter_mut().zip(COUNTS) { | |
| 212 | + | *count = match fields.get(name) { | |
| 213 | + | None | Some(Value::Null) => 0, | |
| 214 | + | Some(value) => match value.as_u64() { | |
| 215 | + | Some(n) if n <= MAX_USAGE_TOKENS => n, | |
| 216 | + | _ => return Err(format!("{name} is a whole number of tokens from 0 to {MAX_USAGE_TOKENS}, not {value}.")), | |
| 217 | + | }, | |
| 218 | + | }; | |
| 219 | + | } | |
| 220 | + | let [input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens] = counts; | |
| 221 | + | let one = ModelUsage { model: model.to_owned(), tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }; | |
| 222 | + | match out.iter_mut().find(|known| known.model == one.model && known.tier == one.tier) { | |
| 223 | + | Some(known) => { | |
| 224 | + | known.input_tokens = known.input_tokens.saturating_add(one.input_tokens); | |
| 225 | + | known.output_tokens = known.output_tokens.saturating_add(one.output_tokens); | |
| 226 | + | known.cache_write_5m_tokens = known.cache_write_5m_tokens.saturating_add(one.cache_write_5m_tokens); | |
| 227 | + | known.cache_write_1h_tokens = known.cache_write_1h_tokens.saturating_add(one.cache_write_1h_tokens); | |
| 228 | + | known.cache_read_tokens = known.cache_read_tokens.saturating_add(one.cache_read_tokens); | |
| 229 | + | } | |
| 230 | + | None => out.push(one), | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | Ok(out) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[cfg(test)] | |
| 237 | + | mod tests { | |
| 238 | + | use super::*; | |
| 239 | + | use serde_json::json; | |
| 240 | + | ||
| 241 | + | fn close(a: f64, b: f64) -> bool { | |
| 242 | + | (a - b).abs() < 1e-9 | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn the_table_pins_list_prices() { | |
| 247 | + | assert_eq!(model_price("claude-opus-5-5", None), Some(price(4.0, 20.0, 0.20))); | |
| 248 | + | assert_eq!(model_price("claude-fable-5-1", None), Some(price(10.0, 50.0, 0.25))); | |
| 249 | + | assert_eq!(model_price("claude-fable-5", None), Some(price(10.0, 50.0, 1.00))); | |
| 250 | + | assert_eq!(model_price("claude-sonnet-4-6", None), Some(price(3.0, 15.0, 0.30))); | |
| 251 | + | assert_eq!(model_price("claude-haiku-5-5", None), Some(price(0.10, 0.50, 0.01))); | |
| 252 | + | assert_eq!(model_price("claude-haiku-5-5", Some("long")), Some(price(0.50, 2.50, 0.05))); | |
| 253 | + | assert_eq!(model_price("claude-haiku-4-5", None), Some(price(1.0, 5.0, 0.10))); | |
| 254 | + | let opus = model_price("claude-opus-4-7", None).unwrap(); | |
| 255 | + | assert!(close(opus.cache_write_5m(), 6.25) && close(opus.cache_write_1h(), 10.0)); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn a_model_is_found_without_its_date_context_or_provider() { | |
| 260 | + | assert_eq!(model_key("claude-haiku-4-5-20251001"), "claude-haiku-4-5"); | |
| 261 | + | assert_eq!(model_key("claude-opus-5-5[1m]"), "claude-opus-5-5"); | |
| 262 | + | assert_eq!(model_key("us.anthropic.claude-sonnet-4-6-20260101-v1:0"), "claude-sonnet-4-6"); | |
| 263 | + | assert_eq!(model_key("anthropic.claude-opus-4-8"), "claude-opus-4-8"); | |
| 264 | + | assert!(model_price("Claude-Opus-5-5[1M]", None).is_some()); | |
| 265 | + | // A tier a model has no row for is its base price. | |
| 266 | + | assert_eq!(model_price("claude-opus-5-5", Some("long")), model_price("claude-opus-5-5", None)); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[test] | |
| 270 | + | fn other_models_are_priced_as_unknown() { | |
| 271 | + | for model in ["gpt-5.1-codex", "claude-opus-3", "o3", "<synthetic>", "claude-opus-5-5x"] { | |
| 272 | + | assert_eq!(model_price(model, None), None, "{model}"); | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn a_sessions_cost_is_the_sum_of_the_models_priced() { | |
| 278 | + | let usage = vec![ | |
| 279 | + | ModelUsage { model: "claude-opus-5-5".into(), input_tokens: 1_000_000, output_tokens: 100_000, cache_write_5m_tokens: 200_000, cache_write_1h_tokens: 100_000, cache_read_tokens: 10_000_000, ..Default::default() }, | |
| 280 | + | ModelUsage { model: "claude-haiku-5-5".into(), tier: Some("long".into()), input_tokens: 2_000_000, ..Default::default() }, | |
| 281 | + | ]; | |
| 282 | + | // Opus 5.5: 4 + 2 + 1 (5m: 0.2M × 5) + 0.8 (1h: 0.1M × 8) + 2 = 9.8; Haiku 5.5 long: 2M × 0.5 = 1. | |
| 283 | + | let priced = estimate(&usage); | |
| 284 | + | assert!(close(priced.cost_usd.unwrap(), 10.8), "{priced:?}"); | |
| 285 | + | assert!(!priced.partial); | |
| 286 | + | let mixed = [usage.clone(), vec![ModelUsage { model: "gpt-5".into(), input_tokens: 5, ..Default::default() }]].concat(); | |
| 287 | + | let priced = estimate(&mixed); | |
| 288 | + | assert!(close(priced.cost_usd.unwrap(), 10.8) && priced.partial); | |
| 289 | + | let unknown = estimate(&[ModelUsage { model: "gpt-5".into(), output_tokens: 1, ..Default::default() }]); | |
| 290 | + | assert_eq!(unknown, CostEstimate { cost_usd: None, partial: true }); | |
| 291 | + | assert_eq!(estimate(&[ModelUsage { model: "gpt-5".into(), ..Default::default() }]), CostEstimate::default()); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn usage_is_checked_as_given() { | |
| 296 | + | let usage = parse_usage(&json!([ | |
| 297 | + | { "model": "claude-opus-5-5", "input_tokens": 3, "output_tokens": 4 }, | |
| 298 | + | { "model": "claude-opus-5-5", "input_tokens": 1, "tier": null }, | |
| 299 | + | { "model": "claude-haiku-5-5", "tier": "long", "cache_read_tokens": 9 }, | |
| 300 | + | ])) | |
| 301 | + | .unwrap(); | |
| 302 | + | assert_eq!(usage.len(), 2); | |
| 303 | + | assert_eq!(usage[0].input_tokens, 4); | |
| 304 | + | assert_eq!(usage[1].tier.as_deref(), Some("long")); | |
| 305 | + | assert!(parse_usage(&json!({})).is_err()); | |
| 306 | + | assert!(parse_usage(&json!([{ "input_tokens": 1 }])).is_err()); | |
| 307 | + | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": -1 }])).is_err()); | |
| 308 | + | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": 1.5 }])).is_err()); | |
| 309 | + | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": "7" }])).is_err()); | |
| 310 | + | assert!(parse_usage(&json!([{ "model": "m", "output_tokens": MAX_USAGE_TOKENS + 1 }])).is_err()); | |
| 311 | + | assert!(parse_usage(&json!([{ "model": "x".repeat(101) }])).is_err()); | |
| 312 | + | let many: Vec<Value> = (0..51).map(|i| json!({ "model": format!("m{i}") })).collect(); | |
| 313 | + | assert!(parse_usage(&Value::Array(many)).is_err()); | |
| 314 | + | assert_eq!(parse_usage(&json!([])).unwrap(), vec![]); | |
| 315 | + | } | |
| 316 | + | } |
| 214 | 214 | /// person). | |
| 215 | 215 | #[serde(default)] | |
| 216 | 216 | pub title_settled_at: Option<String>, | |
| 217 | + | /// What it cost, estimated at Anthropic's list price from the token | |
| 218 | + | /// usage its harness reported ([`crate::model_prices`]), in US dollars; | |
| 219 | + | /// none until it reports usage for a model with a known price. | |
| 220 | + | #[serde(default)] | |
| 221 | + | pub cost_usd: Option<f64>, | |
| 222 | + | /// Whether some of its usage was for models priced as unknown, which | |
| 223 | + | /// [`Session::cost_usd`] leaves out. | |
| 224 | + | #[serde(default)] | |
| 225 | + | pub cost_partial: bool, | |
| 217 | 226 | } | |
| 218 | 227 | ||
| 219 | 228 | /// Where a session's title came from. g1t settles a title from the | |
| ⋯ | |||
| 808 | 817 | pub model: Option<String>, | |
| 809 | 818 | #[serde(default)] | |
| 810 | 819 | pub result: Option<String>, | |
| 820 | + | /// The session's token usage so far, per model, as its harness counts | |
| 821 | + | /// it: cumulative, so it replaces what was reported before. | |
| 822 | + | #[serde(default)] | |
| 823 | + | pub usage: Option<Vec<crate::model_prices::ModelUsage>>, | |
| 811 | 824 | } | |
| 812 | 825 | ||
| 813 | 826 | /// `end_session`. Returns `Outcome<Session>`; ending one already ended | |
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t" | |
| 3 | − | version = "0.4.0" | |
| 3 | + | version = "0.4.1" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | 6 | repository.workspace = true |
| 1011 | 1011 | Starting again with the same --harness-session-id returns the same session.")] | |
| 1012 | 1012 | Start(SessionStartArgs), | |
| 1013 | 1013 | /// Say the current session is still working; renews its claims. | |
| 1014 | − | Heartbeat, | |
| 1014 | + | #[command(after_help = "--usage-json reports the session's token usage so far, which g1t prices at Anthropic's list price: | |
| 1015 | + | a JSON list with one entry per model, each {model, tier?, input_tokens, output_tokens, | |
| 1016 | + | cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens}. It is cumulative, so it replaces | |
| 1017 | + | what the session reported before. - reads it from stdin.")] | |
| 1018 | + | Heartbeat(SessionHeartbeatArgs), | |
| 1015 | 1019 | /// Record a note, prompt or message in the current session. | |
| 1016 | 1020 | Log(SessionLogArgs), | |
| 1017 | 1021 | /// Attach the current session to a work item, a pull request or a branch, or give it a title. | |
| ⋯ | |||
| 1152 | 1156 | } | |
| 1153 | 1157 | ||
| 1154 | 1158 | #[derive(Debug, Args)] | |
| 1159 | + | pub struct SessionHeartbeatArgs { | |
| 1160 | + | /// The session's token usage so far, per model, as JSON; - reads it from stdin. | |
| 1161 | + | #[arg(long, value_name = "JSON")] | |
| 1162 | + | pub usage_json: Option<String>, | |
| 1163 | + | } | |
| 1164 | + | ||
| 1165 | + | #[derive(Debug, Args)] | |
| 1155 | 1166 | pub struct SessionEndArgs { | |
| 1156 | 1167 | /// Why it ended: done, abandoned, handed_off... | |
| 1157 | 1168 | #[arg(short, long)] | |
| 10 | 10 | use std::path::PathBuf; | |
| 11 | 11 | ||
| 12 | 12 | use crate::cli::{ | |
| 13 | − | SessionAcknowledgeArgs, SessionAttachArgs, SessionCommand, SessionEndArgs, SessionListArgs, SessionLogArgs, | |
| 13 | + | SessionAcknowledgeArgs, SessionAttachArgs, SessionCommand, SessionEndArgs, SessionHeartbeatArgs, SessionListArgs, SessionLogArgs, | |
| 14 | 14 | SessionStartArgs, SessionState, SessionTouchArgs, SessionViewArgs, | |
| 15 | 15 | }; | |
| 16 | 16 | use crate::client::Client; | |
| ⋯ | |||
| 22 | 22 | pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> { | |
| 23 | 23 | match command { | |
| 24 | 24 | SessionCommand::Start(args) => start(ctx, args), | |
| 25 | − | SessionCommand::Heartbeat => heartbeat(ctx), | |
| 25 | + | SessionCommand::Heartbeat(args) => heartbeat(ctx, args), | |
| 26 | 26 | SessionCommand::Log(args) => log(ctx, args), | |
| 27 | 27 | SessionCommand::Attach(args) => attach(ctx, args), | |
| 28 | 28 | SessionCommand::End(args) => end(ctx, args), | |
| ⋯ | |||
| 181 | 181 | .send(); | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | − | fn heartbeat(ctx: &Ctx) -> Result<()> { | |
| 184 | + | /// Usage as `--usage-json` gives it: a JSON list of objects, each naming | |
| 185 | + | /// its model. The server checks the counts. | |
| 186 | + | fn usage_of(text: &str) -> Result<Value> { | |
| 187 | + | let value: Value = serde_json::from_str(text.trim()) | |
| 188 | + | .map_err(|error| Error::usage(format!("--usage-json is not JSON: {error}.")))?; | |
| 189 | + | let named = |row: &Value| row["model"].as_str().is_some_and(|model| !model.trim().is_empty()); | |
| 190 | + | match &value { | |
| 191 | + | Value::Array(rows) if rows.iter().all(named) => Ok(value), | |
| 192 | + | _ => Err(Error::usage( | |
| 193 | + | "--usage-json is a list of {model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens}.", | |
| 194 | + | )), | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | fn heartbeat(ctx: &Ctx, args: SessionHeartbeatArgs) -> Result<()> { | |
| 185 | 199 | let id = required()?; | |
| 200 | + | let usage = match args.usage_json.as_deref() { | |
| 201 | + | Some("-") => { | |
| 202 | + | let mut text = String::new(); | |
| 203 | + | std::io::stdin().read_to_string(&mut text)?; | |
| 204 | + | Some(usage_of(&text)?) | |
| 205 | + | } | |
| 206 | + | Some(text) => Some(usage_of(text)?), | |
| 207 | + | None => None, | |
| 208 | + | }; | |
| 186 | 209 | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 187 | − | let found = client.op("update_session").repo(&repo).arg("id", &id).send()?; | |
| 210 | + | let mut request = client.op("update_session").repo(&repo).arg("id", &id); | |
| 211 | + | if let Some(usage) = usage { | |
| 212 | + | request = request.field("usage", usage); | |
| 213 | + | } | |
| 214 | + | let found = request.send()?; | |
| 188 | 215 | say_changes(&client, &repo, &id); | |
| 189 | 216 | if ctx.out.structured() { | |
| 190 | 217 | return ctx.out.emit(&session_of(found)); | |
| ⋯ | |||
| 621 | 648 | use super::*; | |
| 622 | 649 | ||
| 623 | 650 | #[test] | |
| 651 | + | fn usage_is_a_list_of_models_and_their_counts() { | |
| 652 | + | let usage = usage_of(r#" [{"model":"claude-opus-5-5","input_tokens":3}] "#).unwrap(); | |
| 653 | + | assert_eq!(usage[0]["input_tokens"], 3); | |
| 654 | + | assert!(usage_of("[]").is_ok()); | |
| 655 | + | assert!(usage_of("{}").is_err()); | |
| 656 | + | assert!(usage_of(r#"[{"input_tokens":3}]"#).is_err()); | |
| 657 | + | assert!(usage_of("not json").is_err()); | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | #[test] | |
| 624 | 661 | fn the_environment_overrides_the_kept_session() { | |
| 625 | 662 | let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id())); | |
| 626 | 663 | std::fs::create_dir_all(&dir).unwrap(); | |
| 6138 | 6138 | Field { name: "result", kind: "string", required: false, values: &[] }, | |
| 6139 | 6139 | Field { name: "task", kind: "string", required: false, values: &[] }, | |
| 6140 | 6140 | Field { name: "title", kind: "string", required: false, values: &[] }, | |
| 6141 | + | Field { name: "usage", kind: "array", required: false, values: &[] }, | |
| 6141 | 6142 | ], | |
| 6142 | 6143 | }, | |
| 6143 | 6144 | Operation { |
| 1449 | 1449 | titleSource?: TitleSource | null; | |
| 1450 | 1450 | /** When its title was last settled. */ | |
| 1451 | 1451 | titleSettledAt?: string | null; | |
| 1452 | + | /** | |
| 1453 | + | * What it cost, estimated at Anthropic's list price from the token usage | |
| 1454 | + | * its harness reported, in US dollars; none until it reports usage for a | |
| 1455 | + | * model with a known price. Only the workspace's members see it. | |
| 1456 | + | */ | |
| 1457 | + | costUsd?: number | null; | |
| 1458 | + | /** Some of its usage was for models priced as unknown, which `costUsd` leaves out. */ | |
| 1459 | + | costPartial?: boolean; | |
| 1452 | 1460 | }; | |
| 1453 | 1461 | ||
| 1454 | 1462 | /** |
| 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 | + | }); | |
| 1289 | 1289 | prompt: None, | |
| 1290 | 1290 | model: None, | |
| 1291 | 1291 | result: None, | |
| 1292 | + | usage: None, | |
| 1292 | 1293 | } | |
| 1293 | 1294 | } | |
| 1294 | 1295 |
| 1 | + | -- What a harness session cost: its token usage as its harness last | |
| 2 | + | -- reported it (JSON, cumulative, one entry per model: coordination/sessions.rs), | |
| 3 | + | -- and the cost estimated from it at Anthropic's list price in US dollars | |
| 4 | + | -- (g1t_contracts::model_prices). cost_usd is NULL until the session reports | |
| 5 | + | -- usage for a model with a known price. | |
| 6 | + | ALTER TABLE sessions ADD COLUMN usage TEXT; | |
| 7 | + | ALTER TABLE sessions ADD COLUMN cost_usd REAL; |
| 213 | 213 | prompt: None, | |
| 214 | 214 | model: None, | |
| 215 | 215 | result: None, | |
| 216 | + | usage: None, | |
| 216 | 217 | }) | |
| 217 | 218 | .await?; | |
| 218 | 219 | if let Outcome::Fail(failure) = attached { |
| 52 | 52 | " AS item, | |
| 53 | 53 | pull, branch, actor_id, actor_name, harness, harness_session_id, | |
| 54 | 54 | title, state, head_commit, started_at, last_seen_at, ended_at, end_reason, | |
| 55 | − | parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at" | |
| 55 | + | parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at, | |
| 56 | + | usage, cost_usd" | |
| 56 | 57 | ); | |
| 57 | 58 | ||
| 58 | 59 | /// A claim's columns, with its work item's workspace and number. | |
| ⋯ | |||
| 105 | 106 | title_source: Option<String>, | |
| 106 | 107 | #[serde(default)] | |
| 107 | 108 | title_settled_at: Option<String>, | |
| 109 | + | /// Its token usage as last reported (JSON). | |
| 110 | + | #[serde(default)] | |
| 111 | + | usage: Option<String>, | |
| 112 | + | #[serde(default)] | |
| 113 | + | cost_usd: Option<f64>, | |
| 108 | 114 | } | |
| 109 | 115 | ||
| 116 | + | /// Whether some of a session's usage, as kept, was for models priced as unknown. | |
| 117 | + | fn cost_partial(usage: Option<&str>) -> bool { | |
| 118 | + | usage | |
| 119 | + | .and_then(|usage| serde_json::from_str::<Vec<g1t_contracts::model_prices::ModelUsage>>(usage).ok()) | |
| 120 | + | .is_some_and(|usage| g1t_contracts::model_prices::estimate(&usage).partial) | |
| 121 | + | } | |
| 122 | + | ||
| 110 | 123 | impl SessionRow { | |
| 111 | 124 | /// The session as it reads at `now`. | |
| 112 | 125 | pub(crate) fn at(self, now: u64) -> Session { | |
| ⋯ | |||
| 137 | 150 | first_prompt: None, | |
| 138 | 151 | title_source: self.title_source.as_deref().and_then(TitleSource::parse), | |
| 139 | 152 | title_settled_at: self.title_settled_at, | |
| 153 | + | cost_partial: cost_partial(self.usage.as_deref()), | |
| 154 | + | cost_usd: self.cost_usd, | |
| 140 | 155 | } | |
| 141 | 156 | } | |
| 142 | 157 | } | |
| 176 | 176 | ) | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | /// Session `?3`'s token usage (`?1`, JSON as reported) and the cost | |
| 180 | + | /// estimated from it (`?2`): cumulative, so each report replaces the last. | |
| 181 | + | pub(crate) const SET_USAGE: &str = "UPDATE sessions SET usage = ?1, cost_usd = ?2 WHERE id = ?3"; | |
| 182 | + | ||
| 179 | 183 | /// A subagent's heartbeat keeps its parent `?2` seen at `?1`. | |
| 180 | 184 | pub(crate) const SEE_PARENT: &str = "UPDATE sessions SET last_seen_at = ?1 WHERE id = ?2 AND state = 'active' AND last_seen_at < ?1"; | |
| 181 | 185 | ||
| ⋯ | |||
| 339 | 343 | model: fields.model.clone(), | |
| 340 | 344 | title_source: fields.title.as_ref().map(|_| TitleSource::Agent.as_str().to_owned()), | |
| 341 | 345 | title_settled_at: fields.title.as_ref().map(|_| at.clone()), | |
| 346 | + | usage: None, | |
| 347 | + | cost_usd: None, | |
| 342 | 348 | } | |
| 343 | 349 | .at(now); | |
| 344 | 350 | let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor)); | |
| ⋯ | |||
| 570 | 576 | model: tidy(a.model.as_deref(), MAX_MODEL_CHARS), | |
| 571 | 577 | result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS), | |
| 572 | 578 | }; | |
| 579 | + | // Its usage first, so the session returned carries the cost. | |
| 580 | + | if let Some(usage) = &a.usage { | |
| 581 | + | let usage = match g1t_contracts::model_prices::parse_usage(&serde_json::to_value(usage).unwrap_or_default()) { | |
| 582 | + | Ok(usage) => usage, | |
| 583 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 584 | + | }; | |
| 585 | + | let cost = g1t_contracts::model_prices::estimate(&usage).cost_usd; | |
| 586 | + | let json = serde_json::to_string(&usage).unwrap_or_else(|_| "[]".to_owned()); | |
| 587 | + | self.db | |
| 588 | + | .prepare(SET_USAGE) | |
| 589 | + | .bind(&[json.into(), cost.map_or(JsValue::NULL, JsValue::from), row.id.as_str().into()])? | |
| 590 | + | .run() | |
| 591 | + | .await?; | |
| 592 | + | } | |
| 573 | 593 | self.resume(&repo, row, &a.actor, fields).await | |
| 574 | 594 | } | |
| 575 | 595 | ||
Loading the diff…
Loading the diff…
Comments
Sign in to comment.