From b972f6286bedbeb27ec6655245c237e375b07ec8 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:09:39 -0300
Subject: [PATCH 01/42] docs(usage): Add orchestrator usage spec, design and
tasks
---
.specs/features/orchestrator-usage/design.md | 310 +++++++++
.specs/features/orchestrator-usage/spec.md | 331 ++++++++++
.specs/features/orchestrator-usage/tasks.md | 624 +++++++++++++++++++
.specs/features/run-usage-statusline/spec.md | 2 +
4 files changed, 1267 insertions(+)
create mode 100644 .specs/features/orchestrator-usage/design.md
create mode 100644 .specs/features/orchestrator-usage/spec.md
create mode 100644 .specs/features/orchestrator-usage/tasks.md
diff --git a/.specs/features/orchestrator-usage/design.md b/.specs/features/orchestrator-usage/design.md
new file mode 100644
index 0000000..05bff55
--- /dev/null
+++ b/.specs/features/orchestrator-usage/design.md
@@ -0,0 +1,310 @@
+# Orchestrator Usage Design
+
+**Spec**: `.specs/features/orchestrator-usage/spec.md`
+**Status**: Approved (approach A, 2026-09-22)
+
+---
+
+## Approaches considered
+
+All three deliver the same spec. They differ only in where the per-source state lives.
+
+| | A. Ledger tables in SQLite (recommended) | B. JSON column per session row | C. Transcript-only reconcile |
+| --- | --- | --- | --- |
+| High-water per source | one row per source in `usage_sources`, global across rows | recomputed by scanning every row's JSON for the key | no live marks, cost-state read at release only |
+| Live orchestrator cost | statusline observation lands in the store | same | none until release, so ORCH-04 and RUN-06 fail |
+| Readers (`queryUsage`, `aggregateRunUsage`) | unchanged: attribution is materialized into `sessions.usage_*` | unchanged | unchanged |
+| Cost of change | 3 small tables and one store module | one column, but a cross-row scan on every observation | smallest, but breaks the spec |
+
+**Choice: A.** B needs a global lookup anyway, so it ends up as A with worse queries. C is only viable if ORCH-04 and RUN-06 leave the spec.
+
+---
+
+## Architecture Overview
+
+Every harness counter becomes an **observation** `(row, sourceKey, fields)`. One store module, `UsageLedger`, applies the high-water rule and rewrites the row's `usage_*` columns as the sum of its attributions. Everything that reads usage today keeps reading those columns.
+
+```mermaid
+graph TD
+ HOOK[SessionStart hook
appends id to sidecar] --> SIDE[(sidecar file)]
+ SIDE -->|poll 1 s| OPEN[codedeck open
link watcher]
+ OPEN -->|session.linkNative| D[Daemon]
+ SL[statusline] -->|codedeck usage run --json --observe id=cost| CLI[usage CLI]
+ CLI -->|usage.get + observe| D
+ OPEN -->|session.release| D
+ D -->|release / startup / new link| REC[Transcript reconciler]
+ REC --> TR[(~/.claude/projects/*/id.jsonl)]
+ WK[worker parsers
usage.updated] --> D
+ D --> LED[UsageLedger]
+ LED --> SRC[(usage_sources)]
+ LED --> ATT[(usage_attributions)]
+ LED -->|materialize| SES[(sessions.usage_*)]
+ SES --> AGG[aggregateRunUsage / queryUsage]
+ ATT --> AGG
+```
+
+Flows:
+
+1. **Link** (ORCH-01..03). The hook keeps writing the sidecar with `grep` + `printf`, but appends one id per line instead of overwriting. `open` polls the sidecar every second and sends `session.linkNative` for each id it has not sent yet. The hook never talks to the daemon, so ORCH-02 holds by construction.
+2. **Live cost** (ORCH-04, RUN-06). The statusline adds `--observe =` to the `codedeck usage` call it already makes. The daemon links the id (idempotent), applies the observation, then returns the aggregate in the same round trip.
+3. **Reconcile** (ORCH-05..12, ORCH-16). On release of an `open` row, at daemon start for stale rows, and when a second id links, the daemon reads each unreconciled linked transcript and records its `cost-state` (or the token fallback) as an observation.
+4. **Workers** (SRC-01..04). `updateSessionFromEvent` derives the source key from the row's agent and routes non-incremental `usage.updated` through the ledger. opencode deltas keep the current additive path.
+5. **Read** (RUN-01..09). `aggregateRunUsage` splits rows by `origin`. `queryUsage` gains a `byOrigin` bucket and merges legacy entries (P2).
+
+---
+
+## Code Reuse Analysis
+
+### Existing Components to Leverage
+
+| Component | Location | How to Use |
+| --- | --- | --- |
+| Migration pattern | `src/store/database.ts:44-137` | new tables with `CREATE TABLE IF NOT EXISTS`, new indexes next to `idx_sessions_run_id` |
+| Event dedup guard | `src/daemon/daemon.ts:1225-1236` | ledger writes run inside the existing `BEGIN`/`COMMIT`, only when `inserted !== 0`, so SRC-04 comes for free |
+| Usage handler | `src/daemon/daemon.ts:1316-1350` | the non-incremental branch calls `UsageLedger.observe` instead of overwriting columns |
+| Row usage columns | `src/store/sessions.ts` (`usage_*`) | materialization target, so `queryUsage` and `ps`/`show` need no change to see orchestrator cost |
+| Pricing | `src/core/pricing.ts:137-166` | extend `computeSessionCost` with a `cachedInInput` flag; reuse `resolveModelPrice` for ORCH-09 |
+| Run aggregate | `src/core/run-usage.ts` | split into worker and orchestrator partitions with the same summing loop |
+| Startup recovery | `src/daemon/daemon.ts:169-200` | hook the stale-row reconcile after `recover()` |
+| Release handler | `src/daemon/daemon.ts:508-545` | reconcile after `setStatus`, so ORCH-12 status is set first |
+| Sidecar consumer | `src/open/runtime.ts:243` (`takeSessionId`) | read the last line for the resume hint; all lines are linked |
+| Statusline shim tests | `tests/statusline.test.ts:22-85` | same PATH shim, assert the new argv and RUN-06 arithmetic |
+| Daemon seam | `tests/helpers/daemon-seam.ts`, `tests/usage-daemon.test.ts` | drive `session.adopt`, `session.linkNative`, `usage.get` and `session.release` without spawning harnesses |
+| Spawn env | `src/drivers/session-runtime.ts:141` | add `CODEDECK_RUN_ID` when the row has a `run_id` (RUN-10/11) |
+
+### Integration Points
+
+| System | Integration Method |
+| --- | --- |
+| IPC protocol | new method `session.linkNative`; `usage.get` gains optional `observe`; both added to `RequestMethod` in `src/daemon/protocol.ts` |
+| SQLite | three new tables (four with P2), no change to existing columns |
+| Claude Code transcripts | read-only, streamed line by line from `~/.claude/projects/*/.jsonl` |
+| Statusline | argv grows by `--observe`; the response adds `orchestrator` and `total`, top-level fields keep their meaning for workers |
+
+---
+
+## Components
+
+### UsageLedger
+
+- **Purpose**: apply one observation with the high-water rule and materialize the row's usage.
+- **Location**: `src/store/usage-ledger.ts`
+- **Interfaces**:
+ - `observe(sessionId: string, sourceKey: string, obs: UsageObservation): boolean` returns whether any field moved.
+ - `attributionsFor(sessionIds: string[]): UsageAttribution[]` for `aggregateRunUsage` (RUN-06 per-source figures).
+ - `hasSource(sourceKey: string): boolean` for BF-03.
+- **Behavior**:
+ - For each present field `f`: `delta = obs.f - (mark.f ?? 0)`. If `delta > 0`, add it to the attribution `(sessionId, sourceKey)` and set `mark.f = obs.f`. Absent fields change nothing (ORCH-13/14).
+ - After any move, rewrite `sessions.usage_*` for that row as `SUM` over its attributions. Cost stays `NULL` when no attribution of the row has a cost.
+ - Seed on first use: if a row has non-null `usage_*` and no attribution yet (a row that was live across the upgrade), store those values as source `seed:` before applying the observation, so materialization does not drop them.
+- **Dependencies**: the `DatabaseSync` handle; callers own the transaction.
+- **Reuses**: `SessionStore.update` for the materialized columns.
+
+### Source key resolver
+
+- **Purpose**: map a worker `usage.updated` to its source key (spec "Usage model" table).
+- **Location**: `src/core/usage-source.ts`
+- **Interfaces**:
+ - `workerSourceKey(session: Session, processOrdinal: number): string | undefined`, `undefined` means additive (opencode).
+- **Keys**: `claude:#`, `codex:`, `session:` for Antigravity and OMP, `claude-open:` for orchestrator ids. The prefix keeps a worker id and an orchestrator id from ever sharing a mark.
+- **Process ordinal**: `SELECT COUNT(*) FROM events WHERE session_id = ? AND type = 'session.started' AND sequence <= ?`, evaluated inside the consumer transaction. A replayed `session.started` is deduped before it can count, so the ordinal is stable across daemon restarts.
+- **Reuses**: `session.agent`, `session.nativeSessionId`.
+
+### Native link store
+
+- **Purpose**: keep every native id seen by an `open` row and whether its transcript was reconciled.
+- **Location**: `src/store/native-links.ts`
+- **Interfaces**:
+ - `link(sessionId, nativeId): { created: boolean; previous: string[] }`, `previous` lists other unreconciled ids for ORCH-16.
+ - `unreconciled(sessionId): NativeLink[]`
+ - `markReconciled(sessionId, nativeId, state: ReconcileState)`
+ - `staleOpenRows(isDead: (s: Session) => boolean): Session[]` for ORCH-07.
+
+### Transcript reader
+
+- **Purpose**: turn a Claude transcript into one observation without loading the file.
+- **Location**: `src/core/claude-transcript.ts`
+- **Interfaces**:
+ - `findTranscript(nativeId: string, projectsDir = path.join(os.homedir(), ".claude", "projects")): string | undefined` scans each project directory for `.jsonl`.
+ - `readTranscriptUsage(file: string): Promise` streams with `readline`, skips lines that are not valid JSON, keeps the **last** `cost-state` line, and in the same pass sums `assistant` `message.usage` deduped by `message.id` + `requestId`. It also records the last timestamp and last `cwd` for BF-05/06.
+- **Mapping**:
+ - `cost-state` found: cost = `totalCostUSD`. Tokens are summed over `modelUsage`: input = `inputTokens`, output = `outputTokens`, cached = `cacheReadInputTokens + cacheCreationInputTokens`, the same cached rule as `src/drivers/claude/parser.ts:117`. Model = the `modelUsage` key with the highest `costUSD`.
+ - No `cost-state`: tokens from the deduped assistant sum. Cost = `computeSessionCost` per model when every model has a price (ORCH-09), otherwise no cost field and state `no-price` (ORCH-10).
+ - No file: state `missing` (ORCH-11).
+
+### Reconciler (daemon)
+
+- **Purpose**: run the transcript reader for unreconciled links and feed the ledger.
+- **Location**: private methods on `Daemon` in `src/daemon/daemon.ts`, next to `recover()`.
+- **Triggers**:
+ - `session.release` on an `open` row with agent `claude`: after `setStatus` (ORCH-12), await the reconcile, then reply. Other harnesses under `open` are untouched. A reader error is caught and logged, and the status stays.
+ - Daemon start: after `recover()`, reconcile rows from `staleOpenRows` without blocking startup (ORCH-07).
+ - `session.linkNative` or `observe` returning `previous` ids: reconcile those ids (ORCH-16).
+- **Writes**: `UsageLedger.observe(rowId, "claude-open:", obs)` in one transaction per link, then `markReconciled`.
+
+### Link watcher (open)
+
+- **Purpose**: forward ids the hook wrote to the daemon while the session runs.
+- **Location**: `src/open/link-watcher.ts`, started from the shared launch path in `src/cli/commands/open.ts` (the three spawn sites at 670, 768 and 839 share `runId` and `sessionFile`).
+- **Interfaces**:
+ - `startLinkWatcher({ sessionFile, runId, link, intervalMs = 1000 }): { flush(): Promise; stop(): void }`
+- **Behavior**: every tick, read the sidecar, send `session.linkNative` for lines not yet sent, ignore IPC errors (retried next tick). Each exit path awaits `flush()` before `finishOpenSession` (it deletes the sidecar, `src/open/runtime.ts:251`) and therefore before `session.release`, so release always sees every id. The paths that write the sidecar themselves (`open.ts:649`, `749`, `815`) run before the flush and are covered by it.
+- **Reuses**: `SESSION_ID_PATTERN` from `src/open/runtime.ts`.
+
+### Hook and statusline
+
+- `plugin/hooks/session-id.sh`: `printf '%s\n' "$id" >> "$target"`. Nothing else changes.
+- `src/open/runtime.ts` `takeSessionId`: return the last valid line.
+- `plugin/statusline.sh`: when `payload.session_id` is a UUID and `cost.total_cost_usd` is finite and `>= 0`, append `--observe =`. Compute `run $` as `usage.costUsd + sum(orchestrator.sources where nativeId != session_id) + local` (RUN-06). When `orchestrator` is missing (older daemon), keep today's formula.
+- `src/cli/commands/usage.ts`: parse `--observe`, pass it as `usage.get` `observe`, print the same JSON.
+
+### Run aggregate
+
+- **Location**: `src/core/run-usage.ts`
+- **Interface**: `aggregateRunUsage(runId, sessions, attributions, linkStates): RunUsageSummary`
+- **Behavior**: top-level fields over rows with `origin !== "open"` (RUN-02, RUN-05). `orchestrator` over `open` rows, with `costComplete` false when any row cost is `null` or any link state is `missing` or `no-price` (ORCH-10/11). `orchestrator.sources` lists `{ nativeId, costUsd }` summed over the run's `open` rows. `total.costUsd = costUsd + orchestrator.costUsd` (RUN-04).
+
+### Pricing
+
+- **Location**: `src/core/pricing.ts`
+- **Change**: `computeSessionCost({ model, usage, reportedCost, cachedInInput })`. With `cachedInInput`, return `null` when `cached > input` (PRICE-03), otherwise price `input - cached` at the input rate (PRICE-01). `cachedInInputFor(agent)` returns `true` only for `codex`. Callers in `run-usage.ts` and `sessions.ts:350` pass it from `session.agent`. Existing Codex rows are repriced on read, because their cost is computed at query time.
+
+### Usage query
+
+- **Location**: `src/store/sessions.ts` `queryUsage`
+- **Change**: select `origin`, add `byOrigin` keyed `orchestrator` (`open`) and `worker` (anything else) (RUN-09). `--by origin` is added to the CLI option list. P2: merge rows from `usage_legacy` into totals, `byDay`, `byRepository`, `byModel` and the `orchestrator` bucket, never into `byRun` or `byAgent` beyond `claude`.
+
+### Backfill (P2)
+
+- **Location**: `src/cli/commands/usage-backfill.ts`, registered as `codedeck usage backfill`.
+- **Behavior**: collect ids (BF-01), skip worker native ids (BF-02) and ids with a `claude-open:` mark (BF-03), read the transcript, insert one `usage_legacy` row plus the `claude-open:` mark in the same transaction (BF-04). The mark makes a later resume of that id attribute only the increase, and makes a second backfill a no-op (BF-08).
+- **Why a table, not session rows**: legacy rows in `sessions` would show up in `ps --all`, `show` and `getByRunId`.
+
+---
+
+## Data Models
+
+```typescript
+// usage_sources: one high-water mark per source, global across rows
+interface UsageSource {
+ sourceKey: string // PK, e.g. "claude-open:1db0600a-..."
+ cost: number | null
+ inputTokens: number | null
+ outputTokens: number | null
+ cachedTokens: number | null
+ updatedAt: string
+}
+
+// usage_attributions: what each row earned from each source
+interface UsageAttribution {
+ sessionId: string // PK part, FK sessions(id) ON DELETE CASCADE
+ sourceKey: string // PK part
+ cost: number | null
+ inputTokens: number
+ outputTokens: number
+ cachedTokens: number
+}
+
+type ReconcileState = "cost-state" | "tokens" | "no-price" | "missing"
+
+// session_native_links: orchestrator native ids per open row
+interface NativeLink {
+ sessionId: string // PK part, FK sessions(id) ON DELETE CASCADE
+ nativeId: string // PK part
+ linkedAt: string
+ reconciledAt: string | null
+ state: ReconcileState | null
+}
+
+// usage_legacy (P2): one entry per backfilled native id
+interface UsageLegacy {
+ nativeId: string // PK
+ endedAt: string // last timestamped line, drives byDay
+ cwd: string | null
+ repository: string | null // git root of the last cwd, drives byRepository
+ model: string | null
+ cost: number | null
+ inputTokens: number
+ outputTokens: number
+ cachedTokens: number
+}
+
+interface UsageObservation {
+ cost?: number
+ inputTokens?: number
+ outputTokens?: number
+ cachedTokens?: number
+ model?: string
+}
+
+interface RunUsageSummary {
+ runId: string
+ // workers only, same names as today
+ inputTokens: number
+ outputTokens: number
+ cachedTokens: number
+ costUsd: number
+ sessionCount: number
+ activeSessionCount: number
+ costComplete: boolean
+ sessionsWithoutCost: number
+ orchestrator: {
+ costUsd: number
+ costComplete: boolean
+ inputTokens: number
+ outputTokens: number
+ cachedTokens: number
+ sources: Array<{ nativeId: string; costUsd: number }>
+ }
+ total: { costUsd: number }
+}
+```
+
+**Relationships**: `usage_attributions` and `session_native_links` hang off `sessions`. `usage_sources` is keyed by source only, which is what makes the mark global. `usage_legacy` stands alone and shares the `claude-open:` mark namespace with live rows.
+
+**Invariant (ORCH-15)**: for every source key, `SUM(usage_attributions.cost) = usage_sources.cost`. Every write to both tables happens inside one transaction.
+
+---
+
+## Error Handling Strategy
+
+| Error Scenario | Handling | User Impact |
+| --- | --- | --- |
+| Daemon down when the hook runs | the hook only writes the file | none, the watcher links on the next tick after the daemon returns |
+| Daemon slow or down for the statusline | existing 1 s timeout, validation fails | local line, no `run $` (RUN-07) |
+| `--observe` for a row that does not exist or is not `open` | dropped, aggregate still returned | none |
+| Non-finite or negative cost in `--observe` | CLI does not send `observe` | none |
+| Transcript missing | link state `missing` | `?` on the orchestrator cost |
+| Transcript line is not JSON | skipped | none |
+| Unpriced model in the token fallback | link state `no-price`, no cost field | `?` on the orchestrator cost |
+| Reader throws during release | caught, logged to the daemon log, status already set | release succeeds, link stays unreconciled and is retried at the next daemon start |
+| Out-of-order or replayed observation | below the mark, no change | none |
+
+---
+
+## Risks & Concerns
+
+| Concern | Location (file:line) | Impact | Mitigation |
+| --- | --- | --- | --- |
+| `updateSessionFromEvent` swallows every error | `src/daemon/daemon.ts:1350` (`catch {}`) | a ledger bug would silently stop usage updates | ledger unit tests cover the arithmetic; the daemon test asserts materialized columns after each worker fixture |
+| Rows live across the upgrade have usage but no attribution | `src/store/sessions.ts` `usage_*` | first observation would overwrite them with a smaller sum | `seed:` attribution in `UsageLedger` |
+| Statusline observation on every render writes to SQLite | `plugin/statusline.sh:180` | write amplification | the no-op path only reads the mark; writes happen only when a field moves |
+| Transcript scan cost | `~/.claude/projects` (84 files observed, some above 50 MB) | slow release | `readline` streaming, only unreconciled links, startup reconcile runs off the critical path |
+| Three copies of the `open` spawn path | `src/cli/commands/open.ts:652-860` | watcher started on one path only | start it where `sessionFile` and `runId` are both known; one test per path is not needed if the helper is shared, the task checks the three call sites |
+| Plugin copy under `dist/plugin` | `npm run build:plugin` | hook and statusline edits do not reach the running install | the Execute close step runs `npm run build:plugin` |
+| Two live `open` processes on one native id | spec "Usage model" known limit | smaller increases can be missed | accepted in the spec, never double counts |
+| Claude cache-creation tokens priced at the cached rate | `src/core/pricing.ts:160` | Claude token fallback underprices cache writes | out of scope; only the fallback path uses token pricing for Claude, the `cost-state` path uses Claude's own cost |
+
+---
+
+## Tech Decisions (only non-obvious ones)
+
+| Decision | Choice | Rationale |
+| --- | --- | --- |
+| Where attribution lives for readers | materialized into `sessions.usage_*` | `queryUsage`, `ps`, `show` and the TUI keep working unchanged |
+| How the hook links ids | sidecar append plus a watcher in `open` | keeps the hook at `grep` + `printf` with no node spawn on startup, ORCH-02 holds without a timeout dance |
+| How the statusline reports cost | `--observe` on the existing `codedeck usage` call | one process spawn and one IPC round trip per render, as today |
+| Observations only on `origin = 'open'` rows | enforced in the daemon | RUN-10 puts `CODEDECK_RUN_ID` in worker environments; without the guard a nested Claude worker's hook or statusline could attribute its own native id to the orchestrator row as well as to itself |
+| Claude process ordinal | count of `session.started` events up to the usage event | needs no new column and is replay-safe because of the event dedup |
+| Backfill storage | separate `usage_legacy` table | legacy entries must not appear as sessions in `ps --all` or `show` |
+| Source key prefixes | `claude:`, `codex:`, `session:`, `claude-open:`, `seed:` | a worker and an orchestrator can never share a high-water mark by accident |
diff --git a/.specs/features/orchestrator-usage/spec.md b/.specs/features/orchestrator-usage/spec.md
new file mode 100644
index 0000000..9870a67
--- /dev/null
+++ b/.specs/features/orchestrator-usage/spec.md
@@ -0,0 +1,331 @@
+# Orchestrator usage and usage ledger Specification
+
+## Problem Statement
+
+`codedeck usage` reports only worker sessions. The Claude orchestrator launched by `codedeck open`, which is almost all of the user's spend, never gets usage: every `origin='open'` row has an empty usage value, while the orchestrator transcripts hold about US$ 1,470 of reported cost. The same single-value-per-row model also drops earlier Claude worker processes after `send` (US$ 48.80 over 10 sessions), bills Codex cached tokens twice (US$ 7,428 shown where the no-double-count upper bound is US$ 3,830), and marks every run incomplete because the orchestrator row itself has no cost.
+
+## Current state
+
+Counts below were captured on 2026-09-22 between 20:40 and 21:10 UTC from `~/.run-agent/run-agent.db` and the transcript directories. They drift as new sessions run; the fixtures named in the acceptance tests are the stable reference.
+
+1. Usage enters the store only as `usage.updated` events that a driver parser emits from a worker's stream. The daemon either replaces the row's value or, when `incremental` is true, adds to it (`src/daemon/daemon.ts:1316`). One row holds one value (`sessions.usage_*`, `src/store/database.ts:62-65`).
+2. `codedeck open` runs Claude interactively under a pty, so no parser sees its stream. `session.adopt` creates the orchestrator row with `origin='open'` and `runId` equal to its own id (`src/daemon/daemon.ts:428`, `:460`). `open` exports that same id as `CODEDECK_RUN_ID` to the Claude process (`src/cli/commands/open.ts:595`, `:839`). No code path writes usage to that row.
+3. The SessionStart hook overwrites a single sidecar file with the latest native id (`plugin/hooks/session-id.sh:23`). `open` reads it only at exit (`src/cli/commands/open.ts:572`, `:818`; `src/open/runtime.ts:243`, `:369`). At capture time: 132 `open` rows (99 Claude), 31 of the Claude rows with `native_session_id`, none of the 44 `interrupted` and 8 `working` Claude rows with one, and 70 unread sidecars in `~/.run-agent/sessions/`.
+4. One `open` process can pass through several native ids (`/clear`, resume): pid 3595879 left 6 distinct `.name` sidecars. One native id can also appear in several `open` processes, because resume keeps the id: `55f4307e-...` appears under 4 pids.
+5. `usage.get` aggregates every row with the run id, including the orchestrator row (`src/store/sessions.ts:197`, `src/core/run-usage.ts:56`). A run with no workers returns `sessionCount: 1, costComplete: false, sessionsWithoutCost: 1` (observed on run `5d81` before its first worker).
+6. Worker rows created by `codedeck run` have `origin` NULL (`src/store/sessions.ts:56`; observed on row `d7d6`).
+7. `plugin/statusline.sh:175-240` shows local cost (`payload.cost.total_cost_usd`) plus the run's cost, requires `usage.runId` to equal its `CODEDECK_RUN_ID` (`plugin/statusline.sh:191`), and never persists the local value.
+8. Codex `cached_input_tokens` is a subset of `input_tokens`, and `computeSessionCost` bills both (`src/core/pricing.ts:160-164`). `gpt-5.6-luna` declares no cached price (`src/core/pricing.ts:20`). `queryUsage` prices rows at query time from stored tokens (`src/store/sessions.ts:350`), so a formula change reprices history.
+9. Claude worker `send` starts a new `claude -p --resume` process whose `total_cost_usd` restarts at zero. The daemon replaces the value, so earlier processes are lost.
+10. The daemon gives workers `CODEDECK_SESSION_ID` but never `CODEDECK_RUN_ID` (`src/drivers/session-runtime.ts:141`), so a worker that dispatches another worker creates an unlinked row (36 rows on 2026-09-22, almost all reviewers).
+
+## Supersedes
+
+This spec replaces these parts of `.specs/features/run-usage-statusline/spec.md`. The rest of that spec stays in force.
+
+| Old text | Replaced by |
+| --- | --- |
+| AC 1 acceptance test: "`open` must not create a session row in the store" | Already false since `session.adopt`. The `open` row stays (ORCH-*). |
+| Design decision 1: "`open` does not create a row in `sessions`" | Already false since `session.adopt`. This spec keeps the row and makes it the orchestrator's usage owner (ORCH-*). |
+| AC 2 and design decision 5: output contains exactly eight properties | RUN-01 to RUN-04: the existing properties keep their meaning, and `orchestrator` and `total` objects are added. |
+| AC 5: the statusline "cannot write the orchestrator into the store" | ORCH-04: the statusline reports the live orchestrator cost. RUN-06 counts each orchestrator source once. |
+| Design decision 4: `cachedTokens * cached` added to input for every harness | PRICE-01 for harnesses whose cached tokens are part of input. |
+
+## Goals
+
+- [ ] `codedeck usage --all` includes orchestrator cost for every Claude `open` session whose transcript can be found, split from worker cost.
+- [ ] `codedeck usage --json` reports workers and orchestrator as separate figures, and a run with no workers reports `costComplete: true`.
+- [ ] Stored usage for Codex and Claude-after-send matches the harness's own cumulative figures on the fixtures in this spec.
+
+## Out of Scope
+
+| Feature | Reason |
+| --- | --- |
+| Orchestrator usage for `open` on codex or opencode | 2 and 31 rows. Each needs its own transcript reader. Separate feature. |
+| Usage in the web UI or the agents pane | No consumer there today. This feature fixes the data, not the surfaces. |
+| New price values for models missing from the table (for example `claude-opus-5-5`) | Price facts are the user's call. The orchestrator path uses reported cost, so it does not need them. |
+| Antigravity live usage between steps | The final `result` total is already correct. Only the mid-run value is off. |
+| Rewriting historical Claude worker rows lost to `send` | US$ 48.80 total. New sessions are fixed. History is not recomputed. |
+| Changing the `codedeck usage` table layout beyond the origin grouping | Presentation is not the problem. |
+| Transcripts outside `~/.claude/projects` | Not observed on this machine and not used anywhere in the repo. |
+| Live orchestrator token counts | The statusline payload carries cumulative cost only. Tokens arrive at release (ORCH-06). |
+
+---
+
+## Assumptions & Open Questions
+
+| Assumption / decision | Chosen default | Rationale | Confirmed? |
+| --- | --- | --- | --- |
+| Cached price for `gpt-5.6-luna` | No cached price is added. Cached tokens are billed once, at the input price. | The table has no value. Removing the double count is correct regardless of the discount. | y (user "ok", 2026-09-22) |
+| Historical orchestrator backfill granularity | Legacy orchestrator usage keyed by native id, not linked to an `open` row | Linking by cwd and time can misattribute. The user needs the total. | y (user "ok", 2026-09-22) |
+| Live orchestrator cost source | The statusline reports `payload.cost.total_cost_usd` for `payload.session_id` | `cost-state` is written only when a session ends. The statusline value comes from the same ledger (see External Dependencies). | y |
+| Authoritative orchestrator usage at end | Last `cost-state` line of the native transcript | It carries cost and per-model tokens and survives resume in the same id. | y |
+| Correlation key from hook and statusline to the `open` row | `CODEDECK_RUN_ID` from the environment, which equals the `open` row id | Both processes inherit the Claude environment that `open` sets. | y |
+| Transcript location | `~/.claude/projects/*/.jsonl`, found by file name | The slug derivation is Claude Code internal. | y |
+| Token fallback when no `cost-state` exists | Sum `message.usage` of `assistant` lines deduplicated by `message.id` + `requestId` | 5 of 84 found transcripts lack `cost-state`, and 310 of 520 assistant lines repeat in `1db0600a`. | y |
+| Rows with NULL `origin` | Counted as workers | `codedeck run` never sets `origin`. | y |
+| Codex history | Repriced on the next query by PRICE-01, with no migration | Cost is computed from stored tokens at query time. | y |
+
+**Open questions:** none.
+
+---
+
+## Usage model
+
+These definitions make the criteria below testable. They describe stored and query-visible data, not an implementation.
+
+- A **source** is one cumulative counter reported by a harness. Its key is fixed per harness:
+
+ | Harness path | Source key | The reported value is |
+ | --- | --- | --- |
+ | Claude worker (`claude -p`) | native session id + process ordinal within the CodeDeck session (1 for the first process, 2 after the first `send`, and so on) | cumulative for that process |
+ | Codex worker | thread id (`nativeSessionId`) | cumulative for the thread |
+ | Antigravity and OMP workers | CodeDeck session id | cumulative for the session, final value wins |
+ | opencode worker | none, each event is a delta added to the row | a delta |
+ | Claude `open` orchestrator | native session id | cumulative for the native session across resumes |
+
+- Each source has one **high-water mark** per field (cost and each token field): the highest cumulative value observed for it on any row.
+- WHEN a row observes a source value above the high-water mark, the difference is added to that row's **attributed usage** for the source and the mark moves to the new value. An observation at or below the mark changes nothing.
+- A row's usage is the sum of its attributed usage over its sources, plus opencode deltas. The sum of attributed usage over all rows for one source therefore equals the source's high-water mark.
+- Known limit: if two live `open` processes resume the same native id at the same time, each process keeps its own ledger, and the smaller increases can be missed. The total is never counted twice.
+
+Worked example for one native id `X` observed by two `open` rows, used by ORCH-13 to ORCH-15:
+
+| Step | Event | High-water of `X` | Row A attributed | Row B attributed | Sum of rows |
+| --- | --- | --- | --- | --- | --- |
+| 1 | A links `X`, statusline reports 4.00 | 4.00 | 4.00 | - | 4.00 |
+| 2 | statusline reports 3.50 (out of order) | 4.00 | 4.00 | - | 4.00 |
+| 3 | A released, `cost-state` says 10.00 | 10.00 | 10.00 | - | 10.00 |
+| 4 | B resumes `X`, statusline reports 10.00 | 10.00 | 10.00 | 0.00 | 10.00 |
+| 5 | B statusline reports 12.00 | 12.00 | 10.00 | 2.00 | 12.00 |
+| 6 | B released, `cost-state` says 15.00 | 15.00 | 10.00 | 5.00 | 15.00 |
+
+---
+
+## User Stories
+
+### P1: Orchestrator usage is captured ⭐ MVP
+
+**User Story**: As the person driving `codedeck open`, I want the orchestrator's cost recorded so that `codedeck usage` shows where most of my spend goes.
+
+**Why P1**: The orchestrator is almost all of the spend and is invisible today.
+
+**Acceptance Criteria**:
+
+1. ORCH-01: WHEN the SessionStart hook runs with a native session id and a `CODEDECK_RUN_ID` THEN the system SHALL link that native id to the row whose id equals `CODEDECK_RUN_ID` within 3 seconds.
+2. ORCH-02: IF the daemon does not answer THEN the SessionStart hook SHALL exit within 500 ms and SHALL still write the sidecar as it does today.
+3. ORCH-03: WHEN a second distinct native id starts inside the same `open` process THEN the system SHALL keep both native ids linked to that row.
+4. ORCH-04: WHEN the statusline renders a payload with a finite, non-negative `cost.total_cost_usd`, a `session_id` and a `CODEDECK_RUN_ID` THEN the system SHALL record that value as an observation of source `session_id` on row `CODEDECK_RUN_ID`.
+5. ORCH-05: WHEN a Claude `open` row is released THEN the system SHALL record the `totalCostUSD` of the last `cost-state` line of each linked transcript as an observation of that native id.
+6. ORCH-06: WHEN a Claude `open` row is released THEN the system SHALL record the per-model `inputTokens`, `outputTokens`, `cacheReadInputTokens` and `cacheCreationInputTokens` of that `cost-state` line as a token observation of that native id.
+7. ORCH-07: WHEN the daemon starts THEN the system SHALL apply ORCH-05 and ORCH-06 to every Claude `open` row whose status is `interrupted`, or `working` with a dead pid, and that has linked native ids.
+8. ORCH-08: IF a linked transcript has no `cost-state` line THEN the system SHALL record as token observation the sum of `message.usage` over `assistant` lines deduplicated by `message.id` + `requestId`.
+9. ORCH-09: WHEN ORCH-08 produced the token observation and the model has a price in the static table THEN the system SHALL record the priced tokens as the cost observation.
+10. ORCH-10: IF ORCH-08 produced the token observation and the model has no price THEN the system SHALL count the row as without cost in `sessionsWithoutCost`.
+11. ORCH-11: IF no transcript file exists for a linked native id THEN the system SHALL count the row as without cost.
+12. ORCH-12: IF no transcript file exists for a linked native id THEN `session.release` SHALL still set the row to the status the caller requested (`completed` or `failed`).
+13. ORCH-13: WHEN an observation is lower than or equal to the source's high-water mark THEN the system SHALL leave every row's attributed usage unchanged.
+14. ORCH-14: WHEN a row observes a value above the source's high-water mark THEN the system SHALL add the difference to that row's attributed usage.
+15. ORCH-15: The sum of attributed cost over all rows for one native id SHALL equal that native id's high-water mark.
+16. ORCH-16: WHEN a new native id links to a row that already has a linked native id THEN the system SHALL apply ORCH-05 and ORCH-06 to the previously linked native ids.
+
+**Independent Test**: replay the worked example in "Usage model" with fixture transcripts and statusline payloads, then assert row A attributed 10.00, row B attributed 5.00, and `codedeck usage --all --json` orchestrator cost 15.00. Separately, link id `Y` to a row that holds id `X` with a fixture `cost-state` of 3.00 and assert `X` is attributed 3.00 before the row is released.
+
+---
+
+### P1: Run totals split orchestrator and workers ⭐ MVP
+
+**User Story**: As the user reading the statusline, I want the run cost split into orchestrator and workers so the "?" marker means something.
+
+**Why P1**: Today the orchestrator row alone makes every run incomplete.
+
+**Acceptance Criteria**:
+
+1. RUN-01: The `usage.get` result SHALL keep `runId` equal to the requested run id.
+2. RUN-02: The `usage.get` result SHALL compute `inputTokens`, `outputTokens`, `cachedTokens`, `costUsd`, `sessionCount`, `activeSessionCount`, `costComplete` and `sessionsWithoutCost` over worker rows only, where a worker row has that `run_id` and an `origin` that is NULL or different from `open`.
+3. RUN-03: The `usage.get` result SHALL include an `orchestrator` object with `costUsd`, `costComplete`, `inputTokens`, `outputTokens` and `cachedTokens` computed over the `open` rows of that run.
+4. RUN-04: The `usage.get` result SHALL include a `total` object whose `costUsd` equals top-level `costUsd` plus `orchestrator.costUsd`.
+5. RUN-05: WHEN a run has no worker rows THEN `usage.get` SHALL return `sessionCount: 0` and `costComplete: true` at the top level.
+6. RUN-06: WHEN the statusline obtains run usage THEN the `run $` figure SHALL equal top-level worker `costUsd`, plus the orchestrator attributed cost of every source other than `payload.session_id`, plus the payload's `cost.total_cost_usd`.
+7. RUN-07: IF the statusline cannot obtain run usage THEN it SHALL show the local payload cost without a `run $` figure, as today.
+8. RUN-08: The aggregate `codedeck usage` totals SHALL include orchestrator cost.
+9. RUN-09: WHEN `codedeck usage --by origin --json` runs THEN the result SHALL contain a `byOrigin` array with one bucket keyed `orchestrator` for `open` rows and one keyed `worker` for all other rows.
+
+**Independent Test**: seed run `r1` with one `open` row whose source `X` is attributed 3.00 and source `Y` 0.80, and two NULL-origin workers with 0.30 and 0.20. Assert `usage.get` returns `runId: "r1"`, top-level `costUsd` 0.50 and `sessionCount` 2, `orchestrator.costUsd` 3.80 and `total.costUsd` 4.30. Assert the statusline with `session_id` `Y` and payload cost 1.00 renders `run $4.50`, and with the daemon stopped renders `$1.00` and no `run`.
+
+---
+
+### P1: Usage accumulates per source ⭐ MVP
+
+**User Story**: As the user, I want each harness's cumulative counter stored once per source so later turns do not erase earlier ones and nothing is counted twice.
+
+**Why P1**: The same model fixes the orchestrator's multiple native ids and the Claude `send` loss.
+
+**Acceptance Criteria**:
+
+1. SRC-01: WHEN a Claude worker's second process reports `total_cost_usd` THEN the row's cost SHALL equal the first process's last value plus the second process's last value.
+2. SRC-02: WHEN a Codex `turn.completed` arrives for a thread already seen on the row THEN the row's tokens SHALL equal that event's values.
+3. SRC-03: WHEN an opencode `incremental` event arrives THEN the row's usage SHALL increase by the event's values.
+4. SRC-04: WHEN the daemon replays a log line whose events were already committed THEN the row's usage SHALL stay unchanged.
+
+**Independent Test**: feed the events of session `2e99` (0.4661592 then 0.1616463 across two processes) and assert cost 0.6278055. Feed the two `turn.completed` events of Codex thread `01a0c9ee` and assert input 10,891,738 and cached 10,551,808.
+
+---
+
+### P1: Prices do not double count ⭐ MVP
+
+**User Story**: As the user, I want Codex cost computed once per token so the worker figure is believable.
+
+**Why P1**: The current `gpt-5.6-luna` figure is at least 94% inflated.
+
+**Acceptance Criteria**:
+
+1. PRICE-01: WHERE the harness reports cached tokens as part of input (Codex) the system SHALL compute cost as `((input - cached) * inputPrice + cached * cachedPrice + output * outputPrice) / 1,000,000`.
+2. PRICE-02: IF a model has no cached price THEN the system SHALL use the input price for cached tokens.
+3. PRICE-03: IF a Codex row has cached greater than input THEN the system SHALL count that row as without cost.
+4. PRICE-04: WHERE the harness reports cached tokens separately from input (Claude, Antigravity) the system SHALL keep the formula `(input * inputPrice + output * outputPrice + cached * cachedPrice) / 1,000,000`.
+
+**Independent Test**: price `gpt-5.6-luna` with input 1,000,000, cached 900,000 and output 0, and assert US$ 1.00, not US$ 1.90.
+
+---
+
+### P2: Nested dispatch stays in the run
+
+**User Story**: As the user, I want a reviewer dispatched by a worker counted in my run.
+
+**Why P2**: This is attribution. It does not change the grand total.
+
+**Acceptance Criteria**:
+
+1. RUN-10: WHEN the daemon spawns a worker whose row has a `run_id` THEN the worker's environment SHALL contain `CODEDECK_RUN_ID` equal to that `run_id`.
+2. RUN-11: IF the worker's row has no `run_id` THEN the worker's environment SHALL NOT contain `CODEDECK_RUN_ID`.
+
+**Independent Test**: spawn a worker for run `r1` with a stub harness that prints its environment and assert `CODEDECK_RUN_ID=r1`. Spawn a second worker with no `run_id` and assert the variable is absent.
+
+---
+
+### P2: Historical orchestrator backfill
+
+**User Story**: As the user, I want the orchestrator cost I already spent imported once so the history is not empty.
+
+**Why P2**: Useful, not needed for new sessions.
+
+**Acceptance Criteria**:
+
+1. BF-01: WHEN the backfill runs THEN the system SHALL collect native ids from `open` rows, from unread session sidecars and from `.name` sidecars in the sessions directory.
+2. BF-02: IF a collected id is a worker row's `native_session_id` THEN the backfill SHALL skip it.
+3. BF-03: IF a collected id already has a high-water mark THEN the backfill SHALL skip it.
+4. BF-04: WHEN a remaining id's transcript has a `cost-state` line THEN the backfill SHALL store one legacy orchestrator entry with that line's cost and per-model tokens.
+5. BF-05: The legacy entry SHALL appear in `codedeck usage --json` `byDay` under the date of the transcript's last timestamped line.
+6. BF-06: The legacy entry SHALL appear in `codedeck usage --json` `byRepository` under the git root of the transcript's last `cwd` value.
+7. BF-07: The legacy entry SHALL appear in the `orchestrator` bucket of RUN-09.
+8. BF-08: WHEN the backfill runs a second time THEN `codedeck usage --all --json` totals SHALL be identical to those after the first run.
+
+**Independent Test**: run the backfill twice over a fixture sessions directory with three ids, one of them a worker id, and assert two legacy entries, the expected `byDay` and `byRepository` keys, both entries inside the `orchestrator` bucket of `--by origin`, and identical totals after the second run.
+
+---
+
+## Edge Cases
+
+- IF the statusline cannot reach the daemon within its existing 1 s budget THEN the statusline SHALL render the local line unchanged and exit 0.
+- IF a transcript line is not valid JSON THEN the reader SHALL skip that line and continue.
+- WHEN a transcript exceeds 50 MB THEN the reader SHALL read it without loading the whole file into memory.
+- IF `CODEDECK_RUN_ID` names no existing row THEN the system SHALL drop the observation without creating a row.
+
+---
+
+## Implicit-requirement sweep
+
+| Dimension | Resolution |
+| --- | --- |
+| Input validation & bounds | ORCH-04 accepts only finite, non-negative cost. PRICE-03 rejects cached greater than input. |
+| Failure / partial failure | ORCH-02, ORCH-11, ORCH-12, RUN-07, and the statusline edge case. |
+| Idempotency / duplicates | ORCH-13, SRC-04, BF-08. |
+| Auth & rate limits | N/A because everything is local to one user over the existing local IPC. |
+| Concurrency / ordering | ORCH-13 ignores out-of-order observations. ORCH-14 and ORCH-15 cover a native id shared by two rows. The Usage model states the limit for two simultaneous live processes on one id. |
+| Data lifecycle | Sidecars are consumed as today. The backfill reads them and does not delete them. |
+| Observability | N/A because the stored usage is itself the observable, read through `codedeck usage`. |
+| External-dependency failure | ORCH-08 to ORCH-12 cover a missing or changed transcript. |
+| State-transition integrity | ORCH-07 reads only `interrupted` rows or `working` rows with a dead pid at daemon start. |
+
+---
+
+## External Dependencies
+
+| Resource | Identifier | System | Verified | Evidence |
+| --- | --- | --- | --- | --- |
+| Claude transcript cost line | "type":"cost-state" with totalCostUSD, modelUsage | Claude Code 2.1.280 | yes | observed in `~/.claude/projects/-home-andreello-dev-splitc-backend/1db0600a-cf9e-41c7-bbeb-86ebd6bd2a84.jsonl`, lines 1654, 1655, 1798 (final 23.55831195) |
+| Transcript cost line type | cost-state | Claude Code 2.1.280 | yes | observed in the same transcript; the binary declares `"cost-state":"last-wins"` in its transcript metadata table |
+| `cost-state` written only at session end | end-of-session write | Claude Code 2.1.280 | yes | observed: live transcript `b6b5fb85-...jsonl` had 410 lines and 0 `cost-state` at 21:05 UTC; 403 of 1,239 transcripts contain one |
+| Statusline cost is the session cumulative ledger | statusline stdin payload | Claude Code 2.1.280 | yes | observed in the installed binary `~/.local/share/mise/installs/claude/2.1.280/claude`: the statusline payload builds `cost.total_cost_usd` from `em()`, `em()` returns `costLedger.totalCostUSD()`, and `nyt()` writes the same `em()` into `cost-state.totalCostUSD` |
+| `/clear` saves then resets the cost ledger | conversation_reset | Claude Code 2.1.280 | yes | observed in the installed binary: the `conversation_reset` path calls `nRr(s)` (cost saver), then `$Me()` (`resetCostState`, `costLedger.reset(id)`), then `XTr` (`regenerateSessionId`); resume calls `aRr`, which runs `costLedger.restore` |
+| SessionStart fires per native id change | hook stdin session_id | Claude Code 2.1.280 | yes | observed: 6 `.name` sidecars for pid 3595879 in `~/.run-agent/sessions/` |
+| `claude -p` `total_cost_usd` restarts per process | result event | Claude Code 2.1.280 | yes | observed in events of session `2e99`: 0.4661592 (13 turns), then 0.1616463 (4 turns) |
+| Codex `turn.completed` usage is the thread total | turn.completed.usage | codex exec | yes | observed in `~/.codex/sessions/2026/09/22/rollout-...-01a0c9ee-...jsonl`: turn 1 ends at 8,359,849 and turn 2 continues from it |
+| Codex cached is a subset of input | cached_input_tokens | codex exec | yes | observed in the same rollout: input 8,359,849, cached 8,116,224 |
+| Transcript root | ~/.claude/projects | Claude Code 2.1.280 | yes | observed: 1,239 .jsonl transcripts listed under it on 2026-09-22 |
+| Transcript file per native id | ~/.claude/projects/*/.jsonl | Claude Code 2.1.280 | yes | observed: 84 of 88 orchestrator native ids found by file name |
+| Pricing formula | computeSessionCost | repo | yes | `src/core/pricing.ts:160` |
+| Session sidecar directory | ~/.run-agent/sessions/ | repo | yes | `src/open/pty.ts:50` |
+| CodeDeck store | ~/.run-agent/run-agent.db | repo | yes | `src/config/paths.ts` (`getPaths().db`) |
+| Worker session env var | CODEDECK_SESSION_ID | repo | yes | `src/drivers/session-runtime.ts:141` |
+| Run id env var | CODEDECK_RUN_ID | repo | yes | `src/cli/commands/run.ts:15`, `src/cli/commands/open.ts:839` |
+| Unpriced model id | claude-opus-5-5 | repo | yes | `src/core/pricing.ts:30` (only `claude-opus-5` is listed) |
+
+## Requirement Traceability
+
+| Requirement ID | Story | Phase | Status |
+| --- | --- | --- | --- |
+| ORCH-01 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-02 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-03 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-04 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-05 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-06 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-07 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-08 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-09 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-10 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-11 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-12 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-13 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-14 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-15 | P1: Orchestrator usage is captured | Design | Pending |
+| ORCH-16 | P1: Orchestrator usage is captured | Design | Pending |
+| RUN-01 | P1: Run totals split | Design | Pending |
+| RUN-02 | P1: Run totals split | Design | Pending |
+| RUN-03 | P1: Run totals split | Design | Pending |
+| RUN-04 | P1: Run totals split | Design | Pending |
+| RUN-05 | P1: Run totals split | Design | Pending |
+| RUN-06 | P1: Run totals split | Design | Pending |
+| RUN-07 | P1: Run totals split | Design | Pending |
+| RUN-08 | P1: Run totals split | Design | Pending |
+| RUN-09 | P1: Run totals split | Design | Pending |
+| SRC-01 | P1: Usage accumulates per source | Design | Pending |
+| SRC-02 | P1: Usage accumulates per source | Design | Pending |
+| SRC-03 | P1: Usage accumulates per source | Design | Pending |
+| SRC-04 | P1: Usage accumulates per source | Design | Pending |
+| PRICE-01 | P1: Prices do not double count | Design | Pending |
+| PRICE-02 | P1: Prices do not double count | Design | Pending |
+| PRICE-03 | P1: Prices do not double count | Design | Pending |
+| PRICE-04 | P1: Prices do not double count | Design | Pending |
+| RUN-10 | P2: Nested dispatch stays in the run | Design | Pending |
+| RUN-11 | P2: Nested dispatch stays in the run | Design | Pending |
+| BF-01 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-02 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-03 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-04 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-05 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-06 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-07 | P2: Historical orchestrator backfill | Design | Pending |
+| BF-08 | P2: Historical orchestrator backfill | Design | Pending |
+
+**Coverage:** 43 total, 0 mapped to tasks, 43 unmapped until Tasks.
+
+---
+
+## Success Criteria
+
+- [ ] After the backfill, `codedeck usage --all --json` reports orchestrator cost within 1% of the sum of last `cost-state.totalCostUSD` over the collected native ids (US$ 1,470.77 at capture time).
+- [ ] `codedeck usage --json` for a run with no workers reports `sessionCount: 0` and `costComplete: true`.
+- [ ] Codex `gpt-5.6-luna` cost under PRICE-01 is at most US$ 3,830 over the capture-time rows.
diff --git a/.specs/features/orchestrator-usage/tasks.md b/.specs/features/orchestrator-usage/tasks.md
new file mode 100644
index 0000000..04adc27
--- /dev/null
+++ b/.specs/features/orchestrator-usage/tasks.md
@@ -0,0 +1,624 @@
+# Orchestrator Usage Tasks
+
+## Execution Protocol (MANDATORY -- do not skip)
+
+Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. The skill is the source of truth for the full flow (per-task cycle, sub-agent delegation, adequacy review, Verifier, discrimination sensor).
+
+**If the skill cannot be activated, STOP and tell the user - do not proceed without it.**
+
+---
+
+**Spec**: `.specs/features/orchestrator-usage/spec.md`
+**Design**: `.specs/features/orchestrator-usage/design.md`
+**Status**: Draft
+
+---
+
+## Test Coverage Matrix
+
+> Generated from codebase, project guidelines, and spec - confirm before Execute. Guidelines found: `AGENTS.md` and `~/.claude/CLAUDE.md` (never run the full suite, always scope by file), `vitest.config.ts` (`tests/**/*.test.ts`, `node:sqlite` shim), `.github/workflows/ci.yml` (`npm run build`, `npm test`). No coverage threshold configured, strong defaults applied.
+
+| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command |
+| ---------- | ------------------ | -------------------- | ---------------- | ----------- |
+| Schema / migration (`src/store/database.ts`) | integration | new tables exist on a fresh DB and on a DB created by the previous schema; reopening is a no-op | `tests/power-database.test.ts` pattern, new file `tests/usage-schema.test.ts` | `npx vitest run tests/usage-schema.test.ts` |
+| Store modules (`src/store/usage-ledger.ts`, `src/store/native-links.ts`) | integration (real SQLite via shim) | every branch; ORCH-13/14/15 and the 6-step worked example; seed path; ORCH-03 and ORCH-16 `previous` | `tests/usage-ledger.test.ts`, `tests/native-links.test.ts` | `npx vitest run tests/usage-ledger.test.ts tests/native-links.test.ts` |
+| Pure core (`src/core/pricing.ts`, `src/core/usage-source.ts`, `src/core/run-usage.ts`, `src/core/claude-transcript.ts`) | unit | 1:1 to the ACs each covers, every listed edge case (bad JSON line, file above 50 MB streamed, missing file) | `tests/pricing.test.ts`, `tests/usage-source.test.ts`, `tests/usage.test.ts`, `tests/claude-transcript.test.ts` | `npx vitest run ` |
+| Usage query (`src/store/sessions.ts` `queryUsage`) | integration | `byOrigin` buckets, orchestrator in totals, Codex repricing on read, legacy merge (P2) | `tests/usage-query.test.ts` | `npx vitest run tests/usage-query.test.ts` |
+| Daemon IPC and consumer (`src/daemon/daemon.ts`, `src/daemon/protocol.ts`) | integration (daemon seam) | every new or changed method: happy path, dropped observation, replay, release with and without transcript, startup reconcile | `tests/usage-daemon.test.ts`, new `tests/orchestrator-usage-daemon.test.ts` | `npx vitest run tests/usage-daemon.test.ts tests/orchestrator-usage-daemon.test.ts` |
+| Driver spawn (`src/drivers/session-driver.ts`) | integration | env with and without `run_id` | `tests/session-runtime.test.ts` pattern, new `tests/run-env.test.ts` | `npx vitest run tests/run-env.test.ts` |
+| CLI commands (`src/cli/commands/usage.ts`, `src/cli/commands/usage-backfill.ts`) | integration | argv parsing, invalid `--observe` values not sent, JSON shape | `tests/usage-cli.test.ts`, `tests/usage-backfill.test.ts` | `npx vitest run ` |
+| Open runtime (`src/open/link-watcher.ts`, `src/open/runtime.ts`, `src/cli/commands/open.ts`) | unit + integration | watcher sends each id once, retries after IPC error, `flush` before release on each exit path, `takeSessionId` returns last line | `tests/link-watcher.test.ts`, `tests/open-contract.test.ts` | `npx vitest run tests/link-watcher.test.ts tests/open-contract.test.ts` |
+| Plugin shell (`plugin/hooks/session-id.sh`, `plugin/statusline.sh`) | integration (spawned bash) | hook appends and exits under 500 ms with no daemon; statusline argv, RUN-06 arithmetic, RUN-07 fallback, old-daemon fallback | `tests/session-id-hook.test.ts`, `tests/statusline.test.ts` | `npx vitest run tests/session-id-hook.test.ts tests/statusline.test.ts` |
+| Type declarations only | none | build gate only | - | `npx tsc --noEmit` |
+
+## Gate Check Commands
+
+> Generated from codebase - confirm before Execute. The full suite (`npm test`, bare `vitest run`) is never used: every gate names its files.
+
+| Gate Level | When to Use | Command |
+| ---------- | ----------- | ------- |
+| Quick | after a task whose tests are unit or store integration | `npx vitest run ` |
+| Full | after a task that touches the daemon, CLI, open runtime or plugin | `npx tsc --noEmit && npx vitest run ` |
+| Build | at the end of each phase | `npm run build && npx vitest run `, one file per invocation when the list is long |
+
+---
+
+## Execution Plan
+
+Phases run in order. Inside a phase, tasks run in order and arrows show real dependencies. A task may also depend on tasks from an earlier phase.
+
+### Phase 1: Store and pure core
+
+```
+T1 -> T2
+T1 -> T3
+T4 -> T6
+T4 -> T7
+T5
+```
+
+### Phase 2: Daemon integration
+
+```
+T8 -> T9 -> T10 -> T11
+T12
+T13
+```
+
+### Phase 3: Clients
+
+```
+T14 -> T15
+T16 -> T17
+T18 -> T19
+```
+
+### Phase 4: Backfill (P2)
+
+```
+T20 -> T21
+```
+
+---
+
+## Task Breakdown
+
+### Phase 1: Store and pure core
+
+### T1: Add usage ledger tables to the schema
+
+**What**: create `usage_sources`, `usage_attributions` and `session_native_links` with the columns in the design Data Models, both FKs `ON DELETE CASCADE`, and an index on `usage_attributions(source_key)`.
+**Where**: `src/store/database.ts`
+**Depends on**: None
+**Reuses**: `CREATE TABLE IF NOT EXISTS` block at `src/store/database.ts:44` and the index lines at 136-137
+**Requirement**: ORCH-13, ORCH-15
+
+**Done when**:
+
+- [ ] `tests/usage-schema.test.ts` asserts the three tables and the index on a fresh DB
+- [ ] the same test opens a DB created without them, reopens it, and finds them, and a second open does not throw
+- [ ] gate passes: `npx vitest run tests/usage-schema.test.ts tests/power-database.test.ts`
+
+**Tests**: integration
+**Gate**: quick
+
+---
+
+### T2: Implement UsageLedger
+
+**What**: `observe`, `attributionsFor` and `hasSource` per the design, including the `seed:` path and materialization into `sessions.usage_*`.
+**Where**: `src/store/usage-ledger.ts`
+**Depends on**: T1
+**Reuses**: `SessionStore.update` in `src/store/sessions.ts`
+**Requirement**: ORCH-13, ORCH-14, ORCH-15
+
+**Done when**:
+
+- [ ] `tests/usage-ledger.test.ts` replays the 6 steps of the spec worked example and asserts every cell of the table (marks, row A, row B, sum)
+- [ ] a lower or equal observation leaves every attribution and the materialized columns unchanged (ORCH-13)
+- [ ] an absent field changes nothing, a present field moves independently of the others
+- [ ] a row with pre-existing `usage_*` and no attribution keeps its value after the first observation (seed)
+- [ ] for every source in the test, `SUM(attributions.cost) = mark.cost` (ORCH-15)
+- [ ] gate passes: `npx vitest run tests/usage-ledger.test.ts`
+
+**Tests**: integration
+**Gate**: quick
+
+---
+
+### T3: Implement the native link store
+
+**What**: `link`, `unreconciled`, `markReconciled` and `staleOpenRows` per the design.
+**Where**: `src/store/native-links.ts`
+**Depends on**: T1
+**Reuses**: row mapping in `src/store/sessions.ts:56`
+**Requirement**: ORCH-03, ORCH-07, ORCH-16
+
+**Done when**:
+
+- [ ] linking two ids to one row keeps both (ORCH-03); linking the same id twice returns `created: false`
+- [ ] the second link returns the first id in `previous` while it is unreconciled, and not after `markReconciled` (ORCH-16)
+- [ ] `staleOpenRows` returns `interrupted` rows and `working` rows the predicate calls dead, both with an unreconciled link, and nothing else (ORCH-07)
+- [ ] gate passes: `npx vitest run tests/native-links.test.ts`
+
+**Tests**: integration
+**Gate**: quick
+
+---
+
+### T4: Price Codex cached tokens once
+
+**What**: add `cachedInInput` to `computeSessionCost` and export `cachedInInputFor(agent)`.
+**Where**: `src/core/pricing.ts`
+**Depends on**: None
+**Reuses**: `resolveModelPrice`, `isUsablePrice`
+**Requirement**: PRICE-01, PRICE-02, PRICE-03, PRICE-04
+
+**Done when**:
+
+- [ ] `gpt-5.6-luna` with input 1,000,000, cached 900,000, output 0 and `cachedInInput` prices US$ 1.00 (PRICE-01)
+- [ ] a model without a cached price uses the input price for cached (PRICE-02)
+- [ ] cached above input with `cachedInInput` returns `null` (PRICE-03)
+- [ ] without the flag the existing formula and existing `tests/pricing.test.ts` cases still pass (PRICE-04)
+- [ ] `cachedInInputFor("codex")` is `true`, `claude`, `antigravity`, `omp`, `opencode` are `false`
+- [ ] gate passes: `npx vitest run tests/pricing.test.ts`
+
+**Tests**: unit
+**Gate**: quick
+
+---
+
+### T5: Resolve worker source keys
+
+**What**: `workerSourceKey(session, processOrdinal)` returning the prefixed keys from the design, `undefined` for opencode.
+**Where**: `src/core/usage-source.ts`
+**Depends on**: None
+**Reuses**: `Session` from `src/core/session.ts`
+**Requirement**: SRC-01, SRC-02, SRC-03
+
+**Done when**:
+
+- [ ] Claude row with native id `n` gives `claude:n#1` and `claude:n#2` for ordinals 1 and 2
+- [ ] Codex row gives `codex:`, and `codex:` when the native id is unknown
+- [ ] Antigravity and OMP give `session:`, opencode gives `undefined`
+- [ ] gate passes: `npx vitest run tests/usage-source.test.ts`
+
+**Tests**: unit
+**Gate**: quick
+
+---
+
+### T6: Read Claude transcripts into one observation
+
+**What**: `findTranscript` and `readTranscriptUsage` per the design mapping, streamed with `readline`.
+**Where**: `src/core/claude-transcript.ts`
+**Depends on**: T4
+**Reuses**: cached rule in `src/drivers/claude/parser.ts:117`, `computeSessionCost` from T4
+**Requirement**: ORCH-05, ORCH-06, ORCH-08, ORCH-09, ORCH-10, ORCH-11
+
+**Done when**:
+
+- [ ] fixtures under `tests/fixtures/claude-transcript/` (small, hand-made, no real conversation text) cover: two `cost-state` lines where the last wins; no `cost-state` with repeated assistant lines; an unpriced model; a line that is not JSON
+- [ ] `cost-state` fixture gives cost = last `totalCostUSD` and the summed `modelUsage` tokens with cached = read + creation (ORCH-05, ORCH-06)
+- [ ] no-`cost-state` fixture gives tokens deduped by `message.id` + `requestId` (ORCH-08) and the priced cost (ORCH-09)
+- [ ] unpriced fixture gives no cost and state `no-price` (ORCH-10)
+- [ ] `findTranscript` over a temp projects dir returns the path, and `undefined` for an absent id (ORCH-11)
+- [ ] a generated file above 50 MB is read while `process.memoryUsage().heapUsed` grows by less than 50 MB (edge case)
+- [ ] last timestamp and last `cwd` are returned
+- [ ] gate passes: `npx vitest run tests/claude-transcript.test.ts`
+
+**Tests**: unit
+**Gate**: quick
+
+---
+
+### T7: Split the run aggregate into workers and orchestrator
+
+**What**: new `aggregateRunUsage(runId, sessions, attributions, linkStates)` returning the design `RunUsageSummary`, passing `cachedInInputFor(session.agent)` to pricing.
+**Where**: `src/core/run-usage.ts`
+**Depends on**: T4
+**Reuses**: the existing summing loop in the same file
+**Requirement**: RUN-01, RUN-02, RUN-03, RUN-04, RUN-05, ORCH-10, ORCH-11
+
+**Done when**:
+
+- [ ] the spec Independent Test seed (`r1`, open row with `X` 3.00 and `Y` 0.80, workers 0.30 and 0.20) gives `runId` `r1`, top-level `costUsd` 0.50, `sessionCount` 2, `orchestrator.costUsd` 3.80, `total.costUsd` 4.30, `orchestrator.sources` with both ids
+- [ ] a run with only the open row gives `sessionCount` 0 and `costComplete` true at the top level (RUN-05)
+- [ ] a link state `missing` or `no-price` makes `orchestrator.costComplete` false and leaves the top level untouched
+- [ ] NULL origin rows count as workers (RUN-02)
+- [ ] existing cases in `tests/usage.test.ts` still pass with the new signature, or are updated only where the top level no longer includes the open row
+- [ ] gate passes: `npx vitest run tests/usage.test.ts`
+
+**Tests**: unit
+**Gate**: quick
+
+---
+
+### Phase 2: Daemon integration
+
+### T8: Route worker usage through the ledger
+
+**What**: in `updateSessionFromEvent`, compute the process ordinal and the source key, and send non-incremental `usage.updated` to `UsageLedger.observe`; opencode deltas keep the additive path.
+**Where**: `src/daemon/daemon.ts`
+**Depends on**: T2, T5
+**Reuses**: dedup guard and transaction at `src/daemon/daemon.ts:1225-1236`
+**Requirement**: SRC-01, SRC-02, SRC-03, SRC-04
+
+**Done when**:
+
+- [ ] feeding the usage events of `2e99` (two processes, 0.4661592 then 0.1616463) gives cost 0.6278055 (SRC-01); the fixture carries only the `system/init` and `result` lines needed, no prompt text
+- [ ] two Codex `turn.completed` events for one thread give input 10,891,738 and cached 10,551,808 (SRC-02)
+- [ ] opencode incremental events add up (SRC-03)
+- [ ] replaying the same log lines leaves usage unchanged (SRC-04)
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/usage-daemon.test.ts tests/opencode-parser.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T9: Add `session.linkNative` and `usage.get` observe
+
+**What**: new method `session.linkNative`, optional `observe` on `usage.get`, both typed in `RequestMethod`; observations are accepted only on existing `origin = 'open'` rows; `usage.get` returns the T7 shape.
+**Where**: `src/daemon/daemon.ts`, `src/daemon/protocol.ts` (one method union line)
+**Depends on**: T8, T3, T7
+**Reuses**: `usage.get` handler at `src/daemon/daemon.ts:944`, T3 link store, T7 aggregate
+**Requirement**: ORCH-01, ORCH-03, ORCH-04, RUN-01, RUN-02, RUN-03, RUN-04
+
+**Done when**:
+
+- [ ] `session.linkNative` links the id to the open row, and two ids stay linked (ORCH-01, ORCH-03)
+- [ ] `usage.get` with `observe { nativeId, costUsd }` records it on source `claude-open:` and returns the updated aggregate (ORCH-04)
+- [ ] an observation for an unknown run id, or for a row whose origin is not `open`, is dropped without creating a row, and the aggregate is still returned
+- [ ] negative or non-finite `costUsd` is rejected
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/orchestrator-usage-daemon.test.ts tests/usage-daemon.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T10: Reconcile transcripts on release and on a new link
+
+**What**: private reconcile method on `Daemon`, called from `session.release` for Claude open rows after `setStatus`, and for `previous` ids after a link.
+**Where**: `src/daemon/daemon.ts`
+**Depends on**: T9, T6
+**Reuses**: release handler at `src/daemon/daemon.ts:508`, T6 reader, T3 `markReconciled`
+**Requirement**: ORCH-05, ORCH-06, ORCH-11, ORCH-12, ORCH-13, ORCH-14, ORCH-15, ORCH-16
+
+**Done when**:
+
+- [ ] the spec Independent Test replays the worked example with fixture transcripts under a temp `HOME` and asserts row A 10.00, row B 5.00, and `usage.query` `all` orchestrator cost 15.00
+- [ ] linking `Y` to a row holding `X` with a `cost-state` of 3.00 attributes 3.00 to `X` before release (ORCH-16)
+- [ ] release with no transcript sets the requested `completed` or `failed` and leaves the row without cost (ORCH-11, ORCH-12)
+- [ ] a reader that throws is logged, and release still replies with the status set
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/orchestrator-usage-daemon.test.ts tests/release-interrupted.test.ts tests/session-adopt.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T11: Reconcile stale open rows at daemon start
+
+**What**: after `recover()`, reconcile `staleOpenRows` without blocking startup.
+**Where**: `src/daemon/daemon.ts`
+**Depends on**: T10, T3
+**Reuses**: `recover()` at `src/daemon/daemon.ts:169`, `processAlive`, T10 reconcile method
+**Requirement**: ORCH-07
+
+**Done when**:
+
+- [ ] an `interrupted` open row and a `working` open row with a dead pid, both with a fixture transcript, are attributed after daemon start
+- [ ] a `working` row whose pid is alive with the same start time is not read
+- [ ] a second daemon start does not change totals
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/orchestrator-usage-daemon.test.ts tests/power-recover.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T12: Add the origin split to the usage query
+
+**What**: `queryUsage` selects `origin`, adds `byOrigin`, and passes `cachedInInputFor(agent)` to pricing.
+**Where**: `src/store/sessions.ts`
+**Depends on**: T4
+**Reuses**: `accumulate` helper in `queryUsage`
+**Requirement**: RUN-08, RUN-09, PRICE-01
+
+**Done when**:
+
+- [ ] `byOrigin` has `orchestrator` for `open` rows and `worker` for NULL and other origins (RUN-09)
+- [ ] totals include the open row cost (RUN-08)
+- [ ] a Codex row with cached inside input is priced once
+- [ ] gate passes: `npx vitest run tests/usage-query.test.ts`
+
+**Tests**: integration
+**Gate**: quick
+
+---
+
+### T13: Put the run id in worker environments
+
+**What**: add `runId?: string` to `StartOptions`, set it where the daemon starts a driver, and merge `CODEDECK_RUN_ID` into the env in `SessionDriver.start` only when present.
+**Where**: `src/drivers/session-driver.ts`
+**Depends on**: None
+**Reuses**: `getEnv` merge at `src/drivers/session-driver.ts:87`, env merge at `src/drivers/session-runtime.ts:141`
+**Requirement**: RUN-10, RUN-11
+
+**Done when**:
+
+- [ ] a stub harness that prints its env shows `CODEDECK_RUN_ID=r1` for a row with run `r1` (RUN-10)
+- [ ] a row with no run id gives no `CODEDECK_RUN_ID` (RUN-11)
+- [ ] the daemon call site that builds `StartOptions` passes `session.runId` (named in the task report)
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/run-env.test.ts tests/session-runtime.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### Phase 3: Clients
+
+### T14: Add `--observe` and `--by origin` to the usage CLI
+
+**What**: parse `--observe =`, send it only when the id is a UUID and the cost is finite and non-negative, and add `origin` to the `--by` options.
+**Where**: `src/cli/commands/usage.ts`
+**Depends on**: T9, T12
+**Reuses**: single-run branch at `src/cli/commands/usage.ts:103`
+**Requirement**: ORCH-04, RUN-09
+
+**Done when**:
+
+- [ ] a valid `--observe` is sent as `usage.get` `observe`; an invalid value is not sent and the aggregate is still printed
+- [ ] `--by origin --json` prints `byOrigin`
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/usage-cli.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T15: Report cost and compute the run figure in the statusline
+
+**What**: append `--observe =` and compute `run $` per RUN-06, keeping today's formula when `orchestrator` is absent.
+**Where**: `plugin/statusline.sh`
+**Depends on**: T14
+**Reuses**: `getRunUsage` and `runField` at `plugin/statusline.sh:180-235`, shim in `tests/statusline.test.ts`
+**Requirement**: ORCH-04, RUN-06, RUN-07
+
+**Done when**:
+
+- [ ] with `session_id` `Y`, payload cost 1.00 and the RUN-03 seed response, the line shows `run $4.50` and the shim argv ends with `--observe Y=1`
+- [ ] without `session_id` the argv is exactly `usage --json`, as the existing test asserts
+- [ ] a failing shim shows `$1.00` and no `run` (RUN-07)
+- [ ] a response without `orchestrator` renders as today
+- [ ] gate passes: `npx vitest run tests/statusline.test.ts tests/usage-statusline-contract.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T16: Implement the link watcher
+
+**What**: `startLinkWatcher` per the design.
+**Where**: `src/open/link-watcher.ts`
+**Depends on**: None
+**Reuses**: `SESSION_ID_PATTERN` from `src/open/runtime.ts:220`
+**Requirement**: ORCH-01, ORCH-03
+
+**Done when**:
+
+- [ ] with fake timers, an id appended to the sidecar is sent within one tick, and never sent twice
+- [ ] a rejected `link` is retried on the next tick
+- [ ] `flush()` sends pending ids and resolves; `stop()` clears the timer
+- [ ] invalid lines are ignored
+- [ ] gate passes: `npx vitest run tests/link-watcher.test.ts`
+
+**Tests**: unit
+**Gate**: quick
+
+---
+
+### T17: Start the watcher in `open` and flush before release
+
+**What**: start the watcher where `runId` and `sessionFile` are known, and await `flush()` before `finishOpenSession` on each exit path (`open.ts:652`, `752`, `818`, `852`).
+**Where**: `src/cli/commands/open.ts`
+**Depends on**: T16, T9
+**Reuses**: `client.request` already in scope at each exit path
+**Requirement**: ORCH-01, ORCH-16
+
+**Done when**:
+
+- [ ] `tests/open-contract.test.ts` (or the open harness in `tests/helpers/open-harness.ts`) asserts `session.linkNative` precedes `session.release` on the Claude path
+- [ ] the report lists each exit path and the line where `flush()` runs
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/open-contract.test.ts tests/open-pty.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T18: Append ids in the SessionStart hook
+
+**What**: `printf '%s\n' "$id" >> "$target"` instead of overwriting.
+**Where**: `plugin/hooks/session-id.sh`
+**Depends on**: None
+**Reuses**: the current script
+**Requirement**: ORCH-02, ORCH-03
+
+**Done when**:
+
+- [ ] two runs with two ids leave both lines in the file
+- [ ] with `CODEDECK_RUN_ID` set and no daemon socket, the hook exits 0 in under 500 ms and writes the file (ORCH-02)
+- [ ] without `CODEDECK_SESSION_FILE` it writes nothing and exits 0
+- [ ] gate passes: `npx vitest run tests/session-id-hook.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### T19: Read the last id in `takeSessionId`
+
+**What**: return the last line that matches `SESSION_ID_PATTERN`, so the resume hint names the latest session.
+**Where**: `src/open/runtime.ts`
+**Depends on**: T18
+**Reuses**: `takeSessionId` at `src/open/runtime.ts:243`
+**Requirement**: ORCH-03
+
+**Done when**:
+
+- [ ] a two-line sidecar gives the second id and removes both `.name` sidecars that exist
+- [ ] a single-line file without a trailing newline still works
+- [ ] gate passes: `npx vitest run tests/open-contract.test.ts tests/open-action.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+### Phase 4: Backfill (P2)
+
+### T20: Store legacy entries and merge them into the usage query
+
+**What**: add the `usage_legacy` table and merge its rows into `queryUsage` totals, `byDay`, `byRepository`, `byModel`, `byAgent` (`claude`) and the `orchestrator` bucket, never `byRun`.
+**Where**: `src/store/sessions.ts` (table creation in `src/store/database.ts`)
+**Depends on**: T12
+**Reuses**: `accumulate` and `normalizeProjectName` in `queryUsage`
+**Requirement**: BF-05, BF-06, BF-07
+
+**Done when**:
+
+- [ ] a seeded legacy row appears under its `endedAt` date in `byDay`, under its repository in `byRepository`, and in `byOrigin` `orchestrator`
+- [ ] it does not appear in `byRun` or in `session.list`
+- [ ] gate passes: `npx vitest run tests/usage-query.test.ts tests/usage-schema.test.ts`
+
+**Tests**: integration
+**Gate**: quick
+
+---
+
+### T21: Add `codedeck usage backfill`
+
+**What**: the backfill command per the design, one transaction per id writing the legacy row and the `claude-open:` mark.
+**Where**: `src/cli/commands/usage-backfill.ts`
+**Depends on**: T20, T2, T6
+**Reuses**: T6 reader, T2 `hasSource`, sidecar naming from `src/cli/commands/open.ts:572`
+**Requirement**: BF-01, BF-02, BF-03, BF-04, BF-08
+
+**Done when**:
+
+- [ ] the spec Independent Test: a fixture sessions dir with three ids, one of them a worker native id, gives two legacy entries (BF-01, BF-02, BF-04)
+- [ ] an id that already has a mark is skipped (BF-03)
+- [ ] a second run leaves `usage.query` `all` totals identical (BF-08)
+- [ ] sidecars are read, not deleted
+- [ ] gate passes: `npx tsc --noEmit && npx vitest run tests/usage-backfill.test.ts`
+
+**Tests**: integration
+**Gate**: full
+
+---
+
+## Phase Execution Map
+
+```
+Phase 1 -> Phase 2 -> Phase 3 -> Phase 4
+
+Phase 1: T1 -> T2
+ T1 -> T3
+ T4 -> T6
+ T4 -> T7
+ T5 (no dependency)
+Phase 2: T8 -> T9 -> T10 -> T11
+ T12 (no in-phase dependency)
+ T13 (no dependency)
+Phase 3: T14 -> T15
+ T16 -> T17
+ T18 -> T19
+Phase 4: T20 -> T21
+```
+
+Close of Execute: `npm run build:plugin` so the hook and statusline edits reach `dist/plugin`, then the mutation probe from the orchestrator contract.
+
+---
+
+## Task Granularity Check
+
+| Task | Scope | Status |
+| ---- | ----- | ------ |
+| T1 | 3 tables in one schema block | ⚠️ cohesive, one file |
+| T2 | 1 module | ✅ |
+| T3 | 1 module | ✅ |
+| T4 | 1 function plus 1 helper | ✅ |
+| T5 | 1 function | ✅ |
+| T6 | 2 functions, one module | ✅ |
+| T7 | 1 function | ✅ |
+| T8 | 1 handler branch | ✅ |
+| T9 | 2 IPC methods plus their union type | ⚠️ cohesive, `protocol.ts` is one line |
+| T10 | 1 method plus 2 call sites | ✅ |
+| T11 | 1 call site | ✅ |
+| T12 | 1 function | ✅ |
+| T13 | 1 env merge plus 1 option field | ✅ |
+| T14 | 1 command | ✅ |
+| T15 | 1 script | ✅ |
+| T16 | 1 module | ✅ |
+| T17 | 1 wiring change, 4 exit paths | ✅ |
+| T18 | 1 script line | ✅ |
+| T19 | 1 function | ✅ |
+| T20 | 1 table plus 1 query merge | ⚠️ cohesive, the table exists only for this query |
+| T21 | 1 command | ✅ |
+
+## Diagram-Definition Cross-Check
+
+| Task | Depends On (task body) | Diagram Shows | Status |
+| ---- | ---------------------- | ------------- | ------ |
+| T1 | None | none | ✅ |
+| T2 | T1 | T1 -> T2 | ✅ |
+| T3 | T1 | T1 -> T3 | ✅ |
+| T4 | None | none | ✅ |
+| T5 | None | none | ✅ |
+| T6 | T4 | T4 -> T6 | ✅ |
+| T7 | T4 | T4 -> T7 | ✅ |
+| T8 | T2, T5 (phase 1) | cross-phase | ✅ |
+| T9 | T8; T3, T7 (phase 1) | T8 -> T9 | ✅ |
+| T10 | T9; T6 (phase 1) | T9 -> T10 | ✅ |
+| T11 | T10; T3 (phase 1) | T10 -> T11 | ✅ |
+| T12 | T4 (phase 1) | cross-phase | ✅ |
+| T13 | None | none | ✅ |
+| T14 | T9, T12 (phase 2) | cross-phase | ✅ |
+| T15 | T14 | T14 -> T15 | ✅ |
+| T16 | None | none | ✅ |
+| T17 | T16; T9 (phase 2) | T16 -> T17 | ✅ |
+| T18 | None | none | ✅ |
+| T19 | T18 | T18 -> T19 | ✅ |
+| T20 | T12 (phase 2) | cross-phase | ✅ |
+| T21 | T20; T2, T6 (phase 1) | T20 -> T21 | ✅ |
+
+## Test Co-location Validation
+
+| Task | Code Layer Created/Modified | Matrix Requires | Task Says | Status |
+| ---- | --------------------------- | --------------- | --------- | ------ |
+| T1 | Schema / migration | integration | integration | ✅ |
+| T2 | Store modules | integration | integration | ✅ |
+| T3 | Store modules | integration | integration | ✅ |
+| T4 | Pure core | unit | unit | ✅ |
+| T5 | Pure core | unit | unit | ✅ |
+| T6 | Pure core | unit | unit | ✅ |
+| T7 | Pure core | unit | unit | ✅ |
+| T8 | Daemon consumer | integration | integration | ✅ |
+| T9 | Daemon IPC | integration | integration | ✅ |
+| T10 | Daemon IPC | integration | integration | ✅ |
+| T11 | Daemon startup | integration | integration | ✅ |
+| T12 | Usage query | integration | integration | ✅ |
+| T13 | Driver spawn | integration | integration | ✅ |
+| T14 | CLI commands | integration | integration | ✅ |
+| T15 | Plugin shell | integration | integration | ✅ |
+| T16 | Open runtime | unit + integration | unit | ✅ (watcher is pure with injected `link` and fake timers) |
+| T17 | Open runtime | unit + integration | integration | ✅ |
+| T18 | Plugin shell | integration | integration | ✅ |
+| T19 | Open runtime | unit + integration | integration | ✅ |
+| T20 | Schema + usage query | integration | integration | ✅ |
+| T21 | CLI commands | integration | integration | ✅ |
diff --git a/.specs/features/run-usage-statusline/spec.md b/.specs/features/run-usage-statusline/spec.md
index b1e2839..8921051 100644
--- a/.specs/features/run-usage-statusline/spec.md
+++ b/.specs/features/run-usage-statusline/spec.md
@@ -1,5 +1,7 @@
# Uso agregado do run na statusline
+> Substituído em parte por `.specs/features/orchestrator-usage/spec.md` (seção "Supersedes"): a linha `open` no store, o formato de `usage --json`, o registro do custo do orquestrador pela statusline e o preço de cached tokens no Codex.
+
## Goal
O CodeDeck SHALL mostrar na statusline do Claude Code o custo local do orquestrador e o custo e os tokens agregados dos workers do run atual. A configuração SHALL pedir atualização em dois segundos, com degradação para atualização orientada a eventos quando a versão do Claude Code não aceitar essa configuração.
From 5edad1acc43b401f256480211586ae0d7893a4e8 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:13:28 -0300
Subject: [PATCH 02/42] fix(pricing): Price Codex cached tokens once
Count cached tokens inside Codex input once and reject inconsistent token totals.
Co-Authored-By: Codex
---
src/core/pricing.ts | 23 +++++++++++++++++++----
tests/pricing.test.ts | 40 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 58 insertions(+), 5 deletions(-)
diff --git a/src/core/pricing.ts b/src/core/pricing.ts
index 7908297..8539d5a 100644
--- a/src/core/pricing.ts
+++ b/src/core/pricing.ts
@@ -69,6 +69,7 @@ export interface ComputeSessionCostInput {
model?: string | null;
usage?: SessionCostUsage;
reportedCost?: number | null;
+ cachedInInput?: boolean;
}
function isFiniteNumber(value: unknown): value is number {
@@ -138,7 +139,20 @@ export function computeSessionCost({
model,
usage,
reportedCost,
+ cachedInInput = false,
}: ComputeSessionCostInput): number | null {
+ const inputTokens = usage?.inputTokens ?? 0;
+ const outputTokens = usage?.outputTokens ?? 0;
+ const cachedTokens = usage?.cachedTokens ?? 0;
+ if (
+ cachedInInput &&
+ isFiniteNumber(inputTokens) &&
+ isFiniteNumber(cachedTokens) &&
+ cachedTokens > inputTokens
+ ) {
+ return null;
+ }
+
if (isFiniteNumber(reportedCost) && reportedCost < 0) return null;
// Zero is a valid reported cost and must win over every table entry.
if (isFiniteNumber(reportedCost) && reportedCost >= 0) return reportedCost;
@@ -146,9 +160,6 @@ export function computeSessionCost({
const price = resolveModelPrice(model);
if (!price || !isUsablePrice(price)) return null;
- const inputTokens = usage?.inputTokens ?? 0;
- const outputTokens = usage?.outputTokens ?? 0;
- const cachedTokens = usage?.cachedTokens ?? 0;
if (
!isValidTokenCount(inputTokens) ||
!isValidTokenCount(outputTokens) ||
@@ -159,8 +170,12 @@ export function computeSessionCost({
const cachedPrice = price.cached ?? price.input;
return (
- inputTokens * price.input +
+ (cachedInInput ? inputTokens - cachedTokens : inputTokens) * price.input +
outputTokens * price.output +
cachedTokens * cachedPrice
) / 1_000_000;
}
+
+export function cachedInInputFor(agent: string | undefined | null): boolean {
+ return agent === "codex";
+}
diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts
index afd7e47..787ba36 100644
--- a/tests/pricing.test.ts
+++ b/tests/pricing.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { computeSessionCost, MODEL_PRICES, resolveModelPrice } from "../src/core/pricing.js";
+import { cachedInInputFor, computeSessionCost, MODEL_PRICES, resolveModelPrice } from "../src/core/pricing.js";
describe("computeSessionCost", () => {
it("uses a valid reported cost, including zero, without recalculating", () => {
@@ -32,6 +32,44 @@ describe("computeSessionCost", () => {
).toBe(price.input);
});
+ it("prices Codex cached tokens once when they are included in input", () => {
+ expect(
+ computeSessionCost({
+ model: "gpt-5.6-luna",
+ usage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 900_000 },
+ cachedInInput: true,
+ }),
+ ).toBe(1);
+ });
+
+ it("uses input price for included cached tokens when the model has no cached price", () => {
+ expect(
+ computeSessionCost({
+ model: "gpt-5.6-luna",
+ usage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 1_000_000 },
+ cachedInInput: true,
+ }),
+ ).toBe(MODEL_PRICES["gpt-5.6-luna"].input);
+ });
+
+ it("rejects cached tokens above input when cached tokens are included in input", () => {
+ expect(
+ computeSessionCost({
+ model: "gpt-5.6-luna",
+ usage: { inputTokens: 1, cachedTokens: 2 },
+ reportedCost: 0.1,
+ cachedInInput: true,
+ }),
+ ).toBeNull();
+ });
+
+ it("identifies only Codex as reporting cached tokens inside input", () => {
+ expect(cachedInInputFor("codex")).toBe(true);
+ for (const agent of ["claude", "antigravity", "omp", "opencode", undefined, null]) {
+ expect(cachedInInputFor(agent)).toBe(false);
+ }
+ });
+
it("uses an explicit cached price when the table provides one", () => {
const price = MODEL_PRICES["gpt-5"];
expect(price.cached).toBeDefined();
From a61f3209675a471890078b747d926a48b544c7ee Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:14:29 -0300
Subject: [PATCH 03/42] feat(usage): Add usage ledger tables
Store source high-water marks, row attributions, and native session links so usage can be reconciled after restart.
Co-Authored-By: Codex
---
src/store/database.ts | 32 +++++++++++++++++
tests/usage-schema.test.ts | 73 ++++++++++++++++++++++++++++++++++++++
2 files changed, 105 insertions(+)
create mode 100644 tests/usage-schema.test.ts
diff --git a/src/store/database.ts b/src/store/database.ts
index aa379ae..29a6349 100644
--- a/src/store/database.ts
+++ b/src/store/database.ts
@@ -94,9 +94,41 @@ export class Database {
active INTEGER NOT NULL DEFAULT 1
);
+ CREATE TABLE IF NOT EXISTS usage_sources (
+ source_key TEXT PRIMARY KEY,
+ cost REAL,
+ input_tokens INTEGER,
+ output_tokens INTEGER,
+ cached_tokens INTEGER,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS usage_attributions (
+ session_id TEXT NOT NULL,
+ source_key TEXT NOT NULL,
+ cost REAL,
+ input_tokens INTEGER NOT NULL DEFAULT 0,
+ output_tokens INTEGER NOT NULL DEFAULT 0,
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (session_id, source_key),
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_key) REFERENCES usage_sources(source_key) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS session_native_links (
+ session_id TEXT NOT NULL,
+ native_id TEXT NOT NULL,
+ linked_at TEXT NOT NULL,
+ reconciled_at TEXT,
+ state TEXT,
+ PRIMARY KEY (session_id, native_id),
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
+ );
+
CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_usage_attributions_source_key ON usage_attributions(source_key);
CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_active_session_path
ON claims(session_id, path_glob) WHERE active = 1;
`);
diff --git a/tests/usage-schema.test.ts b/tests/usage-schema.test.ts
new file mode 100644
index 0000000..7b57fbd
--- /dev/null
+++ b/tests/usage-schema.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Database } from "../src/store/database.js";
+
+function withTempDb(fn: (file: string) => void): void {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usage-schema-"));
+ const file = path.join(dir, "test.db");
+ try {
+ fn(file);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+function tableNames(db: Database): string[] {
+ return (db.getHandle().prepare(
+ `SELECT name FROM sqlite_master WHERE type = 'table'`,
+ ).all() as Array<{ name: string }>).map((row) => row.name);
+}
+
+describe("usage database schema", () => {
+ it("creates the usage tables and source index on a fresh database", () => {
+ withTempDb((file) => {
+ const db = new Database(file);
+ try {
+ expect(tableNames(db)).toEqual(expect.arrayContaining([
+ "usage_sources",
+ "usage_attributions",
+ "session_native_links",
+ ]));
+ const indexes = (db.getHandle().prepare(
+ `PRAGMA index_list(usage_attributions)`,
+ ).all() as Array<{ name: string }>).map((row) => row.name);
+ expect(indexes).toContain("idx_usage_attributions_source_key");
+ } finally {
+ db.close();
+ }
+ });
+ });
+
+ it("migrates a database without usage tables and remains idempotent", () => {
+ withTempDb((file) => {
+ const original = new Database(file);
+ original.getHandle().exec(`
+ DROP TABLE session_native_links;
+ DROP TABLE usage_attributions;
+ DROP TABLE usage_sources;
+ `);
+ original.close();
+
+ const migrated = new Database(file);
+ try {
+ expect(tableNames(migrated)).toEqual(expect.arrayContaining([
+ "usage_sources",
+ "usage_attributions",
+ "session_native_links",
+ ]));
+ } finally {
+ migrated.close();
+ }
+
+ const reopened = new Database(file);
+ expect(tableNames(reopened)).toEqual(expect.arrayContaining([
+ "usage_sources",
+ "usage_attributions",
+ "session_native_links",
+ ]));
+ reopened.close();
+ });
+ });
+});
From 508942c58cf2d113bb2ff368fb016da6511afb66 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:15:22 -0300
Subject: [PATCH 04/42] feat(usage): Split run totals by origin
Keep worker metrics separate from orchestrator usage while exposing a combined cost.
Co-Authored-By: Codex
---
src/core/run-usage.ts | 88 +++++++++++++++++++++++++++----
tests/usage.test.ts | 119 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 198 insertions(+), 9 deletions(-)
diff --git a/src/core/run-usage.ts b/src/core/run-usage.ts
index d735b81..cbfc0b5 100644
--- a/src/core/run-usage.ts
+++ b/src/core/run-usage.ts
@@ -1,6 +1,17 @@
-import { computeSessionCost } from "./pricing.js";
+import { cachedInInputFor, computeSessionCost } from "./pricing.js";
import { isActiveStatus, type Session } from "./session.js";
+export interface RunAttribution {
+ sessionId: string;
+ sourceKey: string;
+ cost: number | null;
+}
+
+export interface RunLinkState {
+ sessionId: string;
+ state: string | null;
+}
+
export interface RunUsageSummary {
runId: string;
inputTokens: number;
@@ -11,6 +22,15 @@ export interface RunUsageSummary {
activeSessionCount: number;
costComplete: boolean;
sessionsWithoutCost: number;
+ orchestrator: {
+ costUsd: number;
+ costComplete: boolean;
+ inputTokens: number;
+ outputTokens: number;
+ cachedTokens: number;
+ sources: Array<{ nativeId: string; costUsd: number }>;
+ };
+ total: { costUsd: number };
}
/**
@@ -20,30 +40,49 @@ export interface RunUsageSummary {
export function aggregateRunUsage(
runId: string,
sessions: readonly Session[],
+ attributions: readonly RunAttribution[] = [],
+ linkStates: readonly RunLinkState[] = [],
): RunUsageSummary {
const uniqueSessions = new Map();
for (const session of sessions) uniqueSessions.set(session.id, session);
+ const orchestratorSessions = new Set(
+ [...uniqueSessions.values()]
+ .filter((session) => session.origin === "open")
+ .map((session) => session.id),
+ );
const summary: RunUsageSummary = {
runId,
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
- sessionCount: uniqueSessions.size,
- activeSessionCount: [...uniqueSessions.values()].filter((session) => isActiveStatus(session.status)).length,
+ sessionCount: 0,
+ activeSessionCount: 0,
costComplete: true,
sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0 },
};
for (const session of uniqueSessions.values()) {
+ const isOrchestrator = session.origin === "open";
const usage = session.usage;
- summary.inputTokens += usage?.inputTokens ?? 0;
- summary.outputTokens += usage?.outputTokens ?? 0;
- summary.cachedTokens += usage?.cachedTokens ?? 0;
+ const target = isOrchestrator ? summary.orchestrator : summary;
+ target.inputTokens += usage?.inputTokens ?? 0;
+ target.outputTokens += usage?.outputTokens ?? 0;
+ target.cachedTokens += usage?.cachedTokens ?? 0;
const costUsd = computeSessionCost({
model: session.model,
+ cachedInInput: cachedInInputFor(session.agent),
reportedCost: usage?.cost,
usage: {
inputTokens: usage?.inputTokens,
@@ -52,12 +91,43 @@ export function aggregateRunUsage(
},
});
if (costUsd === null) {
- summary.costComplete = false;
- summary.sessionsWithoutCost += 1;
+ if (isOrchestrator) {
+ summary.orchestrator.costComplete = false;
+ } else {
+ summary.costComplete = false;
+ summary.sessionsWithoutCost += 1;
+ }
} else {
- summary.costUsd += costUsd;
+ target.costUsd += costUsd;
+ }
+
+ if (!isOrchestrator) {
+ summary.sessionCount++;
+ if (isActiveStatus(session.status)) summary.activeSessionCount++;
}
}
+ for (const linkState of linkStates) {
+ if (
+ orchestratorSessions.has(linkState.sessionId) &&
+ (linkState.state === "missing" || linkState.state === "no-price")
+ ) {
+ summary.orchestrator.costComplete = false;
+ }
+ }
+
+ const sourceCosts = new Map();
+ for (const attribution of attributions) {
+ if (!orchestratorSessions.has(attribution.sessionId)) continue;
+ const cost = attribution.cost ?? 0;
+ sourceCosts.set(attribution.sourceKey, (sourceCosts.get(attribution.sourceKey) ?? 0) + cost);
+ }
+
+ summary.orchestrator.sources = [...sourceCosts].map(([sourceKey, costUsd]) => ({
+ nativeId: sourceKey.replace(/^claude-open:/, ""),
+ costUsd,
+ }));
+ summary.total.costUsd = summary.costUsd + summary.orchestrator.costUsd;
+
return summary;
}
diff --git a/tests/usage.test.ts b/tests/usage.test.ts
index 78346ab..76d1b51 100644
--- a/tests/usage.test.ts
+++ b/tests/usage.test.ts
@@ -36,6 +36,15 @@ describe("aggregateRunUsage", () => {
activeSessionCount: 0,
costComplete: true,
sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0.5 },
});
});
@@ -61,6 +70,15 @@ describe("aggregateRunUsage", () => {
activeSessionCount: 0,
costComplete: false,
sessionsWithoutCost: 1,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0.42 },
});
});
@@ -129,6 +147,107 @@ describe("aggregateRunUsage", () => {
activeSessionCount: 0,
costComplete: true,
sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0 },
});
});
+
+ it("splits worker and orchestrator usage and groups source costs", () => {
+ expect(
+ aggregateRunUsage(
+ "r1",
+ [
+ makeSession("open", {
+ origin: "open",
+ agent: "claude",
+ usage: { inputTokens: 10, outputTokens: 20, cachedTokens: 3, cost: 3.8 },
+ }),
+ makeSession("worker-1", { origin: null, usage: { cost: 0.3 } }),
+ makeSession("worker-2", { usage: { cost: 0.2 } }),
+ ],
+ [
+ { sessionId: "open", sourceKey: "claude-open:X", cost: 3 },
+ { sessionId: "open", sourceKey: "claude-open:Y", cost: 0.5 },
+ { sessionId: "open", sourceKey: "claude-open:Y", cost: 0.3 },
+ { sessionId: "worker-1", sourceKey: "codex:worker", cost: 10 },
+ ],
+ ),
+ ).toEqual({
+ runId: "r1",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0.5,
+ sessionCount: 2,
+ activeSessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 3.8,
+ costComplete: true,
+ inputTokens: 10,
+ outputTokens: 20,
+ cachedTokens: 3,
+ sources: [
+ { nativeId: "X", costUsd: 3 },
+ { nativeId: "Y", costUsd: 0.8 },
+ ],
+ },
+ total: { costUsd: 4.3 },
+ });
+ });
+
+ it("keeps an open-only run complete for workers when orchestrator links are incomplete", () => {
+ for (const state of ["missing", "no-price"]) {
+ const summary = aggregateRunUsage(
+ "open-only",
+ [makeSession("open", { origin: "open", usage: { cost: 0.25 } })],
+ [],
+ [{ sessionId: "open", state }],
+ );
+
+ expect(summary).toMatchObject({
+ costUsd: 0,
+ sessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: { costUsd: 0.25, costComplete: false },
+ total: { costUsd: 0.25 },
+ });
+ }
+ });
+
+ it("marks an open row without a calculable cost incomplete", () => {
+ const summary = aggregateRunUsage("open-only", [
+ makeSession("open", { origin: "open", model: "unknown-model" }),
+ ]);
+
+ expect(summary).toMatchObject({
+ costUsd: 0,
+ sessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: { costComplete: false },
+ });
+ });
+
+ it("prices Codex cached tokens once", () => {
+ const summary = aggregateRunUsage("codex-run", [
+ makeSession("codex", {
+ model: "gpt-5.6-luna",
+ usage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 900_000 },
+ }),
+ ]);
+
+ expect(summary.costUsd).toBe(1);
+ expect(summary.total.costUsd).toBe(1);
+ expect(summary.costComplete).toBe(true);
+ });
});
From e46f6cb2a1efb53fc7381d78637f3734897213a6 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:17:10 -0300
Subject: [PATCH 05/42] feat(usage): Add origin buckets to usage queries
Expose orchestrator and worker totals so usage views include open sessions.
Co-Authored-By: Codex
---
src/cli/commands/usage.ts | 1 +
src/daemon/protocol.ts | 1 +
src/store/sessions.ts | 11 +++++++++--
tests/usage-query.test.ts | 34 ++++++++++++++++++++++++++++++++++
4 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts
index 841ae92..0c0e83c 100644
--- a/src/cli/commands/usage.ts
+++ b/src/cli/commands/usage.ts
@@ -63,6 +63,7 @@ export async function fetchUsageQuery(params: UsageQueryParams): Promise= ? AND created_at <= ?
ORDER BY created_at ASC
@@ -331,6 +331,7 @@ export class SessionStore {
usage_output_tokens: number | null;
usage_cached_tokens: number | null;
usage_cost: number | null;
+ origin: string | null;
}>;
const totals: UsageTotals = {
@@ -352,6 +353,7 @@ export class SessionStore {
const byModelMap = new Map();
const byAgentMap = new Map();
const byRunMap = new Map();
+ const byOriginMap = new Map();
const repoFilter = params.repository?.toLowerCase();
const modelFilter = params.model?.toLowerCase();
@@ -375,6 +377,7 @@ export class SessionStore {
const cost = computeSessionCost({
model: row.model,
+ cachedInInput: cachedInInputFor(row.agent),
reportedCost: row.usage_cost,
usage: { inputTokens, outputTokens, cachedTokens },
});
@@ -443,6 +446,8 @@ export class SessionStore {
if (row.run_id) {
accumulate(byRunMap, row.run_id, row.name ? `${row.name} (${row.run_id.slice(0, 8)})` : row.run_id.slice(0, 8));
}
+
+ accumulate(byOriginMap, row.origin === "open" ? "orchestrator" : "worker");
}
const sortDescending = (a: UsageMetricBucket, b: UsageMetricBucket) => {
@@ -455,6 +460,7 @@ export class SessionStore {
const byModel = [...byModelMap.values()].sort(sortDescending);
const byAgent = [...byAgentMap.values()].sort(sortDescending);
const byRun = [...byRunMap.values()].sort(sortDescending);
+ const byOrigin = [...byOriginMap.values()].sort(sortDescending);
return {
range: {
@@ -468,6 +474,7 @@ export class SessionStore {
byModel,
byAgent,
byRun,
+ byOrigin,
};
}
}
diff --git a/tests/usage-query.test.ts b/tests/usage-query.test.ts
index ccbf22a..5e559d6 100644
--- a/tests/usage-query.test.ts
+++ b/tests/usage-query.test.ts
@@ -46,6 +46,7 @@ describe("SessionStore.queryUsage", () => {
expect(result.totals.costComplete).toBe(true);
expect(result.byDay).toEqual([]);
expect(result.byRepository).toEqual([]);
+ expect(result.byOrigin).toEqual([]);
});
it("aggregates tokens, calculated costs, and reported costs accurately", () => {
@@ -90,6 +91,39 @@ describe("SessionStore.queryUsage", () => {
expect(result.byRepository[0].sessionCount).toBe(2);
});
+ it("includes orchestrator costs and groups open, NULL, and other origins", () => {
+ store.create(
+ makeSession("open", {
+ origin: "open",
+ agent: "claude",
+ usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 10, cost: 0.7 },
+ }),
+ );
+ store.create(
+ makeSession("codex", {
+ origin: null,
+ model: "gpt-5.6-luna",
+ usage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 900_000 },
+ }),
+ );
+ store.create(
+ makeSession("other-worker", {
+ origin: "run",
+ agent: "omp",
+ usage: { cost: 0.25 },
+ }),
+ );
+
+ const result = store.queryUsage({ period: "all" });
+ const orchestrator = result.byOrigin.find((bucket) => bucket.key === "orchestrator");
+ const worker = result.byOrigin.find((bucket) => bucket.key === "worker");
+
+ expect(result.totals.costUsd).toBeCloseTo(1.95, 5);
+ expect(result.totals.sessionCount).toBe(3);
+ expect(orchestrator).toMatchObject({ sessionCount: 1, costUsd: 0.7 });
+ expect(worker).toMatchObject({ sessionCount: 2, costUsd: 1.25 });
+ });
+
it("marks costComplete as false when encountering unpriced models without reported cost", () => {
store.create(
makeSession("s-unknown", {
From 05425ff02d897cf07031fc6547fddf59769344c5 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:18:45 -0300
Subject: [PATCH 06/42] feat(usage): Add worker source keys
Give each harness a stable source key for usage attribution.
Co-Authored-By: Codex
---
src/core/usage-source.ts | 22 ++++++++++++++++++++++
tests/usage-source.test.ts | 32 ++++++++++++++++++++++++++++++++
2 files changed, 54 insertions(+)
create mode 100644 src/core/usage-source.ts
create mode 100644 tests/usage-source.test.ts
diff --git a/src/core/usage-source.ts b/src/core/usage-source.ts
new file mode 100644
index 0000000..88266ee
--- /dev/null
+++ b/src/core/usage-source.ts
@@ -0,0 +1,22 @@
+import type { Session } from "./session.js";
+
+export function workerSourceKey(
+ session: Pick,
+ processOrdinal: number,
+): string | undefined {
+ switch (session.agent) {
+ case "claude":
+ return `claude:${session.nativeSessionId ?? session.id}#${processOrdinal}`;
+ case "codex":
+ return `codex:${session.nativeSessionId ?? session.id}`;
+ case "antigravity":
+ case "omp":
+ return `session:${session.id}`;
+ case "opencode":
+ break;
+ }
+}
+
+export function openSourceKey(nativeId: string): string {
+ return `claude-open:${nativeId}`;
+}
diff --git a/tests/usage-source.test.ts b/tests/usage-source.test.ts
new file mode 100644
index 0000000..4fa9534
--- /dev/null
+++ b/tests/usage-source.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { openSourceKey, workerSourceKey } from "../src/core/usage-source.js";
+
+describe("usage source keys", () => {
+ it("keeps Claude processes distinct by ordinal", () => {
+ const session = { id: "s1", agent: "claude" as const, nativeSessionId: "n" };
+
+ expect(workerSourceKey(session, 1)).toBe("claude:n#1");
+ expect(workerSourceKey(session, 2)).toBe("claude:n#2");
+ });
+
+ it("uses the session id when a native id is not available", () => {
+ expect(workerSourceKey({ id: "s1", agent: "claude" }, 1)).toBe("claude:s1#1");
+ expect(workerSourceKey({ id: "s2", agent: "codex" }, 3)).toBe("codex:s2");
+ });
+
+ it("uses a Codex native id and session keys for Antigravity and OMP", () => {
+ expect(workerSourceKey({ id: "s1", agent: "codex", nativeSessionId: "thread" }, 1)).toBe(
+ "codex:thread",
+ );
+ expect(workerSourceKey({ id: "s2", agent: "antigravity" }, 1)).toBe("session:s2");
+ expect(workerSourceKey({ id: "s3", agent: "omp" }, 1)).toBe("session:s3");
+ });
+
+ it("does not assign opencode a source key", () => {
+ expect(workerSourceKey({ id: "s1", agent: "opencode" }, 1)).toBeUndefined();
+ });
+
+ it("prefixes open session sources", () => {
+ expect(openSourceKey("native-1")).toBe("claude-open:native-1");
+ });
+});
From 4af261dde3cc8e238c4b49ee04ec921499ef42ce Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:17:15 -0300
Subject: [PATCH 07/42] feat(usage): Implement the high-water usage ledger
Track source marks and per-session deltas so cumulative reports can move across rows without double counting.
Co-Authored-By: Codex
---
src/store/usage-ledger.ts | 227 +++++++++++++++++++++++++
tests/usage-ledger.test.ts | 331 +++++++++++++++++++++++++++++++++++++
2 files changed, 558 insertions(+)
create mode 100644 src/store/usage-ledger.ts
create mode 100644 tests/usage-ledger.test.ts
diff --git a/src/store/usage-ledger.ts b/src/store/usage-ledger.ts
new file mode 100644
index 0000000..8df9e9e
--- /dev/null
+++ b/src/store/usage-ledger.ts
@@ -0,0 +1,227 @@
+import type { DatabaseSync } from "node:sqlite";
+
+export interface UsageObservation {
+ cost?: number;
+ inputTokens?: number;
+ outputTokens?: number;
+ cachedTokens?: number;
+ model?: string;
+}
+
+export interface UsageAttribution {
+ sessionId: string;
+ sourceKey: string;
+ cost: number | null;
+ inputTokens: number;
+ outputTokens: number;
+ cachedTokens: number;
+}
+
+interface UsageSourceRow {
+ source_key: string;
+ cost: number | null;
+ input_tokens: number | null;
+ output_tokens: number | null;
+ cached_tokens: number | null;
+}
+
+interface UsageAttributionRow {
+ session_id: string;
+ source_key: string;
+ cost: number | null;
+ input_tokens: number;
+ output_tokens: number;
+ cached_tokens: number;
+}
+
+interface SessionUsageRow {
+ model: string | null;
+ usage_input_tokens: number | null;
+ usage_output_tokens: number | null;
+ usage_cached_tokens: number | null;
+ usage_cost: number | null;
+}
+
+const fields = [
+ { observation: "cost", source: "cost", attribution: "cost" },
+ { observation: "inputTokens", source: "input_tokens", attribution: "input_tokens" },
+ { observation: "outputTokens", source: "output_tokens", attribution: "output_tokens" },
+ { observation: "cachedTokens", source: "cached_tokens", attribution: "cached_tokens" },
+] as const;
+
+export class UsageLedger {
+ constructor(private db: DatabaseSync) {}
+
+ observe(sessionId: string, sourceKey: string, obs: UsageObservation): boolean {
+ const session = this.db.prepare(`
+ SELECT model, usage_input_tokens, usage_output_tokens,
+ usage_cached_tokens, usage_cost
+ FROM sessions WHERE id = ?
+ `).get(sessionId) as SessionUsageRow | undefined;
+ if (!session) throw new Error(`Session ${sessionId} not found`);
+
+ this.seedSessionUsage(sessionId, session);
+
+ const now = new Date().toISOString();
+ this.db.prepare(`
+ INSERT INTO usage_sources (source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at)
+ VALUES (?, NULL, NULL, NULL, NULL, ?)
+ ON CONFLICT(source_key) DO NOTHING
+ `).run(sourceKey, now);
+
+ const source = this.db.prepare(
+ `SELECT * FROM usage_sources WHERE source_key = ?`,
+ ).get(sourceKey) as unknown as UsageSourceRow;
+ const deltas: Record = {};
+ const sourceUpdates: string[] = [];
+ const sourceValues: Array = [];
+
+ for (const field of fields) {
+ const observed = obs[field.observation];
+ if (observed === undefined) continue;
+ const mark = source[field.source] ?? 0;
+ const delta = observed - mark;
+ if (delta <= 0) continue;
+ deltas[field.attribution] = delta;
+ sourceUpdates.push(`${field.source} = ?`);
+ sourceValues.push(observed);
+ }
+
+ if (sourceUpdates.length > 0) {
+ sourceValues.push(now, sourceKey);
+ this.db.prepare(`
+ UPDATE usage_sources SET ${sourceUpdates.join(", ")}, updated_at = ?
+ WHERE source_key = ?
+ `).run(...sourceValues);
+ this.addAttribution(sessionId, sourceKey, deltas);
+ this.materialize(sessionId);
+ }
+
+ if (obs.model !== undefined && session.model === null) {
+ this.db.prepare(`UPDATE sessions SET model = ? WHERE id = ? AND model IS NULL`)
+ .run(obs.model, sessionId);
+ }
+
+ return sourceUpdates.length > 0;
+ }
+
+ attributionsFor(sessionIds: readonly string[]): UsageAttribution[] {
+ if (sessionIds.length === 0) return [];
+ const placeholders = sessionIds.map(() => "?").join(", ");
+ const rows = this.db.prepare(`
+ SELECT session_id, source_key, cost, input_tokens, output_tokens, cached_tokens
+ FROM usage_attributions
+ WHERE session_id IN (${placeholders})
+ ORDER BY session_id, source_key
+ `).all(...sessionIds) as unknown as UsageAttributionRow[];
+ return rows.map((row) => ({
+ sessionId: row.session_id,
+ sourceKey: row.source_key,
+ cost: row.cost,
+ inputTokens: row.input_tokens,
+ outputTokens: row.output_tokens,
+ cachedTokens: row.cached_tokens,
+ }));
+ }
+
+ hasSource(sourceKey: string): boolean {
+ return this.db.prepare(
+ `SELECT 1 FROM usage_sources WHERE source_key = ?`,
+ ).get(sourceKey) !== undefined;
+ }
+
+ private seedSessionUsage(sessionId: string, session: SessionUsageRow): void {
+ const hasUsage = session.usage_input_tokens !== null ||
+ session.usage_output_tokens !== null ||
+ session.usage_cached_tokens !== null ||
+ session.usage_cost !== null;
+ if (!hasUsage) return;
+
+ const attribution = this.db.prepare(
+ `SELECT 1 FROM usage_attributions WHERE session_id = ? LIMIT 1`,
+ ).get(sessionId);
+ if (attribution) return;
+
+ const sourceKey = `seed:${sessionId}`;
+ const now = new Date().toISOString();
+ this.db.prepare(`
+ INSERT INTO usage_sources (source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(source_key) DO UPDATE SET
+ cost = excluded.cost,
+ input_tokens = excluded.input_tokens,
+ output_tokens = excluded.output_tokens,
+ cached_tokens = excluded.cached_tokens,
+ updated_at = excluded.updated_at
+ `).run(
+ sourceKey,
+ session.usage_cost,
+ session.usage_input_tokens,
+ session.usage_output_tokens,
+ session.usage_cached_tokens,
+ now,
+ );
+ this.db.prepare(`
+ INSERT INTO usage_attributions (
+ session_id, source_key, cost, input_tokens, output_tokens, cached_tokens
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ sessionId,
+ sourceKey,
+ session.usage_cost,
+ session.usage_input_tokens ?? 0,
+ session.usage_output_tokens ?? 0,
+ session.usage_cached_tokens ?? 0,
+ );
+ }
+
+ private addAttribution(
+ sessionId: string,
+ sourceKey: string,
+ deltas: Record,
+ ): void {
+ const current = this.db.prepare(`
+ SELECT * FROM usage_attributions WHERE session_id = ? AND source_key = ?
+ `).get(sessionId, sourceKey) as UsageAttributionRow | undefined;
+ const cost = deltas.cost === undefined
+ ? current?.cost ?? null
+ : (current?.cost ?? 0) + deltas.cost;
+ const inputTokens = (current?.input_tokens ?? 0) + (deltas.input_tokens ?? 0);
+ const outputTokens = (current?.output_tokens ?? 0) + (deltas.output_tokens ?? 0);
+ const cachedTokens = (current?.cached_tokens ?? 0) + (deltas.cached_tokens ?? 0);
+
+ this.db.prepare(`
+ INSERT INTO usage_attributions (
+ session_id, source_key, cost, input_tokens, output_tokens, cached_tokens
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(session_id, source_key) DO UPDATE SET
+ cost = excluded.cost,
+ input_tokens = excluded.input_tokens,
+ output_tokens = excluded.output_tokens,
+ cached_tokens = excluded.cached_tokens
+ `).run(sessionId, sourceKey, cost, inputTokens, outputTokens, cachedTokens);
+ }
+
+ private materialize(sessionId: string): void {
+ const usage = this.db.prepare(`
+ SELECT SUM(cost) AS cost, SUM(input_tokens) AS input_tokens,
+ SUM(output_tokens) AS output_tokens, SUM(cached_tokens) AS cached_tokens
+ FROM usage_attributions WHERE session_id = ?
+ `).get(sessionId) as {
+ cost: number | null;
+ input_tokens: number | null;
+ output_tokens: number | null;
+ cached_tokens: number | null;
+ };
+ this.db.prepare(`
+ UPDATE sessions SET usage_input_tokens = ?, usage_output_tokens = ?,
+ usage_cached_tokens = ?, usage_cost = ? WHERE id = ?
+ `).run(
+ usage.input_tokens ?? 0,
+ usage.output_tokens ?? 0,
+ usage.cached_tokens ?? 0,
+ usage.cost,
+ sessionId,
+ );
+ }
+}
diff --git a/tests/usage-ledger.test.ts b/tests/usage-ledger.test.ts
new file mode 100644
index 0000000..1f3e4f0
--- /dev/null
+++ b/tests/usage-ledger.test.ts
@@ -0,0 +1,331 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Database } from "../src/store/database.js";
+import { UsageLedger } from "../src/store/usage-ledger.js";
+import { SessionStore } from "../src/store/sessions.js";
+import type { Session } from "../src/core/session.js";
+
+interface UsageRow {
+ usage_input_tokens: number | null;
+ usage_output_tokens: number | null;
+ usage_cached_tokens: number | null;
+ usage_cost: number | null;
+}
+
+interface SourceRow {
+ cost: number | null;
+ input_tokens: number | null;
+ output_tokens: number | null;
+ cached_tokens: number | null;
+}
+
+function withLedger(fn: (db: Database, ledger: UsageLedger, sessions: SessionStore) => void): void {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usage-ledger-"));
+ const db = new Database(path.join(dir, "test.db"));
+ const handle = db.getHandle();
+ const ledger = new UsageLedger(handle);
+ const sessions = new SessionStore(handle);
+ try {
+ fn(db, ledger, sessions);
+ } finally {
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+function makeSession(id: string, cwd: string, usage?: Session["usage"]): Session {
+ const now = new Date("2026-09-22T12:00:00.000Z");
+ return {
+ id,
+ agent: "claude",
+ status: "working",
+ cwd,
+ usage,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+function usageFor(db: Database, sessionId: string): UsageRow {
+ return db.getHandle().prepare(`
+ SELECT usage_input_tokens, usage_output_tokens, usage_cached_tokens, usage_cost
+ FROM sessions WHERE id = ?
+ `).get(sessionId) as UsageRow;
+}
+
+function sourceFor(db: Database, sourceKey: string): SourceRow {
+ return db.getHandle().prepare(`
+ SELECT cost, input_tokens, output_tokens, cached_tokens
+ FROM usage_sources WHERE source_key = ?
+ `).get(sourceKey) as SourceRow;
+}
+
+describe("UsageLedger", () => {
+ it("replays the six high-water steps and keeps the source total across rows", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("A", "/tmp"));
+ sessions.create(makeSession("B", "/tmp"));
+
+ expect(ledger.observe("A", "X", {
+ cost: 4,
+ inputTokens: 100,
+ outputTokens: 10,
+ cachedTokens: 5,
+ })).toBe(true);
+ expect(sourceFor(db, "X")).toEqual({
+ cost: 4,
+ input_tokens: 100,
+ output_tokens: 10,
+ cached_tokens: 5,
+ });
+ expect(ledger.attributionsFor(["A", "B"])).toEqual([{
+ sessionId: "A",
+ sourceKey: "X",
+ cost: 4,
+ inputTokens: 100,
+ outputTokens: 10,
+ cachedTokens: 5,
+ }]);
+ expect(usageFor(db, "A")).toEqual({
+ usage_input_tokens: 100,
+ usage_output_tokens: 10,
+ usage_cached_tokens: 5,
+ usage_cost: 4,
+ });
+
+ const beforeLower = {
+ source: sourceFor(db, "X"),
+ attributions: ledger.attributionsFor(["A", "B"]),
+ usageA: usageFor(db, "A"),
+ usageB: usageFor(db, "B"),
+ };
+ expect(ledger.observe("A", "X", {
+ cost: 3.5,
+ inputTokens: 99,
+ outputTokens: 9,
+ cachedTokens: 4,
+ })).toBe(false);
+ expect({
+ source: sourceFor(db, "X"),
+ attributions: ledger.attributionsFor(["A", "B"]),
+ usageA: usageFor(db, "A"),
+ usageB: usageFor(db, "B"),
+ }).toEqual(beforeLower);
+
+ expect(ledger.observe("A", "X", {
+ cost: 10,
+ inputTokens: 500,
+ outputTokens: 50,
+ cachedTokens: 25,
+ })).toBe(true);
+ expect(ledger.attributionsFor(["A"])).toEqual([{
+ sessionId: "A",
+ sourceKey: "X",
+ cost: 10,
+ inputTokens: 500,
+ outputTokens: 50,
+ cachedTokens: 25,
+ }]);
+ expect(usageFor(db, "A")).toEqual({
+ usage_input_tokens: 500,
+ usage_output_tokens: 50,
+ usage_cached_tokens: 25,
+ usage_cost: 10,
+ });
+
+ expect(ledger.observe("B", "X", {
+ cost: 10,
+ inputTokens: 500,
+ outputTokens: 50,
+ cachedTokens: 25,
+ })).toBe(false);
+ expect(ledger.attributionsFor(["B"])).toEqual([]);
+ expect(usageFor(db, "B")).toEqual({
+ usage_input_tokens: null,
+ usage_output_tokens: null,
+ usage_cached_tokens: null,
+ usage_cost: null,
+ });
+
+ expect(ledger.observe("B", "X", {
+ cost: 12,
+ inputTokens: 600,
+ outputTokens: 60,
+ cachedTokens: 30,
+ })).toBe(true);
+ expect(ledger.attributionsFor(["A", "B"])).toEqual([
+ {
+ sessionId: "A",
+ sourceKey: "X",
+ cost: 10,
+ inputTokens: 500,
+ outputTokens: 50,
+ cachedTokens: 25,
+ },
+ {
+ sessionId: "B",
+ sourceKey: "X",
+ cost: 2,
+ inputTokens: 100,
+ outputTokens: 10,
+ cachedTokens: 5,
+ },
+ ]);
+ expect(usageFor(db, "B")).toEqual({
+ usage_input_tokens: 100,
+ usage_output_tokens: 10,
+ usage_cached_tokens: 5,
+ usage_cost: 2,
+ });
+
+ expect(ledger.observe("B", "X", {
+ cost: 15,
+ inputTokens: 750,
+ outputTokens: 75,
+ cachedTokens: 35,
+ })).toBe(true);
+ expect(sourceFor(db, "X")).toEqual({
+ cost: 15,
+ input_tokens: 750,
+ output_tokens: 75,
+ cached_tokens: 35,
+ });
+ expect(ledger.attributionsFor(["A", "B"])).toEqual([
+ {
+ sessionId: "A",
+ sourceKey: "X",
+ cost: 10,
+ inputTokens: 500,
+ outputTokens: 50,
+ cachedTokens: 25,
+ },
+ {
+ sessionId: "B",
+ sourceKey: "X",
+ cost: 5,
+ inputTokens: 250,
+ outputTokens: 25,
+ cachedTokens: 10,
+ },
+ ]);
+ expect(usageFor(db, "A")).toEqual({
+ usage_input_tokens: 500,
+ usage_output_tokens: 50,
+ usage_cached_tokens: 25,
+ usage_cost: 10,
+ });
+ expect(usageFor(db, "B")).toEqual({
+ usage_input_tokens: 250,
+ usage_output_tokens: 25,
+ usage_cached_tokens: 10,
+ usage_cost: 5,
+ });
+
+ const total = ledger.attributionsFor(["A", "B"])
+ .reduce((sum, attribution) => sum + (attribution.cost ?? 0), 0);
+ expect(total).toBe(sourceFor(db, "X").cost);
+ expect(ledger.hasSource("X")).toBe(true);
+ expect(ledger.hasSource("unknown")).toBe(false);
+ });
+ });
+
+ it("moves present fields independently and leaves absent fields alone", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("partial", "/tmp"));
+
+ expect(ledger.observe("partial", "partial-source", { inputTokens: 5 })).toBe(true);
+ expect(sourceFor(db, "partial-source")).toEqual({
+ cost: null,
+ input_tokens: 5,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["partial"])).toEqual([{
+ sessionId: "partial",
+ sourceKey: "partial-source",
+ cost: null,
+ inputTokens: 5,
+ outputTokens: 0,
+ cachedTokens: 0,
+ }]);
+ expect(usageFor(db, "partial")).toEqual({
+ usage_input_tokens: 5,
+ usage_output_tokens: 0,
+ usage_cached_tokens: 0,
+ usage_cost: null,
+ });
+
+ expect(ledger.observe("partial", "partial-source", { cost: 2 })).toBe(true);
+ expect(sourceFor(db, "partial-source")).toEqual({
+ cost: 2,
+ input_tokens: 5,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["partial"])).toEqual([{
+ sessionId: "partial",
+ sourceKey: "partial-source",
+ cost: 2,
+ inputTokens: 5,
+ outputTokens: 0,
+ cachedTokens: 0,
+ }]);
+ expect(usageFor(db, "partial")).toEqual({
+ usage_input_tokens: 5,
+ usage_output_tokens: 0,
+ usage_cached_tokens: 0,
+ usage_cost: 2,
+ });
+ });
+ });
+
+ it("seeds existing usage once before applying a new source", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("seeded", "/tmp", {
+ inputTokens: 12,
+ outputTokens: 3,
+ cachedTokens: 1,
+ cost: 0.75,
+ }));
+
+ expect(ledger.observe("seeded", "next-process", {
+ inputTokens: 2,
+ outputTokens: 4,
+ model: "claude-opus-4-1",
+ })).toBe(true);
+ expect(sourceFor(db, "seed:seeded")).toEqual({
+ cost: 0.75,
+ input_tokens: 12,
+ output_tokens: 3,
+ cached_tokens: 1,
+ });
+ expect(ledger.attributionsFor(["seeded"])).toEqual([
+ {
+ sessionId: "seeded",
+ sourceKey: "next-process",
+ cost: null,
+ inputTokens: 2,
+ outputTokens: 4,
+ cachedTokens: 0,
+ },
+ {
+ sessionId: "seeded",
+ sourceKey: "seed:seeded",
+ cost: 0.75,
+ inputTokens: 12,
+ outputTokens: 3,
+ cachedTokens: 1,
+ },
+ ]);
+ expect(usageFor(db, "seeded")).toEqual({
+ usage_input_tokens: 14,
+ usage_output_tokens: 7,
+ usage_cached_tokens: 1,
+ usage_cost: 0.75,
+ });
+ expect(sessions.get("seeded")?.model).toBe("claude-opus-4-1");
+ });
+ });
+});
From 9c3f1c3b21331e34f0a489e42e9d0fcacc8c7265 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:19:27 -0300
Subject: [PATCH 08/42] feat(usage): Add the native session link store
Keep every native transcript id linked to its open session and expose unreconciled links for recovery.
Co-Authored-By: Codex
---
src/store/native-links.ts | 110 +++++++++++++++++++++++++++++++++++++
tests/native-links.test.ts | 110 +++++++++++++++++++++++++++++++++++++
2 files changed, 220 insertions(+)
create mode 100644 src/store/native-links.ts
create mode 100644 tests/native-links.test.ts
diff --git a/src/store/native-links.ts b/src/store/native-links.ts
new file mode 100644
index 0000000..87aa2d7
--- /dev/null
+++ b/src/store/native-links.ts
@@ -0,0 +1,110 @@
+import type { DatabaseSync } from "node:sqlite";
+import type { Session } from "../core/session.js";
+import { SessionStore } from "./sessions.js";
+
+export type ReconcileState = "cost-state" | "tokens" | "no-price" | "missing";
+
+export interface NativeLink {
+ sessionId: string;
+ nativeId: string;
+ linkedAt: string;
+ reconciledAt: string | null;
+ state: ReconcileState | null;
+}
+
+interface NativeLinkRow {
+ session_id: string;
+ native_id: string;
+ linked_at: string;
+ reconciled_at: string | null;
+ state: ReconcileState | null;
+}
+
+interface StaleCandidate {
+ id: string;
+ status: Session["status"];
+}
+
+function toNativeLink(row: NativeLinkRow): NativeLink {
+ return {
+ sessionId: row.session_id,
+ nativeId: row.native_id,
+ linkedAt: row.linked_at,
+ reconciledAt: row.reconciled_at,
+ state: row.state,
+ };
+}
+
+export class NativeLinkStore {
+ constructor(private db: DatabaseSync) {}
+
+ link(sessionId: string, nativeId: string): { created: boolean; previous: string[] } {
+ const previous = (this.db.prepare(`
+ SELECT native_id FROM session_native_links
+ WHERE session_id = ? AND native_id <> ? AND reconciled_at IS NULL
+ ORDER BY linked_at, native_id
+ `).all(sessionId, nativeId) as Array<{ native_id: string }>).map((row) => row.native_id);
+
+ const result = this.db.prepare(`
+ INSERT INTO session_native_links (session_id, native_id, linked_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(session_id, native_id) DO NOTHING
+ `).run(sessionId, nativeId, new Date().toISOString());
+
+ return { created: Number(result.changes) > 0, previous };
+ }
+
+ unreconciled(sessionId: string): NativeLink[] {
+ const rows = this.db.prepare(`
+ SELECT session_id, native_id, linked_at, reconciled_at, state
+ FROM session_native_links
+ WHERE session_id = ? AND reconciled_at IS NULL
+ ORDER BY linked_at, native_id
+ `).all(sessionId) as unknown as NativeLinkRow[];
+ return rows.map(toNativeLink);
+ }
+
+ linksFor(sessionIds: readonly string[]): NativeLink[] {
+ if (sessionIds.length === 0) return [];
+ const placeholders = sessionIds.map(() => "?").join(", ");
+ const rows = this.db.prepare(`
+ SELECT session_id, native_id, linked_at, reconciled_at, state
+ FROM session_native_links
+ WHERE session_id IN (${placeholders})
+ ORDER BY session_id, linked_at, native_id
+ `).all(...sessionIds) as unknown as NativeLinkRow[];
+ return rows.map(toNativeLink);
+ }
+
+ markReconciled(sessionId: string, nativeId: string, state: ReconcileState): void {
+ this.db.prepare(`
+ UPDATE session_native_links SET reconciled_at = ?, state = ?
+ WHERE session_id = ? AND native_id = ?
+ `).run(new Date().toISOString(), state, sessionId, nativeId);
+ }
+
+ staleOpenRows(isDead: (session: Session) => boolean): Session[] {
+ const candidates = this.db.prepare(`
+ SELECT DISTINCT sessions.id, sessions.status
+ FROM sessions
+ INNER JOIN session_native_links
+ ON session_native_links.session_id = sessions.id
+ WHERE sessions.origin = 'open'
+ AND session_native_links.reconciled_at IS NULL
+ AND sessions.status IN ('interrupted', 'working')
+ ORDER BY sessions.created_at, sessions.id
+ `).all() as unknown as StaleCandidate[];
+ const sessions = new SessionStore(this.db);
+ const stale: Session[] = [];
+
+ for (const candidate of candidates) {
+ const session = sessions.get(candidate.id);
+ if (!session) continue;
+ if (candidate.status === "interrupted" || (candidate.status === "working" && isDead(session))) {
+ stale.push(session);
+ }
+ }
+
+ return stale;
+ }
+}
diff --git a/tests/native-links.test.ts b/tests/native-links.test.ts
new file mode 100644
index 0000000..050c1af
--- /dev/null
+++ b/tests/native-links.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Database } from "../src/store/database.js";
+import { NativeLinkStore } from "../src/store/native-links.js";
+import { SessionStore } from "../src/store/sessions.js";
+import type { Session } from "../src/core/session.js";
+
+function withLinks(fn: (store: NativeLinkStore, sessions: SessionStore) => void): void {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "native-links-"));
+ const db = new Database(path.join(dir, "test.db"));
+ try {
+ const handle = db.getHandle();
+ fn(new NativeLinkStore(handle), new SessionStore(handle));
+ } finally {
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+function makeSession(
+ id: string,
+ status: Session["status"],
+ origin: Session["origin"] = "open",
+): Session {
+ const now = new Date("2026-09-22T12:00:00.000Z");
+ return {
+ id,
+ agent: "claude",
+ status,
+ origin,
+ cwd: "/tmp",
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+describe("NativeLinkStore", () => {
+ it("keeps multiple native ids and reports only other unreconciled links", () => {
+ withLinks((store, sessions) => {
+ sessions.create(makeSession("run-a", "working"));
+
+ expect(store.link("run-a", "native-a")).toEqual({ created: true, previous: [] });
+ expect(store.link("run-a", "native-b")).toEqual({
+ created: true,
+ previous: ["native-a"],
+ });
+ expect(store.link("run-a", "native-b")).toEqual({
+ created: false,
+ previous: ["native-a"],
+ });
+ expect(store.unreconciled("run-a").map((link) => link.nativeId)).toEqual([
+ "native-a",
+ "native-b",
+ ]);
+ expect(store.linksFor(["run-a", "missing"])).toMatchObject([
+ { sessionId: "run-a", nativeId: "native-a", state: null, reconciledAt: null },
+ { sessionId: "run-a", nativeId: "native-b", state: null, reconciledAt: null },
+ ]);
+
+ store.markReconciled("run-a", "native-a", "cost-state");
+ expect(store.unreconciled("run-a").map((link) => link.nativeId)).toEqual(["native-b"]);
+ expect(store.linksFor(["run-a"])[0]).toMatchObject({
+ nativeId: "native-a",
+ state: "cost-state",
+ });
+ expect(store.linksFor(["run-a"])[0].reconciledAt).toBeTruthy();
+ expect(store.link("run-a", "native-c")).toEqual({
+ created: true,
+ previous: ["native-b"],
+ });
+ });
+ });
+
+ it("returns only interrupted or dead working open rows with unreconciled links", () => {
+ withLinks((store, sessions) => {
+ const fixtures: Session[] = [
+ makeSession("interrupted-open", "interrupted"),
+ makeSession("working-dead", "working"),
+ makeSession("working-alive", "working"),
+ makeSession("completed-open", "completed"),
+ makeSession("unlinked-open", "interrupted"),
+ makeSession("interrupted-run", "interrupted", "run"),
+ ];
+ for (const session of fixtures) sessions.create(session);
+ for (const id of [
+ "interrupted-open",
+ "working-dead",
+ "working-alive",
+ "completed-open",
+ "interrupted-run",
+ ]) {
+ store.link(id, `native-${id}`);
+ }
+
+ const checked: string[] = [];
+ const stale = store.staleOpenRows((session) => {
+ checked.push(session.id);
+ return session.id === "working-dead";
+ });
+
+ expect(stale.map((session) => session.id)).toEqual([
+ "interrupted-open",
+ "working-dead",
+ ]);
+ expect(checked).toEqual(["working-alive", "working-dead"]);
+ });
+ });
+});
From 7f872aea549d5f2ca292d2127ae6d9d47a055faf Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:21:10 -0300
Subject: [PATCH 09/42] feat(usage): Read Claude transcript usage
Extract cumulative costs and token totals from Claude transcript files.
Co-Authored-By: Codex
---
src/core/claude-transcript.ts | 162 ++++++++++++++++++
tests/claude-transcript.test.ts | 98 +++++++++++
.../claude-transcript/cost-state.jsonl | 3 +
tests/fixtures/claude-transcript/tokens.jsonl | 5 +
.../fixtures/claude-transcript/unpriced.jsonl | 1 +
5 files changed, 269 insertions(+)
create mode 100644 src/core/claude-transcript.ts
create mode 100644 tests/claude-transcript.test.ts
create mode 100644 tests/fixtures/claude-transcript/cost-state.jsonl
create mode 100644 tests/fixtures/claude-transcript/tokens.jsonl
create mode 100644 tests/fixtures/claude-transcript/unpriced.jsonl
diff --git a/src/core/claude-transcript.ts b/src/core/claude-transcript.ts
new file mode 100644
index 0000000..04ed383
--- /dev/null
+++ b/src/core/claude-transcript.ts
@@ -0,0 +1,162 @@
+import { createReadStream, existsSync, readdirSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { createInterface } from "node:readline";
+import { computeSessionCost, type SessionCostUsage } from "./pricing.js";
+
+export interface TranscriptUsage {
+ state: "cost-state" | "tokens" | "no-price";
+ cost?: number;
+ inputTokens: number;
+ outputTokens: number;
+ cachedTokens: number;
+ model?: string;
+ endedAt?: string;
+ cwd?: string;
+}
+
+type JsonRecord = Record;
+
+interface UsageTotals extends SessionCostUsage {
+ inputTokens: number;
+ outputTokens: number;
+ cachedTokens: number;
+}
+
+function isRecord(value: unknown): value is JsonRecord {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function tokenCount(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
+}
+
+function finiteNumber(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+function emptyUsage(): UsageTotals {
+ return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
+}
+
+function usageFromCostState(value: JsonRecord): TranscriptUsage {
+ const totals = emptyUsage();
+ const modelUsage = isRecord(value.modelUsage) ? value.modelUsage : {};
+ let model: string | undefined;
+ let highestModelCost = Number.NEGATIVE_INFINITY;
+
+ for (const [name, rawUsage] of Object.entries(modelUsage)) {
+ if (!isRecord(rawUsage)) continue;
+ totals.inputTokens += tokenCount(rawUsage.inputTokens);
+ totals.outputTokens += tokenCount(rawUsage.outputTokens);
+ totals.cachedTokens += tokenCount(rawUsage.cacheReadInputTokens) + tokenCount(rawUsage.cacheCreationInputTokens);
+
+ const modelCost = finiteNumber(rawUsage.costUSD);
+ if (modelCost !== undefined && modelCost > highestModelCost) {
+ highestModelCost = modelCost;
+ model = name;
+ }
+ }
+
+ const cost = finiteNumber(value.totalCostUSD);
+ return {
+ state: "cost-state",
+ ...(cost === undefined ? {} : { cost }),
+ ...totals,
+ ...(model === undefined ? {} : { model }),
+ };
+}
+
+export function findTranscript(
+ nativeId: string,
+ projectsDir = path.join(os.homedir(), ".claude", "projects"),
+): string | undefined {
+ let projects;
+ try {
+ projects = readdirSync(projectsDir, { withFileTypes: true });
+ } catch {
+ return undefined;
+ }
+
+ for (const project of projects) {
+ if (!project.isDirectory()) continue;
+ const candidate = path.join(projectsDir, project.name, `${nativeId}.jsonl`);
+ if (existsSync(candidate)) return candidate;
+ }
+}
+
+export async function readTranscriptUsage(file: string): Promise {
+ let latestCostState: TranscriptUsage | undefined;
+ let endedAt: string | undefined;
+ let cwd: string | undefined;
+ const seenMessages = new Set();
+ const usageByModel = new Map();
+
+ const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
+ for await (const line of lines) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (!isRecord(parsed)) continue;
+
+ if (typeof parsed.timestamp === "string") endedAt = parsed.timestamp;
+ if (typeof parsed.cwd === "string") cwd = parsed.cwd;
+
+ if (parsed.type === "cost-state") {
+ latestCostState = usageFromCostState(parsed);
+ continue;
+ }
+
+ if (parsed.type !== "assistant" || !isRecord(parsed.message) || !isRecord(parsed.message.usage)) continue;
+
+ const messageId = parsed.message.id;
+ const requestId = parsed.requestId;
+ if (messageId != null && requestId != null) {
+ const key = JSON.stringify([messageId, requestId]);
+ if (seenMessages.has(key)) continue;
+ seenMessages.add(key);
+ }
+
+ const model = typeof parsed.message.model === "string" ? parsed.message.model : undefined;
+ const totals = usageByModel.get(model) ?? emptyUsage();
+ const usage = parsed.message.usage;
+ totals.inputTokens += tokenCount(usage.input_tokens);
+ totals.outputTokens += tokenCount(usage.output_tokens);
+ totals.cachedTokens += tokenCount(usage.cache_read_input_tokens) + tokenCount(usage.cache_creation_input_tokens);
+ usageByModel.set(model, totals);
+ }
+
+ if (latestCostState) {
+ return {
+ ...latestCostState,
+ ...(endedAt === undefined ? {} : { endedAt }),
+ ...(cwd === undefined ? {} : { cwd }),
+ };
+ }
+
+ const totals = emptyUsage();
+ let cost = 0;
+ let allModelsPriced = true;
+ for (const [model, usage] of usageByModel) {
+ totals.inputTokens += usage.inputTokens;
+ totals.outputTokens += usage.outputTokens;
+ totals.cachedTokens += usage.cachedTokens;
+ const modelCost = computeSessionCost({ model, usage });
+ if (modelCost === null) allModelsPriced = false;
+ else cost += modelCost;
+ }
+
+ return {
+ state: allModelsPriced ? "tokens" : "no-price",
+ ...(allModelsPriced ? { cost } : {}),
+ ...totals,
+ ...(usageByModel.size === 1 && usageByModel.keys().next().value !== undefined
+ ? { model: usageByModel.keys().next().value }
+ : {}),
+ ...(endedAt === undefined ? {} : { endedAt }),
+ ...(cwd === undefined ? {} : { cwd }),
+ };
+}
diff --git a/tests/claude-transcript.test.ts b/tests/claude-transcript.test.ts
new file mode 100644
index 0000000..e6aa61f
--- /dev/null
+++ b/tests/claude-transcript.test.ts
@@ -0,0 +1,98 @@
+import { once } from "node:events";
+import fs from "node:fs";
+import { createWriteStream } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it } from "vitest";
+import { findTranscript, readTranscriptUsage } from "../src/core/claude-transcript.js";
+
+const fixturePath = (name: string): string =>
+ fileURLToPath(new URL(`./fixtures/claude-transcript/${name}`, import.meta.url));
+
+const tempDirs: string[] = [];
+
+function makeTempDir(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-transcript-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
+});
+
+describe("Claude transcript usage", () => {
+ it("uses the last cost-state and sums its model token totals", async () => {
+ await expect(readTranscriptUsage(fixturePath("cost-state.jsonl"))).resolves.toEqual({
+ state: "cost-state",
+ cost: 2.5,
+ inputTokens: 22,
+ outputTokens: 26,
+ cachedTokens: 76,
+ model: "claude-opus-5",
+ endedAt: "2026-09-20T12:00:00.000Z",
+ cwd: "/tmp/final",
+ });
+ });
+
+ it("deduplicates repeated assistant messages and prices the model totals", async () => {
+ const usage = await readTranscriptUsage(fixturePath("tokens.jsonl"));
+
+ expect(usage).toMatchObject({
+ state: "tokens",
+ inputTokens: 300,
+ outputTokens: 140,
+ cachedTokens: 35,
+ endedAt: "2026-09-21T10:03:00.000Z",
+ cwd: "/tmp/final",
+ });
+ expect(usage.cost).toBeCloseTo(0.0014661, 15);
+ });
+
+ it("omits cost when any model is unpriced", async () => {
+ const usage = await readTranscriptUsage(fixturePath("unpriced.jsonl"));
+
+ expect(usage.state).toBe("no-price");
+ expect(usage).not.toHaveProperty("cost");
+ expect(usage).toMatchObject({ inputTokens: 25, outputTokens: 10, cachedTokens: 5 });
+ });
+
+ it("finds a transcript in a direct project subdirectory and tolerates missing paths", () => {
+ const projectsDir = makeTempDir();
+ const projectDir = path.join(projectsDir, "-tmp-project");
+ fs.mkdirSync(projectDir);
+ const file = path.join(projectDir, "native-1.jsonl");
+ fs.writeFileSync(file, "{}\n");
+
+ expect(findTranscript("native-1", projectsDir)).toBe(file);
+ expect(findTranscript("absent", projectsDir)).toBeUndefined();
+ expect(findTranscript("native-1", path.join(projectsDir, "missing"))).toBeUndefined();
+ });
+
+ it("streams a generated file larger than 50 MB", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-transcript-large-"));
+ const file = path.join(dir, "large.jsonl");
+ try {
+ const stream = createWriteStream(file);
+ const segment = "x".repeat(4 * 1024 * 1024);
+
+ for (let index = 0; index < 13; index += 1) {
+ if (!stream.write(`{\"type\":\"ignored\",\"data\":\"${segment}\"}\n`)) {
+ await once(stream, "drain");
+ }
+ }
+ const finished = once(stream, "finish");
+ stream.end();
+ await finished;
+ expect(fs.statSync(file).size).toBeGreaterThan(50 * 1024 * 1024);
+
+ const heapBefore = process.memoryUsage().heapUsed;
+ await readTranscriptUsage(file);
+ const heapGrowth = process.memoryUsage().heapUsed - heapBefore;
+ expect(heapGrowth).toBeLessThan(50 * 1024 * 1024);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }, 30000);
+});
diff --git a/tests/fixtures/claude-transcript/cost-state.jsonl b/tests/fixtures/claude-transcript/cost-state.jsonl
new file mode 100644
index 0000000..3d5bf2f
--- /dev/null
+++ b/tests/fixtures/claude-transcript/cost-state.jsonl
@@ -0,0 +1,3 @@
+{"type":"cost-state","timestamp":"2026-09-20T10:00:00.000Z","cwd":"/tmp/older","totalCostUSD":1.25,"modelUsage":{"claude-opus-5":{"inputTokens":1,"outputTokens":2,"cacheReadInputTokens":3,"cacheCreationInputTokens":4,"costUSD":1.2},"claude-haiku-4-5":{"inputTokens":10,"outputTokens":20,"cacheReadInputTokens":30,"cacheCreationInputTokens":40,"costUSD":0.05}}}
+{"type":"cost-state","timestamp":"2026-09-20T11:00:00.000Z","cwd":"/tmp/latest","totalCostUSD":2.5,"modelUsage":{"claude-opus-5":{"inputTokens":5,"outputTokens":7,"cacheReadInputTokens":11,"cacheCreationInputTokens":13,"costUSD":2.4},"claude-sonnet-5":{"inputTokens":17,"outputTokens":19,"cacheReadInputTokens":23,"cacheCreationInputTokens":29,"costUSD":0.1}}}
+{"type":"system","timestamp":"2026-09-20T12:00:00.000Z","cwd":"/tmp/final"}
diff --git a/tests/fixtures/claude-transcript/tokens.jsonl b/tests/fixtures/claude-transcript/tokens.jsonl
new file mode 100644
index 0000000..e5f8cbe
--- /dev/null
+++ b/tests/fixtures/claude-transcript/tokens.jsonl
@@ -0,0 +1,5 @@
+not-json, skip this line
+{"type":"assistant","timestamp":"2026-09-21T10:00:00.000Z","cwd":"/tmp/worker","requestId":"req-1","message":{"id":"msg-1","model":"claude-sonnet-5","usage":{"input_tokens":100,"output_tokens":40,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}
+{"type":"assistant","timestamp":"2026-09-21T10:01:00.000Z","requestId":"req-1","message":{"id":"msg-1","model":"claude-sonnet-5","usage":{"input_tokens":100,"output_tokens":40,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}
+{"type":"assistant","timestamp":"2026-09-21T10:02:00.000Z","requestId":"req-2","message":{"id":"msg-2","model":"claude-haiku-4-5","usage":{"input_tokens":200,"output_tokens":100,"cache_read_input_tokens":20,"cache_creation_input_tokens":0}}}
+{"type":"system","timestamp":"2026-09-21T10:03:00.000Z","cwd":"/tmp/final"}
diff --git a/tests/fixtures/claude-transcript/unpriced.jsonl b/tests/fixtures/claude-transcript/unpriced.jsonl
new file mode 100644
index 0000000..caac934
--- /dev/null
+++ b/tests/fixtures/claude-transcript/unpriced.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus-5-5","usage":{"input_tokens":25,"output_tokens":10,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}
From afa060cd5adf0c139a69693f60d718546be001e0 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:21:18 -0300
Subject: [PATCH 10/42] feat(usage): Pass run ids to worker environments
Expose the associated run id to spawned workers for usage attribution.
Co-Authored-By: Codex
---
src/core/driver.ts | 1 +
src/drivers/session-driver.ts | 6 +-
tests/run-env.test.ts | 103 ++++++++++++++++++++++++++++++++++
3 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 tests/run-env.test.ts
diff --git a/src/core/driver.ts b/src/core/driver.ts
index 9f370a1..64b6639 100644
--- a/src/core/driver.ts
+++ b/src/core/driver.ts
@@ -44,6 +44,7 @@ export interface StartOptions {
sessionId: string;
prompt: string;
cwd: string;
+ runId?: string;
model?: string;
effort?: ReasoningEffort;
// Claude's native compaction setting. Other drivers ignore this field.
diff --git a/src/drivers/session-driver.ts b/src/drivers/session-driver.ts
index 2642529..27cb688 100644
--- a/src/drivers/session-driver.ts
+++ b/src/drivers/session-driver.ts
@@ -84,6 +84,7 @@ export abstract class SessionDriver implements AgentDriver {
protected getEnv?(_options: StartOptions): NodeJS.ProcessEnv | undefined;
async start(options: StartOptions): Promise {
+ const env = this.getEnv?.(options) ?? {};
const runtime = SessionRuntime.spawn({
sessionId: options.sessionId,
cmd: this.getCommand(),
@@ -91,7 +92,10 @@ export abstract class SessionDriver implements AgentDriver {
cwd: options.cwd,
nativeSessionId: options.resumeSessionId,
hooks: this.hooks,
- env: this.getEnv?.(options),
+ env: {
+ ...env,
+ CODEDECK_RUN_ID: options.runId && options.runId.length > 0 ? options.runId : undefined,
+ },
});
this.handles.set(options.sessionId, runtime);
diff --git a/tests/run-env.test.ts b/tests/run-env.test.ts
new file mode 100644
index 0000000..10beb5c
--- /dev/null
+++ b/tests/run-env.test.ts
@@ -0,0 +1,103 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterAll, describe, expect, it } from "vitest";
+import type { AgentEvent } from "../src/core/events.js";
+import type { StartOptions } from "../src/core/driver.js";
+import { createRuntimeHooks, SessionDriver } from "../src/drivers/session-driver.js";
+
+const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-env-logs-"));
+const previousRunAgentDir = process.env.RUN_AGENT_DIR;
+const previousNoScope = process.env.CODEDECK_NO_SCOPE;
+process.env.RUN_AGENT_DIR = logDir;
+process.env.CODEDECK_NO_SCOPE = "1";
+
+afterAll(() => {
+ if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
+ else process.env.RUN_AGENT_DIR = previousRunAgentDir;
+ if (previousNoScope === undefined) delete process.env.CODEDECK_NO_SCOPE;
+ else process.env.CODEDECK_NO_SCOPE = previousNoScope;
+ fs.rmSync(logDir, { recursive: true, force: true });
+});
+
+class EnvDriver extends SessionDriver {
+ readonly id = "claude" as const;
+ protected readonly hooks = createRuntimeHooks({
+ parse: (line, sessionId) => [
+ {
+ type: "message",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ role: "assistant",
+ content: line,
+ raw: line,
+ } as AgentEvent,
+ ],
+ nativeKeys: [],
+ harness: "stub",
+ });
+ protected readonly resumeError = "unused";
+
+ protected buildArgs(_options: StartOptions): string[] {
+ return [
+ "-e",
+ 'process.stdout.write(JSON.stringify({ present: Object.hasOwn(process.env, "CODEDECK_RUN_ID"), value: process.env.CODEDECK_RUN_ID ?? null }) + "\\n");',
+ ];
+ }
+
+ protected override getCommand(): string {
+ return process.execPath;
+ }
+
+ capabilities() {
+ return {
+ streaming: true,
+ resume: false,
+ fork: false,
+ approvals: false,
+ usage: false,
+ cost: false,
+ modelSelection: false,
+ nativeDiff: false,
+ interrupt: false,
+ };
+ }
+
+ async detect() {
+ return { installed: true };
+ }
+}
+
+async function readEnvironment(runId?: string): Promise<{ present: boolean; value: string | null }> {
+ const driver = new EnvDriver();
+ const session = await driver.start({
+ sessionId: `env-${runId ?? "none"}-${Math.random().toString(36).slice(2)}`,
+ prompt: "",
+ cwd: os.tmpdir(),
+ runId,
+ });
+ let output: { present: boolean; value: string | null } | undefined;
+ for await (const event of driver.events(session)) {
+ if (event.type === "message") output = JSON.parse(String(event.content)) as typeof output;
+ }
+ if (!output) throw new Error("stub harness did not report its environment");
+ return output;
+}
+
+describe("worker run id environment", () => {
+ it("sets CODEDECK_RUN_ID when a run id is present", async () => {
+ await expect(readEnvironment("r1")).resolves.toEqual({ present: true, value: "r1" });
+ });
+
+ it("omits CODEDECK_RUN_ID when a run id is absent or empty", async () => {
+ const previousRunId = process.env.CODEDECK_RUN_ID;
+ process.env.CODEDECK_RUN_ID = "ambient-run";
+ try {
+ await expect(readEnvironment()).resolves.toEqual({ present: false, value: null });
+ await expect(readEnvironment("")).resolves.toEqual({ present: false, value: null });
+ } finally {
+ if (previousRunId === undefined) delete process.env.CODEDECK_RUN_ID;
+ else process.env.CODEDECK_RUN_ID = previousRunId;
+ }
+ });
+});
From b6dd150f6b93fd146b8e19ce8df8ac3b6c02dd32 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:25:28 -0300
Subject: [PATCH 11/42] fix(usage): Record zero-valued observations
A first cost report of zero is known usage, not missing usage. Persist its high-water mark and attribution so run completeness remains accurate.
Co-Authored-By: Codex
---
src/store/usage-ledger.ts | 5 +++--
tests/usage-ledger.test.ts | 31 +++++++++++++++++++++++++++++++
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/store/usage-ledger.ts b/src/store/usage-ledger.ts
index 8df9e9e..9ba9e44 100644
--- a/src/store/usage-ledger.ts
+++ b/src/store/usage-ledger.ts
@@ -79,9 +79,10 @@ export class UsageLedger {
for (const field of fields) {
const observed = obs[field.observation];
if (observed === undefined) continue;
- const mark = source[field.source] ?? 0;
+ const storedMark = source[field.source];
+ const mark = storedMark ?? 0;
const delta = observed - mark;
- if (delta <= 0) continue;
+ if (delta < 0 || (delta === 0 && storedMark !== null)) continue;
deltas[field.attribution] = delta;
sourceUpdates.push(`${field.source} = ?`);
sourceValues.push(observed);
diff --git a/tests/usage-ledger.test.ts b/tests/usage-ledger.test.ts
index 1f3e4f0..f6c3c0b 100644
--- a/tests/usage-ledger.test.ts
+++ b/tests/usage-ledger.test.ts
@@ -281,6 +281,37 @@ describe("UsageLedger", () => {
});
});
+ it("records a first zero-valued observation as known usage", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("zero-cost", "/tmp"));
+
+ expect(ledger.observe("zero-cost", "free-source", { cost: 0 })).toBe(true);
+ expect(sourceFor(db, "free-source")).toEqual({
+ cost: 0,
+ input_tokens: null,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["zero-cost"])).toEqual([{
+ sessionId: "zero-cost",
+ sourceKey: "free-source",
+ cost: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ }]);
+ expect(usageFor(db, "zero-cost")).toEqual({
+ usage_input_tokens: 0,
+ usage_output_tokens: 0,
+ usage_cached_tokens: 0,
+ usage_cost: 0,
+ });
+
+ expect(ledger.observe("zero-cost", "free-source", { cost: 0 })).toBe(false);
+ expect(ledger.attributionsFor(["zero-cost"])[0].cost).toBe(0);
+ });
+ });
+
it("seeds existing usage once before applying a new source", () => {
withLedger((db, ledger, sessions) => {
sessions.create(makeSession("seeded", "/tmp", {
From aea08debb52632be50ee26149851aba33c9ddae8 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:28:06 -0300
Subject: [PATCH 12/42] ref(usage): Use SessionStore for ledger materialization
Keep token updates on the shared session write path and clear nullable cost directly, which SessionStore.update cannot represent.
Co-Authored-By: Codex
---
src/store/usage-ledger.ts | 21 +++++++++++----------
1 file changed, 11 insertions(+), 10 deletions(-)
diff --git a/src/store/usage-ledger.ts b/src/store/usage-ledger.ts
index 9ba9e44..e1628e8 100644
--- a/src/store/usage-ledger.ts
+++ b/src/store/usage-ledger.ts
@@ -1,4 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
+import { SessionStore } from "./sessions.js";
export interface UsageObservation {
cost?: number;
@@ -214,15 +215,15 @@ export class UsageLedger {
output_tokens: number | null;
cached_tokens: number | null;
};
- this.db.prepare(`
- UPDATE sessions SET usage_input_tokens = ?, usage_output_tokens = ?,
- usage_cached_tokens = ?, usage_cost = ? WHERE id = ?
- `).run(
- usage.input_tokens ?? 0,
- usage.output_tokens ?? 0,
- usage.cached_tokens ?? 0,
- usage.cost,
- sessionId,
- );
+ new SessionStore(this.db).update(sessionId, {
+ usage: {
+ inputTokens: usage.input_tokens ?? 0,
+ outputTokens: usage.output_tokens ?? 0,
+ cachedTokens: usage.cached_tokens ?? 0,
+ ...(usage.cost === null ? {} : { cost: usage.cost }),
+ },
+ });
+ this.db.prepare(`UPDATE sessions SET usage_cost = ? WHERE id = ?`)
+ .run(usage.cost, sessionId);
}
}
From 433496399b7ea88cbf387ddf956b0eb9aa7964be Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:32:36 -0300
Subject: [PATCH 13/42] fix(usage): Ignore empty synthetic transcript usage
Keep zero-token synthetic models from making priced fallback usage incomplete.
Measure memory while parsing the large transcript fixture.
Co-Authored-By: Codex
---
src/core/claude-transcript.ts | 14 +++++--
tests/claude-transcript.test.ts | 38 ++++++++++++++++---
.../claude-transcript/synthetic.jsonl | 2 +
3 files changed, 44 insertions(+), 10 deletions(-)
create mode 100644 tests/fixtures/claude-transcript/synthetic.jsonl
diff --git a/src/core/claude-transcript.ts b/src/core/claude-transcript.ts
index 04ed383..e9becad 100644
--- a/src/core/claude-transcript.ts
+++ b/src/core/claude-transcript.ts
@@ -112,6 +112,13 @@ export async function readTranscriptUsage(file: string): Promise {
expect(usage).toMatchObject({ inputTokens: 25, outputTokens: 10, cachedTokens: 5 });
});
+ it("ignores zero-token synthetic assistant lines when pricing fallback usage", async () => {
+ const usage = await readTranscriptUsage(fixturePath("synthetic.jsonl"));
+
+ expect(usage).toMatchObject({
+ state: "tokens",
+ inputTokens: 100,
+ outputTokens: 50,
+ cachedTokens: 30,
+ model: "claude-opus-5",
+ });
+ expect(usage.cost).toBeCloseTo(0.005295, 12);
+ });
+
it("finds a transcript in a direct project subdirectory and tolerates missing paths", () => {
const projectsDir = makeTempDir();
const projectDir = path.join(projectsDir, "-tmp-project");
@@ -75,9 +88,9 @@ describe("Claude transcript usage", () => {
const file = path.join(dir, "large.jsonl");
try {
const stream = createWriteStream(file);
- const segment = "x".repeat(4 * 1024 * 1024);
+ const segment = "x".repeat(64 * 1024);
- for (let index = 0; index < 13; index += 1) {
+ for (let index = 0; index < 820; index += 1) {
if (!stream.write(`{\"type\":\"ignored\",\"data\":\"${segment}\"}\n`)) {
await once(stream, "drain");
}
@@ -87,10 +100,23 @@ describe("Claude transcript usage", () => {
await finished;
expect(fs.statSync(file).size).toBeGreaterThan(50 * 1024 * 1024);
- const heapBefore = process.memoryUsage().heapUsed;
- await readTranscriptUsage(file);
- const heapGrowth = process.memoryUsage().heapUsed - heapBefore;
- expect(heapGrowth).toBeLessThan(50 * 1024 * 1024);
+ const memoryBefore = process.memoryUsage();
+ let peakHeapUsed = memoryBefore.heapUsed;
+ let peakExternal = memoryBefore.external;
+ const originalParse = JSON.parse;
+ JSON.parse = ((...args: Parameters) => {
+ const memory = process.memoryUsage();
+ peakHeapUsed = Math.max(peakHeapUsed, memory.heapUsed);
+ peakExternal = Math.max(peakExternal, memory.external);
+ return originalParse(...args);
+ }) as typeof JSON.parse;
+ try {
+ await readTranscriptUsage(file);
+ } finally {
+ JSON.parse = originalParse;
+ }
+ expect(peakHeapUsed - memoryBefore.heapUsed).toBeLessThan(50 * 1024 * 1024);
+ expect(peakExternal - memoryBefore.external).toBeLessThan(50 * 1024 * 1024);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
diff --git a/tests/fixtures/claude-transcript/synthetic.jsonl b/tests/fixtures/claude-transcript/synthetic.jsonl
new file mode 100644
index 0000000..cf0733f
--- /dev/null
+++ b/tests/fixtures/claude-transcript/synthetic.jsonl
@@ -0,0 +1,2 @@
+{"type":"assistant","requestId":"req-synthetic","message":{"id":"msg-synthetic","model":"","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
+{"type":"assistant","requestId":"req-priced","message":{"id":"msg-priced","model":"claude-opus-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":20,"cache_creation_input_tokens":10}}}
From 17244b59c537857ff82ec8424caff00b45dc5b89 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:36:21 -0300
Subject: [PATCH 14/42] test(usage): Increase transcript memory fixture size
Keep headroom between the generated file size and the memory limit.
Co-Authored-By: Codex
---
tests/claude-transcript.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/claude-transcript.test.ts b/tests/claude-transcript.test.ts
index eda0485..b4e0696 100644
--- a/tests/claude-transcript.test.ts
+++ b/tests/claude-transcript.test.ts
@@ -90,7 +90,7 @@ describe("Claude transcript usage", () => {
const stream = createWriteStream(file);
const segment = "x".repeat(64 * 1024);
- for (let index = 0; index < 820; index += 1) {
+ for (let index = 0; index < 1600; index += 1) {
if (!stream.write(`{\"type\":\"ignored\",\"data\":\"${segment}\"}\n`)) {
await once(stream, "drain");
}
From 1cc3fcaa4e0f1b1c46c6f524cffa67f6ecf0c4c5 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:13:52 -0300
Subject: [PATCH 15/42] fix(plugin): Append session IDs to the sidecar
---
plugin/hooks/session-id.sh | 2 +-
tests/session-id-hook.test.ts | 74 +++++++++++++++++++++++++++++++++++
2 files changed, 75 insertions(+), 1 deletion(-)
create mode 100644 tests/session-id-hook.test.ts
diff --git a/plugin/hooks/session-id.sh b/plugin/hooks/session-id.sh
index b4eaeec..fb151aa 100644
--- a/plugin/hooks/session-id.sh
+++ b/plugin/hooks/session-id.sh
@@ -20,4 +20,4 @@ target=${CODEDECK_SESSION_FILE:-}
id=$(grep -oE '"session_id":"[0-9a-fA-F-]+"' | head -1 | cut -d'"' -f4)
[[ -n "$id" ]] || exit 0
-printf '%s' "$id" > "$target"
+printf '%s\n' "$id" >> "$target"
diff --git a/tests/session-id-hook.test.ts b/tests/session-id-hook.test.ts
new file mode 100644
index 0000000..38ea01d
--- /dev/null
+++ b/tests/session-id-hook.test.ts
@@ -0,0 +1,74 @@
+import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
+import { spawn } from "node:child_process";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+const root = path.resolve(import.meta.dirname, "..");
+const hook = path.join(root, "plugin", "hooks", "session-id.sh");
+
+async function runHook(input: string, env: NodeJS.ProcessEnv): Promise<{ exitCode: number | null; elapsedMs: number }> {
+ const startedAt = performance.now();
+ const child = spawn("bash", [hook], { cwd: root, env, stdio: ["pipe", "ignore", "pipe"] });
+ let stderr = "";
+ child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); });
+
+ const exitCode = await new Promise((resolve, reject) => {
+ child.once("error", reject);
+ child.once("close", resolve);
+ child.stdin.end(input);
+ });
+
+ if (stderr) throw new Error(stderr);
+ return { exitCode, elapsedMs: performance.now() - startedAt };
+}
+
+describe("session id hook", () => {
+ it("appends each session id and exits quickly without a daemon", async () => {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "codedeck-session-id-"));
+ const sessionFile = path.join(dir, "session-id");
+ const runId = "run-example";
+ const env = {
+ ...process.env,
+ CODEDECK_RUN_ID: runId,
+ CODEDECK_SESSION_FILE: sessionFile,
+ RUN_AGENT_DIR: dir,
+ };
+ const ids = [
+ "92d88cce-bdbc-46db-8573-916afd32f6f7",
+ "3f1f93b8-c484-43aa-8a11-32a486109e22",
+ ];
+
+ try {
+ expect(readdirSync(dir)).toEqual([]);
+ for (const id of ids) {
+ const result = await runHook(JSON.stringify({ session_id: id }), env);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.elapsedMs).toBeLessThan(500);
+ }
+
+ expect(readFileSync(sessionFile, "utf8")).toBe(`${ids.join("\n")}\n`);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("exits successfully without writing when the session file is unset", async () => {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "codedeck-session-id-empty-"));
+ const env = { ...process.env, CODEDECK_RUN_ID: "run-example", RUN_AGENT_DIR: dir };
+ delete env.CODEDECK_SESSION_FILE;
+
+ try {
+ const result = await runHook(
+ JSON.stringify({ session_id: "92d88cce-bdbc-46db-8573-916afd32f6f7" }),
+ env,
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(readdirSync(dir)).toEqual([]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
From 4890631b321477de9ff8d99c0aecab09271c953f Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:15:58 -0300
Subject: [PATCH 16/42] fix(open): Read the last session id from the sidecar
---
src/open/runtime.ts | 11 +++++++----
tests/open-args.test.ts | 25 +++++++++++++++++++++++++
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/src/open/runtime.ts b/src/open/runtime.ts
index f9cd4b6..17f0a94 100644
--- a/src/open/runtime.ts
+++ b/src/open/runtime.ts
@@ -241,10 +241,13 @@ export function renderExit(role: Role, id: string | undefined): string {
/** Reads what the SessionStart hook left, and takes both sidecars with it. */
function takeSessionId(file: string): string | undefined {
- let id: string | undefined;
+ let ids: string[] = [];
try {
- id = fs.readFileSync(file, "utf8").trim() || undefined;
- return id;
+ ids = fs
+ .readFileSync(file, "utf8")
+ .split(/\r?\n/)
+ .filter((id) => SESSION_ID_PATTERN.test(id));
+ return ids.at(-1);
} catch {
// No file means the hook never ran: an older Claude Code, a session that
// died before startup, or a plugin the launch could not load. None of those
@@ -253,7 +256,7 @@ function takeSessionId(file: string): string | undefined {
try {
fs.rmSync(file, { force: true });
} catch {}
- if (id && SESSION_ID_PATTERN.test(id)) {
+ for (const id of ids) {
try {
fs.rmSync(`${file}.${id}.name`, { force: true });
} catch {}
diff --git a/tests/open-args.test.ts b/tests/open-args.test.ts
index fd01a24..8d3eeee 100644
--- a/tests/open-args.test.ts
+++ b/tests/open-args.test.ts
@@ -569,6 +569,31 @@ describe("open command pure helpers", () => {
}
});
+ it("uses the last id in the sidecar and removes every matching name sidecar", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-open-test-"));
+ const sessionFile = path.join(tempDir, "session");
+ const ids = [
+ "92d88cce-bdbc-46db-8573-916afd32f6f7",
+ "3f1f93b8-c484-43aa-8a11-32a486109e22",
+ ];
+ fs.writeFileSync(sessionFile, `${ids.join("\n")}\n`);
+ for (const id of ids) fs.writeFileSync(`${sessionFile}.${id}.name`, "task name");
+
+ try {
+ const writes: string[] = [];
+ const result = finishOpenSession("reviewer", sessionFile, (text) => {
+ writes.push(text);
+ });
+
+ expect(result).toBe(ids[1]);
+ expect(writes.join("")).toContain(`codedeck open reviewer --resume ${ids[1]}`);
+ expect(fs.existsSync(sessionFile)).toBe(false);
+ for (const id of ids) expect(fs.existsSync(`${sessionFile}.${id}.name`)).toBe(false);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
// Off-tty Claude skips its hint too, and without an id there is no hint on
// either side, so escape codes would only pollute redirected output.
it("skips the erase off-tty or without an id to offer", () => {
From fadefa69989c6cb6761b7e26f30836b791096d19 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:20:22 -0300
Subject: [PATCH 17/42] feat(open): Poll native session ids for linking
---
src/open/link-watcher.ts | 51 +++++++++++++
tests/link-watcher.test.ts | 144 +++++++++++++++++++++++++++++++++++++
2 files changed, 195 insertions(+)
create mode 100644 src/open/link-watcher.ts
create mode 100644 tests/link-watcher.test.ts
diff --git a/src/open/link-watcher.ts b/src/open/link-watcher.ts
new file mode 100644
index 0000000..ef9108d
--- /dev/null
+++ b/src/open/link-watcher.ts
@@ -0,0 +1,51 @@
+import fs from "node:fs";
+import { SESSION_ID_PATTERN } from "./runtime.js";
+
+export function startLinkWatcher(opts: {
+ sessionFile: string;
+ runId: string;
+ link: (runId: string, nativeId: string) => Promise;
+ intervalMs?: number;
+}): { flush(): Promise; stop(): void } {
+ const sent = new Set();
+ const inFlight = new Map>();
+
+ const send = (nativeId: string): Promise => {
+ if (sent.has(nativeId)) return Promise.resolve();
+ const existing = inFlight.get(nativeId);
+ if (existing) return existing;
+
+ const pending = Promise.resolve()
+ .then(() => opts.link(opts.runId, nativeId))
+ .then(() => {
+ sent.add(nativeId);
+ })
+ .catch(() => {})
+ .finally(() => {
+ inFlight.delete(nativeId);
+ });
+ inFlight.set(nativeId, pending);
+ return pending;
+ };
+
+ const flush = async (): Promise => {
+ let contents: string;
+ try {
+ contents = fs.readFileSync(opts.sessionFile, "utf8");
+ } catch {
+ return;
+ }
+ const nativeIds = [...new Set(contents.split(/\r?\n/).filter((id) => SESSION_ID_PATTERN.test(id)))];
+ await Promise.all(nativeIds.map(send));
+ };
+
+ const timer = setInterval(() => {
+ void flush();
+ }, opts.intervalMs ?? 1000);
+ timer.unref();
+
+ return {
+ flush,
+ stop: () => clearInterval(timer),
+ };
+}
diff --git a/tests/link-watcher.test.ts b/tests/link-watcher.test.ts
new file mode 100644
index 0000000..45390f6
--- /dev/null
+++ b/tests/link-watcher.test.ts
@@ -0,0 +1,144 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { startLinkWatcher } from "../src/open/link-watcher.js";
+
+const ids = [
+ "92d88cce-bdbc-46db-8573-916afd32f6f7",
+ "3f1f93b8-c484-43aa-8a11-32a486109e22",
+];
+
+describe("open link watcher", () => {
+ let tempDir: string;
+
+ afterEach(() => {
+ vi.useRealTimers();
+ if (tempDir) rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("sends appended ids on the default tick and never sends an id twice", async () => {
+ vi.useFakeTimers();
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "codedeck-link-watcher-"));
+ const sessionFile = path.join(tempDir, "session");
+ const link = vi.fn(async () => ({}));
+ const watcher = startLinkWatcher({ sessionFile, runId: "run-example", link });
+ writeFileSync(sessionFile, `${ids[0]}\n${ids[0]}\n`);
+
+ try {
+ await vi.advanceTimersByTimeAsync(999);
+ expect(link).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(link).toHaveBeenCalledTimes(1);
+ expect(link).toHaveBeenCalledWith("run-example", ids[0]);
+
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(link).toHaveBeenCalledTimes(1);
+ } finally {
+ watcher.stop();
+ }
+ });
+
+ it("retries an id when linking rejects", async () => {
+ vi.useFakeTimers();
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "codedeck-link-watcher-"));
+ const sessionFile = path.join(tempDir, "session");
+ const link = vi.fn()
+ .mockRejectedValueOnce(new Error("daemon unavailable"))
+ .mockResolvedValue({});
+ writeFileSync(sessionFile, `${ids[0]}\n`);
+ const watcher = startLinkWatcher({ sessionFile, runId: "run-example", link });
+
+ try {
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(link).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(link).toHaveBeenCalledTimes(2);
+ expect(link).toHaveBeenLastCalledWith("run-example", ids[0]);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(link).toHaveBeenCalledTimes(2);
+ } finally {
+ watcher.stop();
+ }
+ });
+
+ it("flushes all valid ids immediately and stop clears the timer", async () => {
+ vi.useFakeTimers();
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "codedeck-link-watcher-"));
+ const sessionFile = path.join(tempDir, "session");
+ const link = vi.fn(async () => ({}));
+ writeFileSync(sessionFile, `invalid\n${ids[0]}\n${ids[1]}\n`);
+ const watcher = startLinkWatcher({ sessionFile, runId: "run-example", link, intervalMs: 50 });
+
+ try {
+ await watcher.flush();
+
+ expect(link.mock.calls).toEqual([
+ ["run-example", ids[0]],
+ ["run-example", ids[1]],
+ ]);
+
+ watcher.stop();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(100);
+ expect(link).toHaveBeenCalledTimes(2);
+ } finally {
+ watcher.stop();
+ }
+ });
+
+ it("reads ids appended while an earlier link is still in flight before flush resolves", async () => {
+ vi.useFakeTimers();
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "codedeck-link-watcher-"));
+ const sessionFile = path.join(tempDir, "session");
+ let finishFirstLink!: () => void;
+ const firstLink = new Promise((resolve) => {
+ finishFirstLink = resolve;
+ });
+ const link = vi.fn(async (_runId: string, nativeId: string) => {
+ if (nativeId === ids[0]) await firstLink;
+ return {};
+ });
+ writeFileSync(sessionFile, `${ids[0]}\n`);
+ const watcher = startLinkWatcher({ sessionFile, runId: "run-example", link, intervalMs: 50 });
+
+ try {
+ const initialFlush = watcher.flush();
+ await Promise.resolve();
+ expect(link).toHaveBeenCalledWith("run-example", ids[0]);
+
+ writeFileSync(sessionFile, `${ids[0]}\n${ids[1]}\n`);
+ const finalFlush = watcher.flush();
+ finishFirstLink();
+ await Promise.all([initialFlush, finalFlush]);
+
+ expect(link.mock.calls).toEqual([
+ ["run-example", ids[0]],
+ ["run-example", ids[1]],
+ ]);
+ } finally {
+ watcher.stop();
+ }
+ });
+
+ it("treats a missing sidecar as an empty pass", async () => {
+ vi.useFakeTimers();
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "codedeck-link-watcher-"));
+ const link = vi.fn(async () => ({}));
+ const watcher = startLinkWatcher({
+ sessionFile: path.join(tempDir, "missing"),
+ runId: "run-example",
+ link,
+ });
+
+ try {
+ await expect(watcher.flush()).resolves.toBeUndefined();
+ expect(link).not.toHaveBeenCalled();
+ } finally {
+ watcher.stop();
+ }
+ });
+});
From f64db67edb83d4a8581f1197cfa8ce3421ced24e Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:26:26 -0300
Subject: [PATCH 18/42] fix(statusline): Report live orchestrator usage
---
plugin/statusline.sh | 43 ++++++++++++-
tests/statusline.test.ts | 135 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 173 insertions(+), 5 deletions(-)
diff --git a/plugin/statusline.sh b/plugin/statusline.sh
index b7bfc8c..0e9db38 100755
--- a/plugin/statusline.sh
+++ b/plugin/statusline.sh
@@ -181,8 +181,17 @@ const getRunUsage = () => {
const runId = text(process.env.CODEDECK_RUN_ID);
if (!runId) return undefined;
+ const args = ["usage", runId, "--json"];
+ const sessionId = payload.session_id;
+ const observeCost = nonNegativeNumber(payload.cost?.total_cost_usd);
+ if (
+ typeof sessionId === "string" &&
+ /^[0-9a-fA-F-]{8,}$/.test(sessionId) &&
+ observeCost !== undefined
+ ) args.push("--observe", `${sessionId}=${observeCost}`);
+
try {
- const output = execFileSync("codedeck", ["usage", runId, "--json"], {
+ const output = execFileSync("codedeck", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
@@ -215,8 +224,30 @@ const getRunUsage = () => {
}
};
+const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
+
+const getOrchestratorUsage = (usage) => {
+ const value = usage.orchestrator;
+ if (
+ !isObject(value) ||
+ nonNegativeNumber(value.costUsd) === undefined ||
+ typeof value.costComplete !== "boolean" ||
+ nonNegativeNumber(value.inputTokens) === undefined ||
+ nonNegativeNumber(value.outputTokens) === undefined ||
+ nonNegativeNumber(value.cachedTokens) === undefined ||
+ !Array.isArray(value.sources)
+ ) return undefined;
+ if (!value.sources.every((source) =>
+ isObject(source) &&
+ typeof source.nativeId === "string" &&
+ nonNegativeNumber(source.costUsd) !== undefined
+ )) return undefined;
+ return value;
+};
+
const local = localCost();
const runUsage = getRunUsage();
+const orchestratorUsage = runUsage ? getOrchestratorUsage(runUsage) : undefined;
const workerTokens = runUsage
? runUsage.inputTokens + runUsage.outputTokens + runUsage.cachedTokens
: undefined;
@@ -227,8 +258,14 @@ const tokenField = () => {
const runField = () => {
if (!runUsage) return undefined;
- const total = (local ?? 0) + runUsage.costUsd;
- const incomplete = !runUsage.costComplete;
+ const sourceCost = orchestratorUsage
+ ? orchestratorUsage.sources.reduce(
+ (sum, source) => source.nativeId === payload.session_id ? sum : sum + source.costUsd,
+ 0,
+ )
+ : 0;
+ const total = (local ?? 0) + runUsage.costUsd + sourceCost;
+ const incomplete = !runUsage.costComplete || Boolean(orchestratorUsage && !orchestratorUsage.costComplete);
if (!incomplete && total < COST_DISPLAY_THRESHOLD) return undefined;
return paint(MUTED, "run ") + costAmount(total, incomplete);
};
diff --git a/tests/statusline.test.ts b/tests/statusline.test.ts
index 01fe6d2..4b653b1 100644
--- a/tests/statusline.test.ts
+++ b/tests/statusline.test.ts
@@ -117,6 +117,77 @@ describe("Claude statusline", () => {
expect(result.output).not.toContain("claude-sonnet-4");
});
+ it("reports the local orchestrator cost without counting its live source twice", async () => {
+ const sessionId = "92d88cce-bdbc-46db-8573-916afd32f6f7";
+ const otherSourceId = "3f1f93b8-c484-43aa-8a11-32a486109e22";
+ const result = await render({
+ payload: { ...payload(1), session_id: sessionId },
+ sessionId,
+ runId: "run-example",
+ usage: {
+ runId: "run-example",
+ inputTokens: 1200,
+ outputTokens: 800,
+ cachedTokens: 300,
+ costUsd: 0.5,
+ sessionCount: 2,
+ activeSessionCount: 2,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 3.8,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [
+ { nativeId: otherSourceId, costUsd: 3 },
+ { nativeId: sessionId, costUsd: 0.8 },
+ ],
+ },
+ total: { costUsd: 4.3 },
+ },
+ });
+
+ expect(stripAnsi(result.output)).toContain("run $4.50");
+ expect(result.args).toEqual([
+ "usage",
+ "run-example",
+ "--json",
+ "--observe",
+ `${sessionId}=1`,
+ ]);
+ });
+
+ it("observes zero cost but skips invalid session ids and negative costs", async () => {
+ const sessionId = "92d88cce-bdbc-46db-8573-916afd32f6f7";
+ const zero = await render({
+ payload: { ...payload(0), session_id: sessionId },
+ sessionId,
+ runId: "run-example",
+ });
+ const invalidId = await render({
+ payload: { ...payload(1), session_id: "ses_invalid" },
+ sessionId: "ses_invalid",
+ runId: "run-example",
+ });
+ const negativeCost = await render({
+ payload: { ...payload(-1), session_id: sessionId },
+ sessionId,
+ runId: "run-example",
+ });
+
+ expect(zero.args).toEqual([
+ "usage",
+ "run-example",
+ "--json",
+ "--observe",
+ `${sessionId}=0`,
+ ]);
+ expect(invalidId.args).toEqual(["usage", "run-example", "--json"]);
+ expect(negativeCost.args).toEqual(["usage", "run-example", "--json"]);
+ });
+
it("renders the task name from its sidecar before the role", async () => {
const sessionId = "92d88cce-bdbc-46db-8573-916afd32f6f7";
const result = await render({
@@ -156,12 +227,12 @@ describe("Claude statusline", () => {
it("keeps the local token snapshot when the usage CLI fails", async () => {
const result = await render({
- payload: payload(0.25, { total_input_tokens: 1_200, total_output_tokens: 800 }),
+ payload: payload(1, { total_input_tokens: 1_200, total_output_tokens: 800 }),
runId: "run-unavailable",
shimExitCode: 1,
});
- expect(stripAnsi(result.output)).toBe(`builder · ${project}/main · ctx 68% · 2k tok · $0.25`);
+ expect(stripAnsi(result.output)).toBe(`builder · ${project}/main · ctx 68% · 2k tok · $1.00`);
expect(stripAnsi(result.output)).not.toContain(" · run ");
expect(stripAnsi(result.output)).not.toContain("agents");
});
@@ -214,6 +285,66 @@ describe("Claude statusline", () => {
expect(stripAnsi(result.output)).toContain("0 tok · run $0.42?");
});
+ it("marks the run incomplete when orchestrator cost is incomplete", async () => {
+ const result = await render({
+ payload: payload(0),
+ runId: "run-partial-orchestrator",
+ usage: {
+ runId: "run-partial-orchestrator",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0.42,
+ sessionCount: 0,
+ activeSessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: false,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0.42 },
+ },
+ });
+
+ expect(stripAnsi(result.output)).toContain("run $0.42?");
+ });
+
+ it("keeps the existing run formula when orchestrator data is invalid", async () => {
+ const sessionId = "92d88cce-bdbc-46db-8573-916afd32f6f7";
+ const result = await render({
+ payload: { ...payload(0.25), session_id: sessionId },
+ sessionId,
+ runId: "run-invalid-orchestrator",
+ usage: {
+ runId: "run-invalid-orchestrator",
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ costUsd: 0.4,
+ sessionCount: 1,
+ activeSessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0.8,
+ costComplete: false,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [{ nativeId: sessionId, costUsd: "invalid" }],
+ },
+ },
+ });
+
+ expect(stripAnsi(result.output)).toContain("run $0.65");
+ expect(stripAnsi(result.output)).not.toContain("run $0.65?");
+ });
+
it("does not render a duplicate run session count", async () => {
const result = await render({
payload: payload(0),
From 60de66b9ff22c97e9d4200facea4eec1a6fdbf08 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:53:13 -0300
Subject: [PATCH 19/42] feat(usage): Route cumulative worker events through
ledger
Track each non-incremental worker source at its high-water mark while
keeping incremental events additive. Cover Claude process changes, Codex
thread totals, and event replay with hand-made stream fixtures.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 23 +++-
tests/fixtures/usage/claude-process-1.jsonl | 2 +
tests/fixtures/usage/claude-process-2.jsonl | 2 +
tests/fixtures/usage/codex-thread.jsonl | 3 +
tests/usage-daemon.test.ts | 111 +++++++++++++++-----
5 files changed, 110 insertions(+), 31 deletions(-)
create mode 100644 tests/fixtures/usage/claude-process-1.jsonl
create mode 100644 tests/fixtures/usage/claude-process-2.jsonl
create mode 100644 tests/fixtures/usage/codex-thread.jsonl
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 037a122..e4e4fb7 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -24,6 +24,8 @@ import { loadConfig, resolveDefaultSandbox } from "../config/config.js";
import { classifyFailure, RunAgentError, type FailureInfo } from "../core/errors.js";
import { getCachedOrDiscoverModels, type HarnessModels } from "../core/models.js";
import { aggregateRunUsage } from "../core/run-usage.js";
+import { UsageLedger } from "../store/usage-ledger.js";
+import { workerSourceKey } from "../core/usage-source.js";
// Daemon's view of power readiness for the doctor IPC result (field names
// fixed by cross-worker contract; the CLI falls back to local detection
@@ -82,6 +84,7 @@ class Daemon {
private sessions: SessionStore;
private events: EventStore;
private claims: ClaimsStore;
+ private usageLedger: UsageLedger;
private registry = getRegistry();
private server?: net.Server;
private subscribers = new Map>(); // sessionId -> sockets
@@ -118,6 +121,7 @@ class Daemon {
this.sessions = new SessionStore(handle);
this.events = new EventStore(handle);
this.claims = new ClaimsStore(handle);
+ this.usageLedger = new UsageLedger(handle);
}
async start(): Promise {
@@ -1263,7 +1267,7 @@ class Daemon {
}
if (inserted !== 0) {
// Update session status based on event.
- this.updateSessionFromEvent(sessionId, ev);
+ this.updateSessionFromEvent(sessionId, ev, inserted);
}
db.exec("COMMIT");
} catch (error) {
@@ -1330,7 +1334,7 @@ class Daemon {
if (!this.shuttingDown) void this.tryDispatch(sessionId);
}
- private updateSessionFromEvent(sessionId: string, ev: AgentEvent): void {
+ private updateSessionFromEvent(sessionId: string, ev: AgentEvent, sequence?: number): void {
try {
const current = this.sessions.get(sessionId);
if (current?.status === "stopped" && (ev.type === "session.completed" || ev.type === "session.failed")) {
@@ -1352,7 +1356,20 @@ class Daemon {
const sess = this.sessions.get(sessionId);
if (sess) {
const next = ev.usage || {};
- if (ev.incremental) {
+ const currentSequence = sequence ?? (
+ this.db.getHandle().prepare(
+ `SELECT COALESCE(MAX(sequence), 0) AS sequence FROM events WHERE session_id = ?`,
+ ).get(sessionId) as { sequence: number }
+ ).sequence;
+ const processOrdinal = this.db.getHandle().prepare(`
+ SELECT COUNT(*) AS count FROM events
+ WHERE session_id = ? AND type = 'session.started' AND sequence <= ?
+ `).get(sessionId, currentSequence) as { count: number };
+ const sourceKey = workerSourceKey(sess, Math.max(1, processOrdinal.count));
+ if (sourceKey && !ev.incremental) {
+ this.usageLedger.observe(sessionId, sourceKey, next);
+ if (next.model) this.sessions.update(sessionId, { model: next.model });
+ } else if (ev.incremental) {
const cur = sess.usage || {};
const inputTokens = (cur.inputTokens ?? 0) + (next.inputTokens ?? 0);
const outputTokens = (cur.outputTokens ?? 0) + (next.outputTokens ?? 0);
diff --git a/tests/fixtures/usage/claude-process-1.jsonl b/tests/fixtures/usage/claude-process-1.jsonl
new file mode 100644
index 0000000..a491904
--- /dev/null
+++ b/tests/fixtures/usage/claude-process-1.jsonl
@@ -0,0 +1,2 @@
+{"type":"system","subtype":"init","session_id":"2e990000-0000-4000-8000-000000000001"}
+{"type":"result","total_cost_usd":0.46615920000000005}
diff --git a/tests/fixtures/usage/claude-process-2.jsonl b/tests/fixtures/usage/claude-process-2.jsonl
new file mode 100644
index 0000000..7bce09a
--- /dev/null
+++ b/tests/fixtures/usage/claude-process-2.jsonl
@@ -0,0 +1,2 @@
+{"type":"system","subtype":"init","session_id":"2e990000-0000-4000-8000-000000000001"}
+{"type":"result","total_cost_usd":0.16164630000000002}
diff --git a/tests/fixtures/usage/codex-thread.jsonl b/tests/fixtures/usage/codex-thread.jsonl
new file mode 100644
index 0000000..1abdc84
--- /dev/null
+++ b/tests/fixtures/usage/codex-thread.jsonl
@@ -0,0 +1,3 @@
+{"type":"thread.started","thread_id":"01a0c9ee-1265-7c91-92d4-4564c24897f5"}
+{"type":"turn.completed","usage":{"input_tokens":10000000,"output_tokens":500,"cached_input_tokens":9000000}}
+{"type":"turn.completed","usage":{"input_tokens":10891738,"output_tokens":611,"cached_input_tokens":10551808}}
diff --git a/tests/usage-daemon.test.ts b/tests/usage-daemon.test.ts
index 8018f1d..cab9fda 100644
--- a/tests/usage-daemon.test.ts
+++ b/tests/usage-daemon.test.ts
@@ -4,6 +4,8 @@ import os from "node:os";
import path from "node:path";
import { Daemon } from "../src/daemon/daemon.js";
import type { RequestMethod } from "../src/daemon/protocol.js";
+import { parseClaudeLine } from "../src/drivers/claude/parser.js";
+import { parseCodexLine } from "../src/drivers/codex/parser.js";
import { fakeSocket, seed, seam } from "./helpers/daemon-seam.js";
let runAgentDir: string;
@@ -36,6 +38,26 @@ async function request(method: RequestMethod, params: unknown): Promise any[],
+ sourcePrefix: string,
+): Promise {
+ let eventIndex = 0;
+ const driver = {
+ getOffsets: () => undefined,
+ async *events() {
+ for (const line of lines) {
+ for (const event of parse(line, sessionId)) {
+ yield { ...event, sourceKey: `${sourcePrefix}:${eventIndex++}` };
+ }
+ }
+ },
+ };
+ await (daemon as any).attachDriverEvents(sessionId, driver, {});
+}
+
const createParams = (runId: unknown) => ({
prompt: "collect usage",
agent: "codex",
@@ -76,6 +98,15 @@ describe("usage daemon methods", () => {
activeSessionCount: 1,
costComplete: true,
sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0.5 },
});
});
@@ -117,52 +148,38 @@ describe("usage daemon methods", () => {
expect(response.result.byAgent[0].key).toBe("codex");
});
- it("accumulates incremental usage events across steps", async () => {
- const createResponse = await request("session.create", createParams("run-incremental"));
+ it("keeps cumulative usage updates at the source high-water mark", async () => {
+ const createResponse = await request("session.create", createParams("run-cumulative"));
const created = createResponse.result.session;
const daemonAny = daemon as any;
- // Step 1
daemonAny.updateSessionFromEvent(created.id, {
type: "usage.updated",
sessionId: created.id,
timestamp: new Date().toISOString(),
- incremental: true,
- usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 50, cost: 0.01 },
+ usage: { inputTokens: 300, outputTokens: 40, cachedTokens: 10, cost: 0.02 },
});
let sess = seam(daemon!).sessions.get(created.id);
expect(sess?.usage).toEqual({
- inputTokens: 100,
- outputTokens: 20,
- cachedTokens: 50,
- cost: 0.01,
- });
-
- // Step 2
- daemonAny.updateSessionFromEvent(created.id, {
- type: "usage.updated",
- sessionId: created.id,
- timestamp: new Date().toISOString(),
- incremental: true,
- usage: { inputTokens: 200, outputTokens: 30, cachedTokens: 80, cost: 0.02 },
+ inputTokens: 300,
+ outputTokens: 40,
+ cachedTokens: 10,
+ cost: 0.02,
});
- sess = seam(daemon!).sessions.get(created.id);
- expect(sess?.usage?.inputTokens).toBe(300);
- expect(sess?.usage?.outputTokens).toBe(50);
- expect(sess?.usage?.cachedTokens).toBe(130);
- expect(sess?.usage?.cost).toBeCloseTo(0.03, 5);
- // Non-incremental event overwrites
daemonAny.updateSessionFromEvent(created.id, {
type: "usage.updated",
sessionId: created.id,
timestamp: new Date().toISOString(),
- incremental: false,
usage: { inputTokens: 500, outputTokens: 100, cachedTokens: 0, cost: 0.1 },
});
sess = seam(daemon!).sessions.get(created.id);
- expect(sess?.usage?.inputTokens).toBe(500);
- expect(sess?.usage?.outputTokens).toBe(100);
+ expect(sess?.usage).toEqual({
+ inputTokens: 500,
+ outputTokens: 100,
+ cachedTokens: 10,
+ cost: 0.1,
+ });
});
it("accumulates incremental events without cost and computes table cost on usage.get", async () => {
@@ -209,5 +226,43 @@ describe("usage daemon methods", () => {
expect(usageResponse.result.costComplete).toBe(true);
expect(usageResponse.result.sessionsWithoutCost).toBe(0);
});
-});
+ it("adds cumulative Claude cost across processes and ignores replayed lines", async () => {
+ const sessionId = "claude-worker";
+ seed(daemon!, sessionId, "working", { agent: "claude", runId: "claude-run" });
+ const firstLines = fs.readFileSync(
+ path.join(process.cwd(), "tests/fixtures/usage/claude-process-1.jsonl"),
+ "utf-8",
+ ).trim().split("\n");
+ const secondLines = fs.readFileSync(
+ path.join(process.cwd(), "tests/fixtures/usage/claude-process-2.jsonl"),
+ "utf-8",
+ ).trim().split("\n");
+
+ await feedLines(sessionId, firstLines, parseClaudeLine, "claude-process-1");
+ expect(seam(daemon!).sessions.get(sessionId)?.usage?.cost).toBeCloseTo(0.46615920000000005);
+
+ await feedLines(sessionId, firstLines, parseClaudeLine, "claude-process-1");
+ expect(seam(daemon!).sessions.get(sessionId)?.usage?.cost).toBeCloseTo(0.46615920000000005);
+
+ await feedLines(sessionId, secondLines, parseClaudeLine, "claude-process-2");
+ expect(seam(daemon!).sessions.get(sessionId)?.usage?.cost).toBeCloseTo(0.6278055);
+ });
+
+ it("keeps the latest cumulative Codex thread token totals", async () => {
+ const sessionId = "codex-worker";
+ seed(daemon!, sessionId, "working", { agent: "codex", runId: "codex-run" });
+ const lines = fs.readFileSync(
+ path.join(process.cwd(), "tests/fixtures/usage/codex-thread.jsonl"),
+ "utf-8",
+ ).trim().split("\n");
+
+ await feedLines(sessionId, lines, parseCodexLine, "codex-thread");
+
+ expect(seam(daemon!).sessions.get(sessionId)?.usage).toMatchObject({
+ inputTokens: 10_891_738,
+ outputTokens: 611,
+ cachedTokens: 10_551_808,
+ });
+ });
+});
From e3a71b48dc7b57a5a15c28bc4246af04e79af6bb Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:55:22 -0300
Subject: [PATCH 20/42] feat(usage): Add native link and live observation IPC
Accept native ids only for open rows and validate reported costs. Return
worker and orchestrator usage with source attributions in usage.get.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 62 ++++++++++-
src/daemon/protocol.ts | 12 ++-
tests/orchestrator-usage-daemon.test.ts | 134 ++++++++++++++++++++++++
3 files changed, 204 insertions(+), 4 deletions(-)
create mode 100644 tests/orchestrator-usage-daemon.test.ts
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index e4e4fb7..1e2cfcd 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -25,7 +25,8 @@ import { classifyFailure, RunAgentError, type FailureInfo } from "../core/errors
import { getCachedOrDiscoverModels, type HarnessModels } from "../core/models.js";
import { aggregateRunUsage } from "../core/run-usage.js";
import { UsageLedger } from "../store/usage-ledger.js";
-import { workerSourceKey } from "../core/usage-source.js";
+import { openSourceKey, workerSourceKey } from "../core/usage-source.js";
+import { NativeLinkStore } from "../store/native-links.js";
// Daemon's view of power readiness for the doctor IPC result (field names
// fixed by cross-worker contract; the CLI falls back to local detection
@@ -85,6 +86,7 @@ class Daemon {
private events: EventStore;
private claims: ClaimsStore;
private usageLedger: UsageLedger;
+ private nativeLinks: NativeLinkStore;
private registry = getRegistry();
private server?: net.Server;
private subscribers = new Map>(); // sessionId -> sockets
@@ -122,6 +124,7 @@ class Daemon {
this.events = new EventStore(handle);
this.claims = new ClaimsStore(handle);
this.usageLedger = new UsageLedger(handle);
+ this.nativeLinks = new NativeLinkStore(handle);
}
async start(): Promise {
@@ -544,6 +547,26 @@ class Daemon {
break;
}
+ case "session.linkNative": {
+ const p = (params || {}) as { id?: unknown; nativeId?: unknown };
+ if (typeof p.id !== "string" || typeof p.nativeId !== "string" || p.nativeId.length === 0) {
+ send({ error: { code: "INVALID", message: "id and nativeId required" } });
+ return;
+ }
+ const session = this.sessions.get(p.id);
+ if (!session) {
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
+ return;
+ }
+ if (session.origin !== "open") {
+ send({ result: { created: false } });
+ return;
+ }
+ const { created } = this.nativeLinks.link(session.id, p.nativeId);
+ send({ result: { created } });
+ break;
+ }
+
case "session.release": {
const p = params as any;
const s = this.sessions.get(p.id);
@@ -981,13 +1004,46 @@ class Daemon {
}
case "usage.get": {
- const p = (params || {}) as { runId?: unknown };
+ const p = (params || {}) as {
+ runId?: unknown;
+ observe?: { nativeId?: unknown; costUsd?: unknown };
+ };
if (typeof p.runId !== "string" || p.runId.length === 0) {
send({ error: { code: "INVALID", message: "runId required" } });
return;
}
+ if (p.observe !== undefined) {
+ const { nativeId, costUsd } = p.observe;
+ if (
+ typeof nativeId !== "string" ||
+ nativeId.length === 0 ||
+ typeof costUsd !== "number" ||
+ !Number.isFinite(costUsd) ||
+ costUsd < 0
+ ) {
+ send({ error: { code: "INVALID", message: "observe requires nativeId and a finite non-negative costUsd" } });
+ return;
+ }
+ const session = this.sessions.getByRunId(p.runId)
+ .find((candidate) => candidate.id === p.runId && candidate.origin === "open");
+ if (session) {
+ const db = this.db.getHandle();
+ db.exec("BEGIN");
+ try {
+ this.nativeLinks.link(session.id, nativeId);
+ this.usageLedger.observe(session.id, openSourceKey(nativeId), { cost: costUsd });
+ db.exec("COMMIT");
+ } catch (error) {
+ try { db.exec("ROLLBACK"); } catch {}
+ throw error;
+ }
+ }
+ }
const sessions = this.sessions.getByRunId(p.runId);
- send({ result: aggregateRunUsage(p.runId, sessions) });
+ const sessionIds = sessions.map((session) => session.id);
+ const attributions = this.usageLedger.attributionsFor(sessionIds);
+ const linkStates = this.nativeLinks.linksFor(sessionIds).map(({ sessionId, state }) => ({ sessionId, state }));
+ send({ result: aggregateRunUsage(p.runId, sessions, attributions, linkStates) });
break;
}
diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts
index 9f5447d..83cf83c 100644
--- a/src/daemon/protocol.ts
+++ b/src/daemon/protocol.ts
@@ -10,6 +10,7 @@ export type RequestMethod =
| "session.adopt"
| "session.patch"
| "session.release"
+ | "session.linkNative"
| "session.list"
| "session.get"
| "session.rename"
@@ -87,6 +88,11 @@ export interface ReleaseSessionRequest {
};
}
+export interface LinkNativeSessionRequest {
+ method: "session.linkNative";
+ params: { id: string; nativeId: string };
+}
+
export interface ListSessionsRequest {
method: "session.list";
params: { all?: boolean; json?: boolean };
@@ -154,7 +160,10 @@ export interface ListModelsRequest {
export interface GetUsageRequest {
method: "usage.get";
- params: { runId: string };
+ params: {
+ runId: string;
+ observe?: { nativeId: string; costUsd: number };
+ };
}
export type UsagePeriod = "today" | "3d" | "7d" | "30d" | "all";
@@ -225,6 +234,7 @@ export type RequestParams =
| AdoptSessionRequest
| PatchSessionRequest
| ReleaseSessionRequest
+ | LinkNativeSessionRequest
| ListSessionsRequest
| GetSessionRequest
| RenameSessionRequest
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
new file mode 100644
index 0000000..6cc1715
--- /dev/null
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -0,0 +1,134 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Daemon } from "../src/daemon/daemon.js";
+import type { RequestMethod } from "../src/daemon/protocol.js";
+import { fakeSocket, makeTempDir, removeTempDir, seed, seam } from "./helpers/daemon-seam.js";
+
+let runAgentDir: string;
+let homeDir: string;
+let daemon: Daemon | undefined;
+const originalRunAgentDir = process.env.RUN_AGENT_DIR;
+const originalHome = process.env.HOME;
+let requestNumber = 0;
+
+beforeEach(() => {
+ runAgentDir = makeTempDir("orchestrator-usage-daemon-");
+ homeDir = makeTempDir("orchestrator-usage-home-");
+ process.env.RUN_AGENT_DIR = runAgentDir;
+ process.env.HOME = homeDir;
+ requestNumber = 0;
+ daemon = new Daemon();
+});
+
+afterEach(() => {
+ try { daemon && seam(daemon).db.close(); } catch {}
+ daemon = undefined;
+ if (originalRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
+ else process.env.RUN_AGENT_DIR = originalRunAgentDir;
+ if (originalHome === undefined) delete process.env.HOME;
+ else process.env.HOME = originalHome;
+ removeTempDir(runAgentDir);
+ removeTempDir(homeDir);
+});
+
+async function request(method: RequestMethod, params: unknown): Promise> {
+ const { writes, socket } = fakeSocket();
+ await seam(daemon!).handleRequest(
+ { id: `orchestrator-${++requestNumber}`, method, params },
+ socket,
+ );
+ return JSON.parse(writes[0]);
+}
+
+function seedOpen(id: string, extra: Record = {}): void {
+ seed(daemon!, id, "working", { runId: id, origin: "open", agent: "claude", ...extra });
+}
+
+describe("orchestrator usage daemon methods", () => {
+ it("links multiple native ids to an open row and reports whether each link was created", async () => {
+ seedOpen("open-link-row");
+
+ await expect(request("session.linkNative", { id: "open-link-row", nativeId: "native-x" }))
+ .resolves.toMatchObject({ result: { created: true } });
+ await expect(request("session.linkNative", { id: "open-link-row", nativeId: "native-y" }))
+ .resolves.toMatchObject({ result: { created: true } });
+ await expect(request("session.linkNative", { id: "open-link-row", nativeId: "native-x" }))
+ .resolves.toMatchObject({ result: { created: false } });
+
+ const links = (daemon as any).nativeLinks.linksFor(["open-link-row"]);
+ expect(links.map((link: { nativeId: string }) => link.nativeId)).toEqual(["native-x", "native-y"]);
+ });
+
+ it("records a finite live cost observation and returns updated run usage", async () => {
+ seedOpen("open-observe-row");
+ const response = await request("usage.get", {
+ runId: "open-observe-row",
+ observe: { nativeId: "native-observed", costUsd: 1.25 },
+ });
+
+ expect(response.result).toMatchObject({
+ runId: "open-observe-row",
+ sessionCount: 0,
+ costComplete: true,
+ costUsd: 0,
+ orchestrator: {
+ costUsd: 1.25,
+ sources: [{ nativeId: "native-observed", costUsd: 1.25 }],
+ },
+ total: { costUsd: 1.25 },
+ });
+ expect((daemon as any).nativeLinks.linksFor(["open-observe-row"])).toMatchObject([
+ { nativeId: "native-observed", state: null },
+ ]);
+ });
+
+ it.each([-0.01, Number.NaN, Number.POSITIVE_INFINITY])(
+ "rejects invalid observed cost %s",
+ async (costUsd) => {
+ seedOpen("open-invalid-observe");
+
+ const response = await request("usage.get", {
+ runId: "open-invalid-observe",
+ observe: { nativeId: "native-invalid", costUsd },
+ });
+
+ expect(response.error?.code).toBe("INVALID");
+ expect((daemon as any).nativeLinks.linksFor(["open-invalid-observe"])).toEqual([]);
+ },
+ );
+
+ it("drops valid observations when the run is missing or its row is not open", async () => {
+ seed(daemon!, "worker-run-row", "working", {
+ runId: "worker-run-row",
+ origin: "run",
+ agent: "codex",
+ });
+
+ const missing = await request("usage.get", {
+ runId: "missing-open-row",
+ observe: { nativeId: "native-drop", costUsd: 2 },
+ });
+ const nonOpen = await request("usage.get", {
+ runId: "worker-run-row",
+ observe: { nativeId: "native-drop", costUsd: 2 },
+ });
+
+ expect(missing.result.runId).toBe("missing-open-row");
+ expect(nonOpen.result.runId).toBe("worker-run-row");
+ expect((daemon as any).nativeLinks.linksFor(["worker-run-row"])).toEqual([]);
+ expect(seam(daemon!).sessions.get("worker-run-row")?.usage).toBeUndefined();
+ });
+
+ it("returns SESSION_NOT_FOUND for a missing link target and ignores non-open rows", async () => {
+ seed(daemon!, "worker-link-row", "working", { origin: "run" });
+
+ const missing = await request("session.linkNative", { id: "absent", nativeId: "native-x" });
+ const worker = await request("session.linkNative", { id: "worker-link-row", nativeId: "native-x" });
+
+ expect(missing.error?.code).toBe("SESSION_NOT_FOUND");
+ expect(worker.result).toEqual({ created: false });
+ expect((daemon as any).nativeLinks.linksFor(["worker-link-row"])).toEqual([]);
+ });
+});
From 4597c72f6dc2a39a8d75fd79a8000cb02722cbae Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:59:17 -0300
Subject: [PATCH 21/42] feat(usage): Reconcile linked Claude transcripts
Record transcript cost and token high-water values on release, and revisit
older links when an open row receives another native id. Keep release
status even when transcript reading fails.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 58 +++++++++++++-
tests/fixtures/usage/cost-state-10.jsonl | 1 +
tests/fixtures/usage/cost-state-15.jsonl | 1 +
tests/fixtures/usage/cost-state-3.jsonl | 1 +
tests/orchestrator-usage-daemon.test.ts | 96 +++++++++++++++++++++++-
5 files changed, 153 insertions(+), 4 deletions(-)
create mode 100644 tests/fixtures/usage/cost-state-10.jsonl
create mode 100644 tests/fixtures/usage/cost-state-15.jsonl
create mode 100644 tests/fixtures/usage/cost-state-3.jsonl
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 1e2cfcd..978932a 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -27,6 +27,7 @@ import { aggregateRunUsage } from "../core/run-usage.js";
import { UsageLedger } from "../store/usage-ledger.js";
import { openSourceKey, workerSourceKey } from "../core/usage-source.js";
import { NativeLinkStore } from "../store/native-links.js";
+import { findTranscript, readTranscriptUsage } from "../core/claude-transcript.js";
// Daemon's view of power readiness for the doctor IPC result (field names
// fixed by cross-worker contract; the CLI falls back to local detection
@@ -320,6 +321,53 @@ class Daemon {
}
}
+ private async reconcileOpenUsage(sessionId: string, nativeIds?: readonly string[]): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session || session.origin !== "open" || session.agent !== "claude") return;
+ const selectedIds = nativeIds === undefined ? undefined : new Set(nativeIds);
+
+ for (const link of this.nativeLinks.unreconciled(sessionId)) {
+ if (selectedIds && !selectedIds.has(link.nativeId)) continue;
+ const transcript = findTranscript(link.nativeId);
+ if (!transcript) {
+ this.nativeLinks.markReconciled(sessionId, link.nativeId, "missing");
+ continue;
+ }
+
+ const usage = await readTranscriptUsage(transcript);
+ const db = this.db.getHandle();
+ db.exec("BEGIN");
+ try {
+ this.usageLedger.observe(sessionId, openSourceKey(link.nativeId), {
+ cost: usage.cost,
+ inputTokens: usage.inputTokens,
+ outputTokens: usage.outputTokens,
+ cachedTokens: usage.cachedTokens,
+ model: usage.model,
+ });
+ this.nativeLinks.markReconciled(sessionId, link.nativeId, usage.state);
+ db.exec("COMMIT");
+ } catch (error) {
+ try { db.exec("ROLLBACK"); } catch {}
+ throw error;
+ }
+ }
+ }
+
+ private async reconcileOpenUsageSafely(sessionId: string, nativeIds?: readonly string[]): Promise {
+ try {
+ await this.reconcileOpenUsage(sessionId, nativeIds);
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ try {
+ fs.appendFileSync(
+ getPaths().daemonLog,
+ `[${new Date().toISOString()}] usage reconcile failed session=${sessionId}: ${detail}\n`,
+ );
+ } catch {}
+ }
+ }
+
private async handleRequest(req: IpcRequest, socket: net.Socket): Promise {
const { id, method, params } = req;
const send = (res: Omit) => {
@@ -562,7 +610,8 @@ class Daemon {
send({ result: { created: false } });
return;
}
- const { created } = this.nativeLinks.link(session.id, p.nativeId);
+ const { created, previous } = this.nativeLinks.link(session.id, p.nativeId);
+ if (previous.length > 0) await this.reconcileOpenUsageSafely(session.id, previous);
send({ result: { created } });
break;
}
@@ -602,6 +651,9 @@ class Daemon {
extra.lastEvent = "released";
}
this.sessions.setStatus(s.id, targetStatus, extra);
+ if (s.origin === "open" && s.agent === "claude") {
+ await this.reconcileOpenUsageSafely(s.id);
+ }
const ev: AgentEvent = targetStatus === "failed"
? {
@@ -1029,14 +1081,16 @@ class Daemon {
if (session) {
const db = this.db.getHandle();
db.exec("BEGIN");
+ let previous: string[] = [];
try {
- this.nativeLinks.link(session.id, nativeId);
+ previous = this.nativeLinks.link(session.id, nativeId).previous;
this.usageLedger.observe(session.id, openSourceKey(nativeId), { cost: costUsd });
db.exec("COMMIT");
} catch (error) {
try { db.exec("ROLLBACK"); } catch {}
throw error;
}
+ if (previous.length > 0) await this.reconcileOpenUsageSafely(session.id, previous);
}
}
const sessions = this.sessions.getByRunId(p.runId);
diff --git a/tests/fixtures/usage/cost-state-10.jsonl b/tests/fixtures/usage/cost-state-10.jsonl
new file mode 100644
index 0000000..5c64e20
--- /dev/null
+++ b/tests/fixtures/usage/cost-state-10.jsonl
@@ -0,0 +1 @@
+{"type":"cost-state","totalCostUSD":10,"modelUsage":{"claude-sonnet-4-5":{"inputTokens":1000,"outputTokens":200,"cacheReadInputTokens":30,"cacheCreationInputTokens":5,"costUSD":10}}}
diff --git a/tests/fixtures/usage/cost-state-15.jsonl b/tests/fixtures/usage/cost-state-15.jsonl
new file mode 100644
index 0000000..74925a7
--- /dev/null
+++ b/tests/fixtures/usage/cost-state-15.jsonl
@@ -0,0 +1 @@
+{"type":"cost-state","totalCostUSD":15,"modelUsage":{"claude-sonnet-4-5":{"inputTokens":1500,"outputTokens":300,"cacheReadInputTokens":45,"cacheCreationInputTokens":10,"costUSD":15}}}
diff --git a/tests/fixtures/usage/cost-state-3.jsonl b/tests/fixtures/usage/cost-state-3.jsonl
new file mode 100644
index 0000000..cf130dc
--- /dev/null
+++ b/tests/fixtures/usage/cost-state-3.jsonl
@@ -0,0 +1 @@
+{"type":"cost-state","totalCostUSD":3,"modelUsage":{"claude-sonnet-4-5":{"inputTokens":300,"outputTokens":100,"cacheReadInputTokens":15,"cacheCreationInputTokens":5,"costUSD":3}}}
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index 6cc1715..fc80787 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
-import os from "node:os";
import path from "node:path";
import { Daemon } from "../src/daemon/daemon.js";
import type { RequestMethod } from "../src/daemon/protocol.js";
+import type { Session } from "../src/core/session.js";
import { fakeSocket, makeTempDir, removeTempDir, seed, seam } from "./helpers/daemon-seam.js";
let runAgentDir: string;
@@ -42,10 +42,23 @@ async function request(method: RequestMethod, params: unknown): Promise = {}): void {
+function seedOpen(id: string, extra: Partial = {}): void {
seed(daemon!, id, "working", { runId: id, origin: "open", agent: "claude", ...extra });
}
+function transcriptFile(nativeId: string): string {
+ const projectDir = path.join(homeDir, ".claude", "projects", "fixture-project");
+ fs.mkdirSync(projectDir, { recursive: true });
+ return path.join(projectDir, `${nativeId}.jsonl`);
+}
+
+function installTranscript(nativeId: string, fixture: string): void {
+ fs.copyFileSync(
+ path.join(process.cwd(), "tests/fixtures/usage", fixture),
+ transcriptFile(nativeId),
+ );
+}
+
describe("orchestrator usage daemon methods", () => {
it("links multiple native ids to an open row and reports whether each link was created", async () => {
seedOpen("open-link-row");
@@ -131,4 +144,83 @@ describe("orchestrator usage daemon methods", () => {
expect(worker.result).toEqual({ created: false });
expect((daemon as any).nativeLinks.linksFor(["worker-link-row"])).toEqual([]);
});
+
+ it("reconciles transcript high-water totals across two open rows", async () => {
+ const nativeId = "native-shared";
+ seedOpen("row-a");
+ seedOpen("row-b");
+
+ await request("session.linkNative", { id: "row-a", nativeId });
+ await request("usage.get", { runId: "row-a", observe: { nativeId, costUsd: 4 } });
+ await request("usage.get", { runId: "row-a", observe: { nativeId, costUsd: 3.5 } });
+ installTranscript(nativeId, "cost-state-10.jsonl");
+ const releasedA = await request("session.release", { id: "row-a" });
+ expect(releasedA.result.session.status).toBe("completed");
+
+ await request("session.linkNative", { id: "row-b", nativeId });
+ await request("usage.get", { runId: "row-b", observe: { nativeId, costUsd: 10 } });
+ await request("usage.get", { runId: "row-b", observe: { nativeId, costUsd: 12 } });
+ installTranscript(nativeId, "cost-state-15.jsonl");
+ await request("session.release", { id: "row-b" });
+
+ expect(seam(daemon!).sessions.get("row-a")?.usage).toMatchObject({
+ cost: 10,
+ inputTokens: 1000,
+ outputTokens: 200,
+ cachedTokens: 35,
+ });
+ expect(seam(daemon!).sessions.get("row-b")?.usage).toMatchObject({
+ cost: 5,
+ inputTokens: 500,
+ outputTokens: 100,
+ cachedTokens: 20,
+ });
+ const query = await request("usage.query", { period: "all" });
+ expect(query.result.byOrigin.find((bucket: { key: string }) => bucket.key === "orchestrator").costUsd)
+ .toBe(15);
+ });
+
+ it("reconciles an earlier linked id when another id is added", async () => {
+ seedOpen("row-relink");
+ installTranscript("native-x", "cost-state-3.jsonl");
+ await request("session.linkNative", { id: "row-relink", nativeId: "native-x" });
+
+ await request("session.linkNative", { id: "row-relink", nativeId: "native-y" });
+
+ expect(seam(daemon!).sessions.get("row-relink")?.usage?.cost).toBe(3);
+ expect((daemon as any).nativeLinks.linksFor(["row-relink"])).toMatchObject([
+ { nativeId: "native-x", state: "cost-state" },
+ { nativeId: "native-y", state: null },
+ ]);
+ });
+
+ it.each(["completed", "failed"] as const)(
+ "releases an open row as %s when its transcript is missing",
+ async (status) => {
+ const rowId = `row-missing-${status}`;
+ seedOpen(rowId);
+ await request("session.linkNative", { id: rowId, nativeId: `native-missing-${status}` });
+
+ const response = await request("session.release", { id: rowId, status });
+
+ expect(response.result.session.status).toBe(status);
+ expect(seam(daemon!).sessions.get(rowId)?.usage).toBeUndefined();
+ expect((daemon as any).nativeLinks.linksFor([rowId])).toMatchObject([
+ { state: "missing" },
+ ]);
+ },
+ );
+
+ it("logs transcript read errors and still replies to release", async () => {
+ seedOpen("row-reader-error");
+ await request("session.linkNative", { id: "row-reader-error", nativeId: "native-reader-error" });
+ fs.mkdirSync(transcriptFile("native-reader-error"));
+
+ const response = await request("session.release", { id: "row-reader-error" });
+
+ expect(response.result.session.status).toBe("completed");
+ expect(fs.readFileSync(path.join(runAgentDir, "daemon.log"), "utf-8"))
+ .toContain("usage reconcile failed session=row-reader-error");
+ expect((daemon as any).nativeLinks.unreconciled("row-reader-error")).toHaveLength(1);
+ });
});
From 72be0dac9f54e3fd41e77bf48b41d3fd5bfe9b08 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:02:23 -0300
Subject: [PATCH 22/42] feat(usage): Reconcile stale open sessions at startup
Schedule transcript reconciliation after recovery without delaying the
socket listener. Preserve dead working rows that recovery terminalizes,
and keep the reconciliation promise available to tests.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 11 ++++
tests/orchestrator-usage-daemon.test.ts | 83 ++++++++++++++++++++++++-
2 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 978932a..9b60cd0 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -92,6 +92,7 @@ class Daemon {
private server?: net.Server;
private subscribers = new Map>(); // sessionId -> sockets
private startTime = Date.now();
+ private startupReconcilePromise: Promise = Promise.resolve();
private sessionLocks = new Set();
// Power-shutdown state. `shuttingDown` is set synchronously by the signal
// handler so concurrent handleRequest calls are refused during the drain.
@@ -130,8 +131,18 @@ class Daemon {
async start(): Promise {
const paths = getPaths();
+ const isDead = (session: Session) => !livePidIdentity(session);
+ // recover() terminalizes dead open rows, so retain candidates before it
+ // and combine them with any rows still stale afterward.
+ const staleBeforeRecover = this.nativeLinks.staleOpenRows(isDead);
// Recover orphaned sessions
await this.recover();
+ const staleRows = new Map();
+ for (const session of staleBeforeRecover) staleRows.set(session.id, session);
+ for (const session of this.nativeLinks.staleOpenRows(isDead)) staleRows.set(session.id, session);
+ this.startupReconcilePromise = Promise.all(
+ [...staleRows.keys()].map((sessionId) => this.reconcileOpenUsageSafely(sessionId)),
+ ).then(() => {});
this.server = createIpcServer(async (req, socket) => {
try {
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index fc80787..453d278 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { Daemon } from "../src/daemon/daemon.js";
import type { RequestMethod } from "../src/daemon/protocol.js";
import type { Session } from "../src/core/session.js";
+import { processStartTime } from "../src/utils/process.js";
import { fakeSocket, makeTempDir, removeTempDir, seed, seam } from "./helpers/daemon-seam.js";
let runAgentDir: string;
@@ -11,20 +12,48 @@ let homeDir: string;
let daemon: Daemon | undefined;
const originalRunAgentDir = process.env.RUN_AGENT_DIR;
const originalHome = process.env.HOME;
+let signalListenerSnapshot = new Map();
let requestNumber = 0;
+const shutdownSignals = ["SIGTERM", "SIGINT", "SIGHUP"];
+
+async function closeStartedDaemon(instance: Daemon): Promise {
+ const daemonAny = instance as any;
+ if (daemonAny.server?.listening) {
+ await new Promise((resolve) => daemonAny.server.close(() => resolve()));
+ }
+ for (const name of ["daemon.sock", "daemon.pid"]) {
+ try { fs.unlinkSync(path.join(runAgentDir, name)); } catch {}
+ }
+ try { daemonAny.db.close(); } catch {}
+}
+
+function removeAddedSignalListeners(): void {
+ for (const signal of shutdownSignals) {
+ const previous = signalListenerSnapshot.get(signal) ?? [];
+ for (const listener of process.listeners(signal as NodeJS.Signals)) {
+ if (!previous.includes(listener)) process.removeListener(signal as NodeJS.Signals, listener as (...args: any[]) => void);
+ }
+ }
+}
+
beforeEach(() => {
runAgentDir = makeTempDir("orchestrator-usage-daemon-");
homeDir = makeTempDir("orchestrator-usage-home-");
process.env.RUN_AGENT_DIR = runAgentDir;
process.env.HOME = homeDir;
requestNumber = 0;
+ signalListenerSnapshot = new Map(
+ shutdownSignals.map((signal) => [signal, process.listeners(signal as NodeJS.Signals)]),
+ );
daemon = new Daemon();
});
-afterEach(() => {
+afterEach(async () => {
+ if (daemon) await closeStartedDaemon(daemon);
try { daemon && seam(daemon).db.close(); } catch {}
daemon = undefined;
+ removeAddedSignalListeners();
if (originalRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
else process.env.RUN_AGENT_DIR = originalRunAgentDir;
if (originalHome === undefined) delete process.env.HOME;
@@ -223,4 +252,56 @@ describe("orchestrator usage daemon methods", () => {
.toContain("usage reconcile failed session=row-reader-error");
expect((daemon as any).nativeLinks.unreconciled("row-reader-error")).toHaveLength(1);
});
+
+ it("reconciles stale open rows after startup without reading a live row", async () => {
+ seed(daemon!, "startup-interrupted", "interrupted", {
+ runId: "startup-interrupted",
+ origin: "open",
+ agent: "claude",
+ });
+ seed(daemon!, "startup-dead", "working", {
+ runId: "startup-dead",
+ origin: "open",
+ agent: "claude",
+ pid: 999_999_999,
+ pidStartTime: "dead-process",
+ });
+ seed(daemon!, "startup-live", "working", {
+ runId: "startup-live",
+ origin: "open",
+ agent: "claude",
+ pid: process.pid,
+ pidStartTime: processStartTime(process.pid),
+ });
+ installTranscript("native-startup-interrupted", "cost-state-3.jsonl");
+ installTranscript("native-startup-dead", "cost-state-3.jsonl");
+ installTranscript("native-startup-live", "cost-state-3.jsonl");
+ await request("session.linkNative", { id: "startup-interrupted", nativeId: "native-startup-interrupted" });
+ await request("session.linkNative", { id: "startup-dead", nativeId: "native-startup-dead" });
+ await request("session.linkNative", { id: "startup-live", nativeId: "native-startup-live" });
+ (daemon as any).maybeSpawnInhibit = () => {};
+
+ const firstDaemon = daemon!;
+ await firstDaemon.start();
+ await (firstDaemon as any).startupReconcilePromise;
+
+ expect(seam(firstDaemon).sessions.get("startup-interrupted")?.usage?.cost).toBe(3);
+ expect(seam(firstDaemon).sessions.get("startup-dead")?.usage?.cost).toBe(3);
+ expect(seam(firstDaemon).sessions.get("startup-live")?.usage).toBeUndefined();
+ expect((firstDaemon as any).nativeLinks.unreconciled("startup-live")).toHaveLength(1);
+ const firstTotal = (await request("usage.query", { period: "all" })).result.byOrigin
+ .find((bucket: { key: string }) => bucket.key === "orchestrator").costUsd;
+
+ await closeStartedDaemon(firstDaemon);
+ removeAddedSignalListeners();
+ daemon = new Daemon();
+ (daemon as any).maybeSpawnInhibit = () => {};
+ await daemon.start();
+ await (daemon as any).startupReconcilePromise;
+
+ const secondTotal = (await request("usage.query", { period: "all" })).result.byOrigin
+ .find((bucket: { key: string }) => bucket.key === "orchestrator").costUsd;
+ expect(secondTotal).toBe(firstTotal);
+ expect(secondTotal).toBe(6);
+ });
});
From 7f0a07adf496f86120f69c3f8732b466b2984607 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:07:37 -0300
Subject: [PATCH 23/42] fix(usage): Preserve run id when resuming workers
Carry the stored run id through DriverSession.send so follow-up worker
processes keep CODEDECK_RUN_ID. Pass the run id to the initial driver start.
Co-Authored-By: Codex
---
src/core/driver.ts | 1 +
src/daemon/daemon.ts | 2 ++
src/drivers/session-driver.ts | 2 ++
tests/run-env.test.ts | 41 ++++++++++++++++++++++++++++++++++-
tests/usage-daemon.test.ts | 27 +++++++++++++++++++++++
5 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/src/core/driver.ts b/src/core/driver.ts
index 64b6639..c56f863 100644
--- a/src/core/driver.ts
+++ b/src/core/driver.ts
@@ -70,6 +70,7 @@ export interface StartOptions {
export interface DriverSession {
id: string;
+ runId?: string;
nativeSessionId?: string;
pid?: number;
// Linux /proc start tick captured with the PID; prevents killing/reattaching
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 9b60cd0..1d0d462 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -1180,6 +1180,7 @@ class Daemon {
sessionId,
prompt,
cwd: session.worktree || session.cwd,
+ runId: session.runId ?? undefined,
model: model || session.model,
// Read back from the session rather than the request so a follow-up turn
// from send() runs with the same effort/tier the session was started with.
@@ -1256,6 +1257,7 @@ class Daemon {
this.broadcast(s.id, turnEvent);
const drvSession: DriverSession = {
id: s.id,
+ runId: s.runId ?? undefined,
nativeSessionId: s.nativeSessionId,
cwd: s.worktree || s.cwd,
model: s.model,
diff --git a/src/drivers/session-driver.ts b/src/drivers/session-driver.ts
index 27cb688..bf1306c 100644
--- a/src/drivers/session-driver.ts
+++ b/src/drivers/session-driver.ts
@@ -101,6 +101,7 @@ export abstract class SessionDriver implements AgentDriver {
return {
id: options.sessionId,
+ runId: options.runId,
nativeSessionId: runtime.nativeSessionId,
pid: runtime.pid,
cwd: options.cwd,
@@ -135,6 +136,7 @@ export abstract class SessionDriver implements AgentDriver {
sessionId: session.id,
prompt: message,
cwd: session.cwd,
+ runId: session.runId,
model: session.model,
effort: session.effort,
fast: session.fast,
diff --git a/tests/run-env.test.ts b/tests/run-env.test.ts
index 10beb5c..bc412fb 100644
--- a/tests/run-env.test.ts
+++ b/tests/run-env.test.ts
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import type { AgentEvent } from "../src/core/events.js";
-import type { StartOptions } from "../src/core/driver.js";
+import type { DriverSession, StartOptions } from "../src/core/driver.js";
import { createRuntimeHooks, SessionDriver } from "../src/drivers/session-driver.js";
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-env-logs-"));
@@ -84,6 +84,32 @@ async function readEnvironment(runId?: string): Promise<{ present: boolean; valu
return output;
}
+async function drainEnvironment(
+ driver: EnvDriver,
+ session: DriverSession,
+): Promise<{ present: boolean; value: string | null }> {
+ let output: { present: boolean; value: string | null } | undefined;
+ for await (const event of driver.events(session)) {
+ if (event.type === "message") output = JSON.parse(String(event.content)) as typeof output;
+ }
+ if (!output) throw new Error("stub harness did not report its environment");
+ return output;
+}
+
+async function readResumedEnvironment(runId?: string): Promise<{ present: boolean; value: string | null }> {
+ const driver = new EnvDriver();
+ const session = await driver.start({
+ sessionId: `env-resume-${runId ?? "none"}-${Math.random().toString(36).slice(2)}`,
+ prompt: "",
+ cwd: os.tmpdir(),
+ runId,
+ resumeSessionId: "native-thread",
+ });
+ await drainEnvironment(driver, session);
+ await driver.send(session, "resume");
+ return drainEnvironment(driver, session);
+}
+
describe("worker run id environment", () => {
it("sets CODEDECK_RUN_ID when a run id is present", async () => {
await expect(readEnvironment("r1")).resolves.toEqual({ present: true, value: "r1" });
@@ -100,4 +126,17 @@ describe("worker run id environment", () => {
else process.env.CODEDECK_RUN_ID = previousRunId;
}
});
+
+ it("preserves CODEDECK_RUN_ID through a resumed send and omits it without a run", async () => {
+ await expect(readResumedEnvironment("r1")).resolves.toEqual({ present: true, value: "r1" });
+
+ const previousRunId = process.env.CODEDECK_RUN_ID;
+ process.env.CODEDECK_RUN_ID = "ambient-run";
+ try {
+ await expect(readResumedEnvironment()).resolves.toEqual({ present: false, value: null });
+ } finally {
+ if (previousRunId === undefined) delete process.env.CODEDECK_RUN_ID;
+ else process.env.CODEDECK_RUN_ID = previousRunId;
+ }
+ });
});
diff --git a/tests/usage-daemon.test.ts b/tests/usage-daemon.test.ts
index cab9fda..b35b5c8 100644
--- a/tests/usage-daemon.test.ts
+++ b/tests/usage-daemon.test.ts
@@ -117,6 +117,33 @@ describe("usage daemon methods", () => {
expect(seam(daemon!).sessions.get(created.id)?.runId).toBeUndefined();
});
+ it("passes the stored run id to the driver start options", async () => {
+ delete (daemon as any).startDriverForSession;
+ const originalDriver = (daemon as any).registry.get("codex");
+ const startOptions: Array<{ runId?: string }> = [];
+ try {
+ seam(daemon!).registry.register({
+ id: "codex",
+ start: async (options: { sessionId: string; cwd: string; runId?: string }) => {
+ startOptions.push(options);
+ return { id: options.sessionId, nativeSessionId: "thread-id", cwd: options.cwd };
+ },
+ async *events() {},
+ });
+ seed(daemon!, "daemon-run-session", "working", {
+ runId: "run-parent",
+ agent: "codex",
+ cwd: runAgentDir,
+ });
+
+ await (daemon as any).startDriverForSession("daemon-run-session", "task");
+
+ expect(startOptions).toMatchObject([{ runId: "run-parent" }]);
+ } finally {
+ seam(daemon!).registry.register(originalDriver);
+ }
+ });
+
it.each([
["empty", { runId: "" }],
["missing", {}],
From bd471cfb62aed3bc4a241593cf11e33f3b993c47 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:10:59 -0300
Subject: [PATCH 24/42] fix(usage): Retry terminal transcript reconciliations
Retry failed transcript reads for completed or failed open rows after a
daemon restart. Keep startup recovery non-blocking and verify the retry.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 21 ++++++++++++++-------
tests/orchestrator-usage-daemon.test.ts | 17 ++++++++++++++---
2 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 1d0d462..ade46ee 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -132,16 +132,23 @@ class Daemon {
async start(): Promise {
const paths = getPaths();
const isDead = (session: Session) => !livePidIdentity(session);
- // recover() terminalizes dead open rows, so retain candidates before it
- // and combine them with any rows still stale afterward.
- const staleBeforeRecover = this.nativeLinks.staleOpenRows(isDead);
// Recover orphaned sessions
await this.recover();
- const staleRows = new Map();
- for (const session of staleBeforeRecover) staleRows.set(session.id, session);
- for (const session of this.nativeLinks.staleOpenRows(isDead)) staleRows.set(session.id, session);
+ const staleSessionIds = new Set(
+ this.nativeLinks.staleOpenRows(isDead).map((session) => session.id),
+ );
+ const retryableRows = this.db.getHandle().prepare(`
+ SELECT DISTINCT sessions.id
+ FROM sessions
+ INNER JOIN session_native_links ON session_native_links.session_id = sessions.id
+ WHERE sessions.origin = 'open'
+ AND sessions.agent = 'claude'
+ AND sessions.status IN ('completed', 'failed')
+ AND session_native_links.reconciled_at IS NULL
+ `).all() as Array<{ id: string }>;
+ for (const row of retryableRows) staleSessionIds.add(row.id);
this.startupReconcilePromise = Promise.all(
- [...staleRows.keys()].map((sessionId) => this.reconcileOpenUsageSafely(sessionId)),
+ [...staleSessionIds].map((sessionId) => this.reconcileOpenUsageSafely(sessionId)),
).then(() => {});
this.server = createIpcServer(async (req, socket) => {
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index 453d278..c652d08 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -240,10 +240,12 @@ describe("orchestrator usage daemon methods", () => {
},
);
- it("logs transcript read errors and still replies to release", async () => {
+ it("logs transcript read errors, replies to release, and retries at startup", async () => {
seedOpen("row-reader-error");
- await request("session.linkNative", { id: "row-reader-error", nativeId: "native-reader-error" });
- fs.mkdirSync(transcriptFile("native-reader-error"));
+ const nativeId = "native-reader-error";
+ await request("session.linkNative", { id: "row-reader-error", nativeId });
+ const brokenTranscript = transcriptFile(nativeId);
+ fs.mkdirSync(brokenTranscript);
const response = await request("session.release", { id: "row-reader-error" });
@@ -251,6 +253,15 @@ describe("orchestrator usage daemon methods", () => {
expect(fs.readFileSync(path.join(runAgentDir, "daemon.log"), "utf-8"))
.toContain("usage reconcile failed session=row-reader-error");
expect((daemon as any).nativeLinks.unreconciled("row-reader-error")).toHaveLength(1);
+
+ fs.rmSync(brokenTranscript, { recursive: true, force: true });
+ installTranscript(nativeId, "cost-state-3.jsonl");
+ (daemon as any).maybeSpawnInhibit = () => {};
+ await daemon!.start();
+ await (daemon as any).startupReconcilePromise;
+
+ expect(seam(daemon!).sessions.get("row-reader-error")?.usage?.cost).toBe(3);
+ expect((daemon as any).nativeLinks.unreconciled("row-reader-error")).toHaveLength(0);
});
it("reconciles stale open rows after startup without reading a live row", async () => {
From 5c63a4de16cbfd4fc60469a7c02c5c7e1550b798 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:12:10 -0300
Subject: [PATCH 25/42] fix(usage): Use persisted process ordinal for source
keys
Derive Claude source numbering from the session.started count at the
current event sequence, matching the worker usage model.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index ade46ee..0c13358 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -1495,7 +1495,7 @@ class Daemon {
SELECT COUNT(*) AS count FROM events
WHERE session_id = ? AND type = 'session.started' AND sequence <= ?
`).get(sessionId, currentSequence) as { count: number };
- const sourceKey = workerSourceKey(sess, Math.max(1, processOrdinal.count));
+ const sourceKey = workerSourceKey(sess, processOrdinal.count);
if (sourceKey && !ev.incremental) {
this.usageLedger.observe(sessionId, sourceKey, next);
if (next.model) this.sessions.update(sessionId, { model: next.model });
From b366cd92c84ad8ede49b6f990b7efbe50c8f6ce6 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:55:27 -0300
Subject: [PATCH 26/42] feat(usage): Add live observations and origin output
---
src/cli/commands/usage.ts | 41 ++++++++++--
tests/usage-cli.test.ts | 134 ++++++++++++++++++++++++++++++++++++++
2 files changed, 170 insertions(+), 5 deletions(-)
create mode 100644 tests/usage-cli.test.ts
diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts
index 0c0e83c..53bd15d 100644
--- a/src/cli/commands/usage.ts
+++ b/src/cli/commands/usage.ts
@@ -5,6 +5,7 @@ import type { RunUsageSummary } from "../../core/run-usage.js";
import type { AgentId } from "../../core/session.js";
import type { UsagePeriod, UsageQueryParams, UsageQueryResult } from "../../daemon/protocol.js";
import { getPaths } from "../../config/paths.js";
+import { SESSION_ID_PATTERN } from "../../open/runtime.js";
import { Database } from "../../store/database.js";
import { SessionStore, resolveUsageDateRange } from "../../store/sessions.js";
import { renderSnapshot } from "../usage/snapshot.js";
@@ -23,10 +24,22 @@ export interface UsageCommandOptions {
model?: string;
agent?: string;
run?: string;
- by?: "day" | "repo" | "model" | "agent" | "run";
+ by?: "day" | "repo" | "model" | "agent" | "run" | "origin";
tui?: boolean;
watch?: boolean;
interval?: string;
+ observe?: string;
+}
+
+function parseUsageObservation(value: string | undefined): { nativeId: string; costUsd: number } | undefined {
+ if (value === undefined) return undefined;
+ const separator = value.lastIndexOf("=");
+ if (separator <= 0 || separator === value.length - 1) return undefined;
+
+ const nativeId = value.slice(0, separator);
+ const costUsd = Number(value.slice(separator + 1));
+ if (!SESSION_ID_PATTERN.test(nativeId) || !Number.isFinite(costUsd) || costUsd < 0) return undefined;
+ return { nativeId, costUsd };
}
export function formatUsageSummary(summary: RunUsageSummary): string {
@@ -34,6 +47,19 @@ export function formatUsageSummary(summary: RunUsageSummary): string {
return `Run ${summary.runId}: ${summary.sessionCount} sessions, ${summary.inputTokens} input / ${summary.outputTokens} output / ${summary.cachedTokens} cached tokens, cost ${cost}`;
}
+function renderUsageSnapshot(
+ result: UsageQueryResult,
+ options: { plain: boolean; by: UsageCommandOptions["by"] },
+): string {
+ const { plain, by } = options;
+ if (by !== "origin") return renderSnapshot(result, { plain, by });
+ const rows = result.byOrigin.map((bucket) => {
+ const cost = `$${bucket.costUsd.toFixed(2)}${bucket.costComplete ? "" : "?"}`;
+ return `${bucket.key}: ${bucket.sessionCount} sessions, ${bucket.inputTokens} input / ${bucket.outputTokens} output / ${bucket.cachedTokens} cached tokens, cost ${cost}`;
+ });
+ return ["Usage by origin", ...rows].join("\n");
+}
+
export async function fetchUsageQuery(params: UsageQueryParams): Promise {
const client = new IpcClient();
try {
@@ -92,7 +118,8 @@ export function registerUsageCommand(program: Command): void {
.option("-c, --current", "filter by current working directory repository")
.option("-m, --model ", "filter by model name")
.option("-a, --agent ", "filter by agent harness (e.g. codex, claude)")
- .option("--by ", "group by dimension: day, repo, model, agent, run")
+ .option("--by ", "group by dimension: day, repo, model, agent, run, origin")
+ .option("--observe ", "report live orchestrator cost")
.option("-i, --tui", "open interactive full-screen TUI dashboard")
.option("-w, --watch", "watch usage in real time with live updates")
.option("--interval ", "refresh interval for --watch (default: 2)", "2")
@@ -114,7 +141,11 @@ export function registerUsageCommand(program: Command): void {
let summary: RunUsageSummary;
try {
- summary = await client.request("usage.get", { runId: targetRunId });
+ const observe = parseUsageObservation(opts.observe);
+ summary = await client.request("usage.get", {
+ runId: targetRunId,
+ ...(observe ? { observe } : {}),
+ });
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 3;
@@ -183,7 +214,7 @@ export function registerUsageCommand(program: Command): void {
const intervalSec = Math.max(1, Number(opts.interval) || 2);
const printLive = async () => {
const res = await fetchUsageQuery(queryParams);
- const snap = renderSnapshot(res, { plain: false, by: opts.by });
+ const snap = renderUsageSnapshot(res, { plain: false, by: opts.by });
process.stdout.write(`\x1b[H\x1b[2J${snap}\n\n \x1b[2mUpdating every ${intervalSec}s... (Ctrl+C to quit)\x1b[0m\n`);
};
await printLive();
@@ -210,7 +241,7 @@ export function registerUsageCommand(program: Command): void {
console.log(JSON.stringify(result, null, 2));
} else {
const isPlain = opts.plain ?? !process.stdout.isTTY;
- console.log(renderSnapshot(result, { plain: isPlain, by: opts.by }));
+ console.log(renderUsageSnapshot(result, { plain: isPlain, by: opts.by }));
}
});
}
diff --git a/tests/usage-cli.test.ts b/tests/usage-cli.test.ts
new file mode 100644
index 0000000..528fcc1
--- /dev/null
+++ b/tests/usage-cli.test.ts
@@ -0,0 +1,134 @@
+import { Command } from "commander";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const ensureDaemonStarted = vi.fn(async () => {});
+const request = vi.fn();
+
+vi.mock("../src/daemon/ipc.js", () => ({
+ IpcClient: class {
+ ensureDaemonStarted = ensureDaemonStarted;
+ request = request;
+ },
+}));
+
+const { registerUsageCommand } = await import("../src/cli/commands/usage.js");
+
+const runSummary = {
+ runId: "run-1",
+ inputTokens: 100,
+ outputTokens: 20,
+ cachedTokens: 5,
+ costUsd: 0.25,
+ sessionCount: 1,
+ activeSessionCount: 0,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ orchestrator: {
+ costUsd: 0,
+ costComplete: true,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ sources: [],
+ },
+ total: { costUsd: 0.25 },
+};
+
+const usageResult = {
+ range: { period: "all", since: "2026-09-01T00:00:00.000Z", until: "2026-09-30T23:59:59.999Z" },
+ totals: {
+ sessionCount: 1,
+ activeSessionCount: 0,
+ completedSessionCount: 1,
+ failedSessionCount: 0,
+ inputTokens: 100,
+ outputTokens: 20,
+ cachedTokens: 5,
+ totalTokens: 125,
+ costUsd: 0.25,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ },
+ byDay: [],
+ byRepository: [],
+ byModel: [],
+ byAgent: [],
+ byRun: [],
+ byOrigin: [{ key: "orchestrator", sessionCount: 1, inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, costUsd: 0.25, costComplete: true }],
+};
+
+let logs: string[];
+let errors: string[];
+const originalExitCode = process.exitCode;
+
+function runProgram(argv: string[]): Promise {
+ const program = new Command();
+ program.exitOverride();
+ registerUsageCommand(program);
+ return program.parseAsync(["node", "codedeck", "usage", ...argv], { from: "node" });
+}
+
+beforeEach(() => {
+ process.exitCode = undefined;
+ logs = [];
+ errors = [];
+ ensureDaemonStarted.mockClear();
+ request.mockReset();
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => errors.push(args.join(" ")));
+});
+
+afterEach(() => {
+ process.exitCode = originalExitCode;
+ vi.restoreAllMocks();
+});
+
+describe("usage CLI", () => {
+ it("sends a valid native id and finite non-negative cost as an observation", async () => {
+ request.mockResolvedValue(runSummary);
+
+ await runProgram(["run-1", "--observe", "92d88cce-bdbc-46db-8573-916afd32f6f7=0.125", "--json"]);
+
+ expect(request).toHaveBeenCalledWith("usage.get", {
+ runId: "run-1",
+ observe: { nativeId: "92d88cce-bdbc-46db-8573-916afd32f6f7", costUsd: 0.125 },
+ });
+ expect(JSON.parse(logs[0]!)).toEqual(runSummary);
+ });
+
+ it.each([
+ ["malformed", "not-a-session-id=0.5"],
+ ["negative", "92d88cce-bdbc-46db-8573-916afd32f6f7=-0.01"],
+ ["non-finite", "92d88cce-bdbc-46db-8573-916afd32f6f7=Infinity"],
+ ["empty cost", "92d88cce-bdbc-46db-8573-916afd32f6f7="],
+ ])("ignores a %s observation and still prints the run aggregate", async (_label, value) => {
+ request.mockResolvedValue(runSummary);
+
+ await runProgram(["run-1", "--observe", value]);
+
+ expect(request).toHaveBeenCalledWith("usage.get", { runId: "run-1" });
+ expect(logs).toEqual([
+ "Run run-1: 1 sessions, 100 input / 20 output / 5 cached tokens, cost $0.25",
+ ]);
+ expect(errors).toEqual([]);
+ expect(process.exitCode).toBeUndefined();
+ });
+
+ it("prints the byOrigin buckets in the JSON response", async () => {
+ request.mockResolvedValue(usageResult);
+
+ await runProgram(["--all", "--by", "origin", "--json"]);
+
+ expect(JSON.parse(logs[0]!).byOrigin).toEqual(usageResult.byOrigin);
+ });
+
+ it("renders the byOrigin buckets when --by origin is used", async () => {
+ request.mockResolvedValue(usageResult);
+
+ await runProgram(["--all", "--by", "origin", "--plain"]);
+
+ expect(logs[0]).toContain("Usage by origin");
+ expect(logs[0]).toContain("orchestrator: 1 sessions");
+ expect(logs[0]).toContain("cost $0.25");
+ });
+});
From b3fc8941f5f90e90bde66c8c0ef8a4452a5c0cad Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:58:46 -0300
Subject: [PATCH 27/42] feat(open): Flush native links before release
---
src/cli/commands/open.ts | 32 +++++++++++++++++++
tests/open-contract.test.ts | 64 ++++++++++++++++++++++++++++++++++++-
2 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts
index d1569bd..70be2b4 100644
--- a/src/cli/commands/open.ts
+++ b/src/cli/commands/open.ts
@@ -23,6 +23,7 @@ import { createWorktree } from "../../git/worktree.js";
import { ptyShimPath, type PtyLaunch } from "../../open/pty.js";
import { isInteractiveTerminal } from "./setup.js";
import { sessionsDir } from "../../open/pty.js";
+import { startLinkWatcher } from "../../open/link-watcher.js";
import { ROLES, parseRole, resolvePluginDir, type Role } from "../../core/roles.js";
import { getCliName } from "../cli-name.js";
@@ -656,6 +657,27 @@ export function registerOpenCommand(program: Command): void {
...(opts.resume !== undefined ? { resume: opts.resume } : {}),
});
const runId = adoptRes.session.id;
+ let linkWatcher: ReturnType | undefined;
+ let linkWatcherFlush: Promise | undefined;
+ const flushLinkWatcher = async () => {
+ if (linkWatcherFlush !== undefined) {
+ await linkWatcherFlush;
+ return;
+ }
+ if (linkWatcher === undefined) return;
+
+ const watcher = linkWatcher;
+ linkWatcherFlush = (async () => {
+ try {
+ await watcher.flush();
+ } catch {}
+ try {
+ watcher.stop();
+ } catch {}
+ linkWatcher = undefined;
+ })();
+ await linkWatcherFlush;
+ };
let patchPromise: Promise | undefined;
try {
@@ -712,6 +734,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
+ await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -812,6 +835,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
+ await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -872,12 +896,19 @@ export function registerOpenCommand(program: Command): void {
await playBoot(role, model, effort);
}
+ linkWatcher = startLinkWatcher({
+ sessionFile,
+ runId,
+ link: (id, nativeId) => client.request("session.linkNative", { id, nativeId }),
+ });
+
const closeClaude = async () => {
if (!fs.existsSync(sessionFile) && opts.resume && SESSION_ID_PATTERN.test(opts.resume)) {
try {
fs.writeFileSync(sessionFile, opts.resume);
} catch {}
}
+ await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -911,6 +942,7 @@ export function registerOpenCommand(program: Command): void {
},
);
} catch (err: unknown) {
+ await flushLinkWatcher();
try {
await client.request("session.release", {
id: runId,
diff --git a/tests/open-contract.test.ts b/tests/open-contract.test.ts
index 677364c..334613d 100644
--- a/tests/open-contract.test.ts
+++ b/tests/open-contract.test.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
effectiveModel,
@@ -12,6 +12,11 @@ import {
} from "../src/open/contract.js";
import type { HarnessModels } from "../src/core/models.js";
import { resolvePluginDir } from "../src/core/roles.js";
+import { IpcClient } from "../src/daemon/ipc.js";
+import * as claudeLauncher from "../src/open/launchers/claude.js";
+import * as linkWatcher from "../src/open/link-watcher.js";
+import * as runtime from "../src/open/runtime.js";
+import { setupOpenHarness } from "./helpers/open-harness.js";
const catalog = (models: string[]): HarnessModels => ({
agent: "opencode",
@@ -26,6 +31,19 @@ const catalog = (models: string[]): HarnessModels => ({
});
const pluginDir = resolvePluginDir();
+const { runOpen } = setupOpenHarness({ prefix: "codedeck-open-link-contract-" });
+
+function runClaudeOpen(argv: string[]): Promise {
+ const configDir = process.env.RUN_AGENT_CONFIG_DIR;
+ if (!configDir) throw new Error("test config directory is missing");
+ fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
+ agents: { reviewer: { harness: "claude", model: "claude-sonnet-4-6", effort: "high" } },
+ }));
+ vi.spyOn(claudeLauncher, "preflightModel").mockResolvedValue(undefined);
+ vi.spyOn(claudeLauncher, "resolveBinary").mockResolvedValue("/bin/claude");
+ vi.spyOn(claudeLauncher, "assertSupport").mockResolvedValue(undefined);
+ return runOpen(argv);
+}
afterEach(() => {
delete process.env.CODEDECK_CLI_NAME;
@@ -204,3 +222,47 @@ describe("effectiveModel", () => {
expect(effectiveModel(["--add-dir", "other"])).toBeUndefined();
});
});
+
+describe("open native session linking", () => {
+ it("links every sidecar id before releasing the Claude row", async () => {
+ const nativeId = "92d88cce-bdbc-46db-8573-916afd32f6f7";
+ await runClaudeOpen(["reviewer", "--no-theme", "--no-worktree"]);
+
+ const [, , options] = vi.mocked(runtime.spawnHarness).mock.calls[0]!;
+ fs.writeFileSync(options.sessionFile, `${nativeId}\n`);
+ await options.onClose();
+
+ const lifecycleCalls = vi.mocked(IpcClient.prototype.request).mock.calls
+ .filter(([method]) => method === "session.linkNative" || method === "session.release");
+ expect(lifecycleCalls.map(([method]) => method)).toEqual([
+ "session.linkNative",
+ "session.release",
+ ]);
+ expect(lifecycleCalls[0]?.[1]).toEqual({ id: "0001", nativeId });
+ });
+
+ it("stops the watcher and releases the session when flush rejects", async () => {
+ const events: string[] = [];
+ const nativeWatcher = {
+ flush: vi.fn(async () => {
+ events.push("flush");
+ throw new Error("link failed");
+ }),
+ stop: vi.fn(() => {
+ events.push("stop");
+ }),
+ };
+ vi.spyOn(linkWatcher, "startLinkWatcher").mockReturnValue(nativeWatcher);
+ vi.mocked(runtime.finishOpenSession).mockImplementation(() => {
+ events.push("finish");
+ });
+
+ await runClaudeOpen(["reviewer", "--no-theme", "--no-worktree"]);
+ const [, , options] = vi.mocked(runtime.spawnHarness).mock.calls[0]!;
+ await options.onClose();
+
+ expect(events).toEqual(["flush", "stop", "finish"]);
+ expect(vi.mocked(IpcClient.prototype.request).mock.calls.map(([method]) => method))
+ .toContain("session.release");
+ });
+});
From ea58f013fb5530154145e05270344b859b158ec8 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:02:36 -0300
Subject: [PATCH 28/42] feat(usage): Merge legacy orchestrator entries
---
src/store/database.ts | 12 +++
src/store/sessions.ts | 162 +++++++++++++++++++++++++++++--------
tests/usage-query.test.ts | 59 ++++++++++++++
tests/usage-schema.test.ts | 18 +++++
4 files changed, 217 insertions(+), 34 deletions(-)
diff --git a/src/store/database.ts b/src/store/database.ts
index 29a6349..2f65d86 100644
--- a/src/store/database.ts
+++ b/src/store/database.ts
@@ -125,6 +125,18 @@ export class Database {
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS usage_legacy (
+ native_id TEXT PRIMARY KEY,
+ ended_at TEXT NOT NULL,
+ cwd TEXT,
+ repository TEXT,
+ model TEXT,
+ cost REAL,
+ input_tokens INTEGER NOT NULL DEFAULT 0,
+ output_tokens INTEGER NOT NULL DEFAULT 0,
+ cached_tokens INTEGER NOT NULL DEFAULT 0
+ );
+
CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC);
diff --git a/src/store/sessions.ts b/src/store/sessions.ts
index 6541ace..669be28 100644
--- a/src/store/sessions.ts
+++ b/src/store/sessions.ts
@@ -39,6 +39,18 @@ export interface SessionRow {
pending_at: string | null;
}
+interface UsageLegacyRow {
+ native_id: string;
+ ended_at: string;
+ cwd: string | null;
+ repository: string | null;
+ model: string | null;
+ cost: number | null;
+ input_tokens: number;
+ output_tokens: number;
+ cached_tokens: number;
+}
+
function rowToSession(row: SessionRow): Session {
// `failure` is stored as JSON text; tolerate a corrupt blob rather than
@@ -333,6 +345,18 @@ export class SessionStore {
usage_cost: number | null;
origin: string | null;
}>;
+ const hasLegacyTable = this.db.prepare(
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'usage_legacy'`,
+ ).get() !== undefined;
+ const legacyRows: UsageLegacyRow[] = hasLegacyTable
+ ? this.db.prepare(`
+ SELECT native_id, ended_at, cwd, repository, model, cost,
+ input_tokens, output_tokens, cached_tokens
+ FROM usage_legacy
+ WHERE ended_at >= ? AND ended_at <= ?
+ ORDER BY ended_at ASC
+ `).all(since, until) as unknown as UsageLegacyRow[]
+ : [];
const totals: UsageTotals = {
sessionCount: 0,
@@ -360,6 +384,43 @@ export class SessionStore {
const agentFilter = params.agent;
const runIdFilter = params.runId;
+ const accumulate = (
+ map: Map,
+ key: string,
+ inputTokens: number,
+ outputTokens: number,
+ cachedTokens: number,
+ totalTokens: number,
+ cost: number | null,
+ label?: string,
+ ) => {
+ let bucket = map.get(key);
+ if (!bucket) {
+ bucket = {
+ key,
+ label: label ?? key,
+ sessionCount: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ totalTokens: 0,
+ costUsd: 0,
+ costComplete: true,
+ };
+ map.set(key, bucket);
+ }
+ bucket.sessionCount++;
+ bucket.inputTokens += inputTokens;
+ bucket.outputTokens += outputTokens;
+ bucket.cachedTokens += cachedTokens;
+ bucket.totalTokens += totalTokens;
+ if (cost === null) {
+ bucket.costComplete = false;
+ } else {
+ bucket.costUsd += cost;
+ }
+ };
+
for (const row of rows) {
if (runIdFilter && row.run_id !== runIdFilter) continue;
if (agentFilter && row.agent !== agentFilter) continue;
@@ -399,55 +460,88 @@ export class SessionStore {
totals.costUsd += cost;
}
- const accumulate = (map: Map, key: string, label?: string) => {
- let bucket = map.get(key);
- if (!bucket) {
- bucket = {
- key,
- label: label ?? key,
- sessionCount: 0,
- inputTokens: 0,
- outputTokens: 0,
- cachedTokens: 0,
- totalTokens: 0,
- costUsd: 0,
- costComplete: true,
- };
- map.set(key, bucket);
- }
- bucket.sessionCount++;
- bucket.inputTokens += inputTokens;
- bucket.outputTokens += outputTokens;
- bucket.cachedTokens += cachedTokens;
- bucket.totalTokens += totalTokens;
- if (cost === null) {
- bucket.costComplete = false;
- } else {
- bucket.costUsd += cost;
- }
- };
-
// Day (local date string YYYY-MM-DD)
const d = new Date(row.created_at);
const dayKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
- accumulate(byDayMap, dayKey);
+ accumulate(byDayMap, dayKey, inputTokens, outputTokens, cachedTokens, totalTokens, cost);
// Repo (normalized project name across worktrees)
const repoKey = normalizeProjectName(row);
- accumulate(byRepoMap, repoKey);
+ accumulate(byRepoMap, repoKey, inputTokens, outputTokens, cachedTokens, totalTokens, cost);
// Model
- accumulate(byModelMap, row.model || "unknown");
+ accumulate(byModelMap, row.model || "unknown", inputTokens, outputTokens, cachedTokens, totalTokens, cost);
// Agent
- accumulate(byAgentMap, row.agent);
+ accumulate(byAgentMap, row.agent, inputTokens, outputTokens, cachedTokens, totalTokens, cost);
// Run
if (row.run_id) {
- accumulate(byRunMap, row.run_id, row.name ? `${row.name} (${row.run_id.slice(0, 8)})` : row.run_id.slice(0, 8));
+ accumulate(
+ byRunMap,
+ row.run_id,
+ inputTokens,
+ outputTokens,
+ cachedTokens,
+ totalTokens,
+ cost,
+ row.name ? `${row.name} (${row.run_id.slice(0, 8)})` : row.run_id.slice(0, 8),
+ );
}
- accumulate(byOriginMap, row.origin === "open" ? "orchestrator" : "worker");
+ accumulate(
+ byOriginMap,
+ row.origin === "open" ? "orchestrator" : "worker",
+ inputTokens,
+ outputTokens,
+ cachedTokens,
+ totalTokens,
+ cost,
+ );
+ }
+
+ for (const row of legacyRows) {
+ if (runIdFilter) continue;
+ if (agentFilter && agentFilter !== "claude") continue;
+ if (modelFilter && (!row.model || !row.model.toLowerCase().includes(modelFilter))) continue;
+ if (repoFilter) {
+ const repoStr = [row.repository, row.cwd].filter(Boolean).join(" ").toLowerCase();
+ if (!repoStr.includes(repoFilter)) continue;
+ }
+
+ const inputTokens = row.input_tokens;
+ const outputTokens = row.output_tokens;
+ const cachedTokens = row.cached_tokens;
+ const totalTokens = inputTokens + outputTokens + cachedTokens;
+ const cost = row.cost;
+
+ totals.sessionCount++;
+ totals.inputTokens += inputTokens;
+ totals.outputTokens += outputTokens;
+ totals.cachedTokens += cachedTokens;
+ totals.totalTokens += totalTokens;
+ if (cost === null) {
+ totals.costComplete = false;
+ totals.sessionsWithoutCost++;
+ } else {
+ totals.costUsd += cost;
+ }
+
+ const d = new Date(row.ended_at);
+ const dayKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ accumulate(byDayMap, dayKey, inputTokens, outputTokens, cachedTokens, totalTokens, cost);
+ accumulate(
+ byRepoMap,
+ normalizeProjectName({ repository: row.repository, cwd: row.cwd }),
+ inputTokens,
+ outputTokens,
+ cachedTokens,
+ totalTokens,
+ cost,
+ );
+ accumulate(byModelMap, row.model || "unknown", inputTokens, outputTokens, cachedTokens, totalTokens, cost);
+ accumulate(byAgentMap, "claude", inputTokens, outputTokens, cachedTokens, totalTokens, cost);
+ accumulate(byOriginMap, "orchestrator", inputTokens, outputTokens, cachedTokens, totalTokens, cost);
}
const sortDescending = (a: UsageMetricBucket, b: UsageMetricBucket) => {
diff --git a/tests/usage-query.test.ts b/tests/usage-query.test.ts
index 5e559d6..e2c5b08 100644
--- a/tests/usage-query.test.ts
+++ b/tests/usage-query.test.ts
@@ -124,6 +124,65 @@ describe("SessionStore.queryUsage", () => {
expect(worker).toMatchObject({ sessionCount: 2, costUsd: 1.25 });
});
+ it("merges in-range legacy usage into analytics without listing it as a session", () => {
+ const endedAt = new Date(2026, 8, 7, 12).toISOString();
+ const outsideRange = new Date(2026, 7, 31, 12).toISOString();
+ const insertLegacy = db.getHandle().prepare(`
+ INSERT INTO usage_legacy (
+ native_id, ended_at, cwd, repository, model, cost,
+ input_tokens, output_tokens, cached_tokens
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ insertLegacy.run(
+ "native-in-range",
+ endedAt,
+ "/srv/legacy-repo/project",
+ "/srv/legacy-repo",
+ "claude-sonnet-4-6",
+ 1.25,
+ 120,
+ 30,
+ 15,
+ );
+ insertLegacy.run(
+ "native-out-of-range",
+ outsideRange,
+ "/srv/old-repo/project",
+ "/srv/old-repo",
+ "claude-sonnet-4-6",
+ 99,
+ 900,
+ 90,
+ 9,
+ );
+
+ const result = store.queryUsage({
+ since: new Date(2026, 8, 1).toISOString(),
+ until: new Date(2026, 8, 30, 23, 59, 59, 999).toISOString(),
+ });
+ const localDay = `${new Date(endedAt).getFullYear()}-${String(new Date(endedAt).getMonth() + 1).padStart(2, "0")}-${String(new Date(endedAt).getDate()).padStart(2, "0")}`;
+
+ expect(result.totals).toMatchObject({
+ sessionCount: 1,
+ inputTokens: 120,
+ outputTokens: 30,
+ cachedTokens: 15,
+ totalTokens: 165,
+ costUsd: 1.25,
+ costComplete: true,
+ sessionsWithoutCost: 0,
+ });
+ expect(result.byDay[0]).toMatchObject({ key: localDay, sessionCount: 1, costUsd: 1.25 });
+ expect(result.byRepository[0]).toMatchObject({ key: "legacy-repo", costUsd: 1.25 });
+ expect(result.byModel[0]).toMatchObject({ key: "claude-sonnet-4-6", costUsd: 1.25 });
+ expect(result.byAgent[0]).toMatchObject({ key: "claude", costUsd: 1.25 });
+ expect(result.byOrigin).toMatchObject([
+ { key: "orchestrator", sessionCount: 1, costUsd: 1.25 },
+ ]);
+ expect(result.byRun).toEqual([]);
+ expect(store.list(50, true)).toEqual([]);
+ });
+
it("marks costComplete as false when encountering unpriced models without reported cost", () => {
store.create(
makeSession("s-unknown", {
diff --git a/tests/usage-schema.test.ts b/tests/usage-schema.test.ts
index 7b57fbd..94ab635 100644
--- a/tests/usage-schema.test.ts
+++ b/tests/usage-schema.test.ts
@@ -29,11 +29,26 @@ describe("usage database schema", () => {
"usage_sources",
"usage_attributions",
"session_native_links",
+ "usage_legacy",
]));
const indexes = (db.getHandle().prepare(
`PRAGMA index_list(usage_attributions)`,
).all() as Array<{ name: string }>).map((row) => row.name);
expect(indexes).toContain("idx_usage_attributions_source_key");
+ const legacyColumns = (db.getHandle().prepare(
+ `PRAGMA table_info(usage_legacy)`,
+ ).all() as Array<{ name: string }>).map((row) => row.name);
+ expect(legacyColumns).toEqual(expect.arrayContaining([
+ "native_id",
+ "ended_at",
+ "cwd",
+ "repository",
+ "model",
+ "cost",
+ "input_tokens",
+ "output_tokens",
+ "cached_tokens",
+ ]));
} finally {
db.close();
}
@@ -47,6 +62,7 @@ describe("usage database schema", () => {
DROP TABLE session_native_links;
DROP TABLE usage_attributions;
DROP TABLE usage_sources;
+ DROP TABLE usage_legacy;
`);
original.close();
@@ -56,6 +72,7 @@ describe("usage database schema", () => {
"usage_sources",
"usage_attributions",
"session_native_links",
+ "usage_legacy",
]));
} finally {
migrated.close();
@@ -66,6 +83,7 @@ describe("usage database schema", () => {
"usage_sources",
"usage_attributions",
"session_native_links",
+ "usage_legacy",
]));
reopened.close();
});
From 3693f1bc282a30e3f71b2eaf6a647649304e7ab9 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:09:14 -0300
Subject: [PATCH 29/42] feat(usage): Add historical orchestrator backfill
---
src/cli/commands/usage-backfill.ts | 177 ++++++++++++++++++++
src/cli/commands/usage.ts | 15 ++
tests/usage-backfill.test.ts | 255 +++++++++++++++++++++++++++++
3 files changed, 447 insertions(+)
create mode 100644 src/cli/commands/usage-backfill.ts
create mode 100644 tests/usage-backfill.test.ts
diff --git a/src/cli/commands/usage-backfill.ts b/src/cli/commands/usage-backfill.ts
new file mode 100644
index 0000000..b0964fc
--- /dev/null
+++ b/src/cli/commands/usage-backfill.ts
@@ -0,0 +1,177 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { DatabaseSync } from "node:sqlite";
+import { findTranscript, readTranscriptUsage } from "../../core/claude-transcript.js";
+import { openSourceKey } from "../../core/usage-source.js";
+import { getPaths } from "../../config/paths.js";
+import { SESSION_ID_PATTERN } from "../../open/runtime.js";
+import { Database } from "../../store/database.js";
+import { UsageLedger } from "../../store/usage-ledger.js";
+
+export interface UsageBackfillSummary {
+ imported: number;
+ skipped: number;
+}
+
+interface NativeSessionRow {
+ native_session_id: string;
+}
+
+function addId(ids: Set, value: string): void {
+ const nativeId = value.trim();
+ if (SESSION_ID_PATTERN.test(nativeId)) ids.add(nativeId);
+}
+
+function collectNativeIds(
+ db: DatabaseSync,
+ sessionsDir: string,
+): { ids: Set; workerIds: Set } {
+ const ids = new Set();
+ const openRows = db.prepare(`
+ SELECT native_session_id FROM sessions
+ WHERE origin = 'open' AND native_session_id IS NOT NULL
+ `).all() as unknown as NativeSessionRow[];
+ for (const row of openRows) addId(ids, row.native_session_id);
+
+ const workerRows = db.prepare(`
+ SELECT native_session_id FROM sessions
+ WHERE native_session_id IS NOT NULL AND (origin IS NULL OR origin <> 'open')
+ `).all() as unknown as NativeSessionRow[];
+ const workerIds = new Set(workerRows.map((row) => row.native_session_id));
+
+ let files: string[];
+ try {
+ files = fs.readdirSync(sessionsDir);
+ } catch {
+ return { ids, workerIds };
+ }
+
+ for (const file of files) {
+ const nameSidecar = file.match(/^codedeck-session-\d+\.([^.]+)\.name$/);
+ if (nameSidecar?.[1]) addId(ids, nameSidecar[1]);
+
+ if (!/^codedeck-session-\d+$/.test(file)) continue;
+ let contents: string;
+ try {
+ contents = fs.readFileSync(path.join(sessionsDir, file), "utf8");
+ } catch {
+ continue;
+ }
+ for (const line of contents.split(/\r?\n/)) addId(ids, line);
+ }
+
+ return { ids, workerIds };
+}
+
+function repositoryRoot(cwd: string | undefined): string | null {
+ if (cwd === undefined) return null;
+ let current = path.resolve(cwd);
+ while (true) {
+ if (fs.existsSync(path.join(current, ".git"))) return current;
+ const parent = path.dirname(current);
+ if (parent === current) return null;
+ current = parent;
+ }
+}
+
+function importLegacyUsage(
+ db: DatabaseSync,
+ ledger: UsageLedger,
+ nativeId: string,
+ usage: Awaited>,
+): boolean {
+ const sourceKey = openSourceKey(nativeId);
+ const endedAt = usage.endedAt;
+ if (endedAt === undefined) return false;
+
+ db.exec("BEGIN IMMEDIATE");
+ try {
+ if (ledger.hasSource(sourceKey)) {
+ db.exec("ROLLBACK");
+ return false;
+ }
+
+ const cost = usage.cost ?? null;
+ const inputTokens = usage.inputTokens;
+ const outputTokens = usage.outputTokens;
+ const cachedTokens = usage.cachedTokens;
+ const updatedAt = new Date().toISOString();
+ db.prepare(`
+ INSERT INTO usage_legacy (
+ native_id, ended_at, cwd, repository, model, cost,
+ input_tokens, output_tokens, cached_tokens
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ nativeId,
+ endedAt,
+ usage.cwd ?? null,
+ repositoryRoot(usage.cwd ?? undefined),
+ usage.model ?? null,
+ cost,
+ inputTokens,
+ outputTokens,
+ cachedTokens,
+ );
+ db.prepare(`
+ INSERT INTO usage_sources (
+ source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `).run(sourceKey, cost, inputTokens, outputTokens, cachedTokens, updatedAt);
+ db.exec("COMMIT");
+ return true;
+ } catch (error) {
+ try {
+ db.exec("ROLLBACK");
+ } catch {}
+ throw error;
+ }
+}
+
+export async function backfillUsage(): Promise {
+ const paths = getPaths();
+ const db = new Database(paths.db);
+ try {
+ const handle = db.getHandle();
+ const ledger = new UsageLedger(handle);
+ const { ids, workerIds } = collectNativeIds(handle, paths.sessionsDir);
+ const summary: UsageBackfillSummary = { imported: 0, skipped: 0 };
+
+ for (const nativeId of ids) {
+ if (workerIds.has(nativeId)) {
+ summary.skipped++;
+ continue;
+ }
+
+ const sourceKey = openSourceKey(nativeId);
+ if (ledger.hasSource(sourceKey)) {
+ summary.skipped++;
+ continue;
+ }
+
+ const transcript = findTranscript(nativeId);
+ if (!transcript) {
+ summary.skipped++;
+ continue;
+ }
+
+ let usage: Awaited>;
+ try {
+ usage = await readTranscriptUsage(transcript);
+ } catch {
+ summary.skipped++;
+ continue;
+ }
+ if (usage.state !== "cost-state" || usage.endedAt === undefined) {
+ summary.skipped++;
+ continue;
+ }
+
+ if (importLegacyUsage(handle, ledger, nativeId, usage)) summary.imported++;
+ else summary.skipped++;
+ }
+
+ return summary;
+ } finally {
+ db.close();
+ }
+}
diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts
index 53bd15d..f4c7d97 100644
--- a/src/cli/commands/usage.ts
+++ b/src/cli/commands/usage.ts
@@ -10,6 +10,7 @@ import { Database } from "../../store/database.js";
import { SessionStore, resolveUsageDateRange } from "../../store/sessions.js";
import { renderSnapshot } from "../usage/snapshot.js";
import { runDashboard, type DashboardFetcher } from "../usage/dashboard.js";
+import { backfillUsage } from "./usage-backfill.js";
export interface UsageCommandOptions {
json?: boolean;
@@ -29,6 +30,7 @@ export interface UsageCommandOptions {
watch?: boolean;
interval?: string;
observe?: string;
+ backfill?: boolean;
}
function parseUsageObservation(value: string | undefined): { nativeId: string; costUsd: number } | undefined {
@@ -120,12 +122,25 @@ export function registerUsageCommand(program: Command): void {
.option("-a, --agent ", "filter by agent harness (e.g. codex, claude)")
.option("--by ", "group by dimension: day, repo, model, agent, run, origin")
.option("--observe ", "report live orchestrator cost")
+ .option("--backfill", "import historical orchestrator usage")
.option("-i, --tui", "open interactive full-screen TUI dashboard")
.option("-w, --watch", "watch usage in real time with live updates")
.option("--interval ", "refresh interval for --watch (default: 2)", "2")
.option("--plain", "output plain text table without ANSI colors")
.option("--json", "output usage data as JSON")
.action(async (runId: string | undefined, opts: UsageCommandOptions) => {
+ if (opts.backfill) {
+ try {
+ const summary = await backfillUsage();
+ if (opts.json) console.log(JSON.stringify(summary));
+ else console.log(`Usage backfill: imported ${summary.imported}, skipped ${summary.skipped}`);
+ } catch (error) {
+ console.error(`Failed to backfill usage: ${error instanceof Error ? error.message : String(error)}`);
+ process.exitCode = 3;
+ }
+ return;
+ }
+
const targetRunId = opts.run ?? runId;
// 1. Single-Run Branch (100% Backwards Compatible with statusline.sh)
diff --git a/tests/usage-backfill.test.ts b/tests/usage-backfill.test.ts
new file mode 100644
index 0000000..8d3e8e4
--- /dev/null
+++ b/tests/usage-backfill.test.ts
@@ -0,0 +1,255 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Command } from "commander";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getPaths } from "../src/config/paths.js";
+import { openSourceKey } from "../src/core/usage-source.js";
+import { Database } from "../src/store/database.js";
+import { SessionStore } from "../src/store/sessions.js";
+import { registerUsageCommand } from "../src/cli/commands/usage.js";
+
+let tempRoot: string;
+let tempHome: string;
+let originalHome: string | undefined;
+let originalRunAgentDir: string | undefined;
+let originalExitCode: string | number | undefined;
+let logs: string[];
+
+const ids = {
+ open: "92d88cce-bdbc-46db-8573-916afd32f6f7",
+ sidecar: "3f1f93b8-c484-43aa-8a11-32a486109e22",
+ worker: "f46552ad-8900-441a-a62b-c6901010a988",
+ name: "866d169a-9c87-4c03-8f1f-2b4d44a31a34",
+ marked: "eb8318ac-85b6-4da6-80f4-7092bc9d9160",
+ noCostState: "2639586b-9e63-4985-bdb1-0d4439570210",
+ missing: "4096e136-4b8b-4d8d-b078-97019b8e0f55",
+};
+
+async function runBackfill(args: string[] = ["--json"]): Promise {
+ const program = new Command();
+ program.exitOverride();
+ registerUsageCommand(program);
+ await program.parseAsync(["node", "codedeck", "usage", "--backfill", ...args], { from: "node" });
+}
+
+function seedSession(id: string, agent: string, origin: string | null, nativeId: string): void {
+ const db = new Database(getPaths().db);
+ try {
+ db.getHandle().prepare(`
+ INSERT INTO sessions (
+ id, agent, native_session_id, status, cwd, created_at, updated_at, origin
+ ) VALUES (?, ?, ?, 'completed', ?, ?, ?, ?)
+ `).run(id, agent, nativeId, tempRoot, new Date(2026, 8, 7).toISOString(), new Date(2026, 8, 7).toISOString(), origin);
+ } finally {
+ db.close();
+ }
+}
+
+function createRepo(name: string): { root: string; cwd: string } {
+ const root = path.join(tempRoot, name);
+ const cwd = path.join(root, "nested", "project");
+ fs.mkdirSync(path.join(root, ".git"), { recursive: true });
+ fs.mkdirSync(cwd, { recursive: true });
+ return { root, cwd };
+}
+
+function writeCostTranscript(
+ nativeId: string,
+ cwd: string,
+ values: { cost?: number; input?: number; output?: number; cacheRead?: number; cacheCreation?: number } = {},
+): string {
+ const transcriptDir = path.join(tempHome, ".claude", "projects", "fixture-project");
+ fs.mkdirSync(transcriptDir, { recursive: true });
+ const file = path.join(transcriptDir, `${nativeId}.jsonl`);
+ const record = {
+ type: "cost-state",
+ timestamp: "2026-09-07T14:30:00.000Z",
+ cwd,
+ totalCostUSD: values.cost ?? 1.25,
+ modelUsage: {
+ "claude-sonnet-4-6": {
+ inputTokens: values.input ?? 120,
+ outputTokens: values.output ?? 30,
+ cacheReadInputTokens: values.cacheRead ?? 10,
+ cacheCreationInputTokens: values.cacheCreation ?? 5,
+ costUSD: values.cost ?? 1.25,
+ },
+ },
+ };
+ fs.writeFileSync(file, `${JSON.stringify(record)}\n`);
+ return file;
+}
+
+function writeTokenOnlyTranscript(nativeId: string, cwd: string): void {
+ const transcriptDir = path.join(tempHome, ".claude", "projects", "fixture-project");
+ fs.mkdirSync(transcriptDir, { recursive: true });
+ const record = {
+ type: "assistant",
+ timestamp: "2026-09-07T14:30:00.000Z",
+ cwd,
+ requestId: "request-1",
+ message: {
+ id: "message-1",
+ model: "claude-sonnet-4-6",
+ usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 1 },
+ },
+ };
+ fs.writeFileSync(path.join(transcriptDir, `${nativeId}.jsonl`), `${JSON.stringify(record)}\n`);
+}
+
+function writeSidecar(fileName: string, contents: string): string {
+ const sessionsDir = getPaths().sessionsDir;
+ fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
+ const file = path.join(sessionsDir, fileName);
+ fs.writeFileSync(file, contents);
+ return file;
+}
+
+function legacyRows(): Array> {
+ const db = new Database(getPaths().db);
+ try {
+ return db.getHandle().prepare(`
+ SELECT native_id, ended_at, cwd, repository, model, cost,
+ input_tokens, output_tokens, cached_tokens
+ FROM usage_legacy ORDER BY native_id
+ `).all() as Array>;
+ } finally {
+ db.close();
+ }
+}
+
+function usageTotals(): unknown {
+ const db = new Database(getPaths().db);
+ try {
+ return new SessionStore(db.getHandle()).queryUsage({ period: "all" }).totals;
+ } finally {
+ db.close();
+ }
+}
+
+beforeEach(() => {
+ originalHome = process.env.HOME;
+ originalRunAgentDir = process.env.RUN_AGENT_DIR;
+ originalExitCode = process.exitCode;
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "usage-backfill-"));
+ tempHome = path.join(tempRoot, "home");
+ fs.mkdirSync(tempHome, { recursive: true });
+ process.env.HOME = tempHome;
+ process.env.RUN_AGENT_DIR = path.join(tempRoot, "run-agent");
+ process.exitCode = undefined;
+ logs = [];
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
+});
+
+afterEach(() => {
+ if (originalHome === undefined) delete process.env.HOME;
+ else process.env.HOME = originalHome;
+ if (originalRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
+ else process.env.RUN_AGENT_DIR = originalRunAgentDir;
+ process.exitCode = originalExitCode;
+ vi.restoreAllMocks();
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+});
+
+describe("usage historical backfill", () => {
+ it("imports open rows and every sidecar line, skips worker ids, and stays idempotent", async () => {
+ seedSession("open-row", "claude", "open", ids.open);
+ seedSession("worker-row", "codex", null, ids.worker);
+ const regularSidecar = writeSidecar("codedeck-session-4100", `${ids.sidecar}\n${ids.worker}\n`);
+ const nameSidecar = writeSidecar(`codedeck-session-4100.${ids.open}.name`, "reviewer");
+ const repo = createRepo("main-repo");
+ writeCostTranscript(ids.open, repo.cwd, { cost: 1.25 });
+ writeCostTranscript(ids.sidecar, repo.cwd, { cost: 0.75, input: 60, output: 20, cacheRead: 5, cacheCreation: 2 });
+ writeCostTranscript(ids.worker, repo.cwd, { cost: 50 });
+
+ await runBackfill();
+
+ expect(JSON.parse(logs[0]!)).toEqual({ imported: 2, skipped: 1 });
+ expect(legacyRows().map((row) => row.native_id).sort()).toEqual([ids.open, ids.sidecar].sort());
+ const db = new Database(getPaths().db);
+ let totalsAfterFirstRun: unknown;
+ try {
+ const mark = db.getHandle().prepare(`
+ SELECT cost, input_tokens, output_tokens, cached_tokens
+ FROM usage_sources WHERE source_key = ?
+ `).get(openSourceKey(ids.open));
+ expect(mark).toEqual({ cost: 1.25, input_tokens: 120, output_tokens: 30, cached_tokens: 15 });
+ expect(new SessionStore(db.getHandle()).list(50, true).map((session) => session.id).sort())
+ .toEqual(["open-row", "worker-row"]);
+ totalsAfterFirstRun = new SessionStore(db.getHandle()).queryUsage({ period: "all" }).totals;
+ } finally {
+ db.close();
+ }
+ expect(fs.readFileSync(regularSidecar, "utf8")).toBe(`${ids.sidecar}\n${ids.worker}\n`);
+ expect(fs.readFileSync(nameSidecar, "utf8")).toBe("reviewer");
+
+ logs = [];
+ await runBackfill();
+
+ expect(JSON.parse(logs[0]!)).toEqual({ imported: 0, skipped: 3 });
+ expect(usageTotals()).toEqual(totalsAfterFirstRun);
+ });
+
+ it("collects an id from a name sidecar and stores the transcript git root", async () => {
+ const repo = createRepo("name-repo");
+ const sidecar = writeSidecar(`codedeck-session-4101.${ids.name}.name`, "reviewer");
+ writeCostTranscript(ids.name, repo.cwd, { cost: 0.9 });
+
+ await runBackfill();
+
+ expect(JSON.parse(logs[0]!)).toEqual({ imported: 1, skipped: 0 });
+ expect(legacyRows()).toEqual([
+ expect.objectContaining({
+ native_id: ids.name,
+ ended_at: "2026-09-07T14:30:00.000Z",
+ cwd: repo.cwd,
+ repository: repo.root,
+ model: "claude-sonnet-4-6",
+ cost: 0.9,
+ input_tokens: 120,
+ output_tokens: 30,
+ cached_tokens: 15,
+ }),
+ ]);
+ expect(fs.readFileSync(sidecar, "utf8")).toBe("reviewer");
+ });
+
+ it("skips existing marks, missing transcripts, and transcripts without cost-state", async () => {
+ const repo = createRepo("partial-repo");
+ const sidecar = writeSidecar(
+ "codedeck-session-4102",
+ `${ids.marked}\n${ids.noCostState}\n${ids.missing}\n`,
+ );
+ writeCostTranscript(ids.marked, repo.cwd);
+ writeTokenOnlyTranscript(ids.noCostState, repo.cwd);
+ const db = new Database(getPaths().db);
+ try {
+ db.getHandle().prepare(`
+ INSERT INTO usage_sources (
+ source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `).run(openSourceKey(ids.marked), 2, 20, 4, 1, new Date().toISOString());
+ } finally {
+ db.close();
+ }
+
+ await runBackfill();
+
+ expect(JSON.parse(logs[0]!)).toEqual({ imported: 0, skipped: 3 });
+ expect(legacyRows()).toEqual([]);
+ expect(fs.readFileSync(sidecar, "utf8")).toBe(`${ids.marked}\n${ids.noCostState}\n${ids.missing}\n`);
+ });
+
+ it("prints a one-line text summary without --json", async () => {
+ const repo = createRepo("text-repo");
+ writeSidecar("codedeck-session-4103", `${ids.sidecar}\n`);
+ writeCostTranscript(ids.sidecar, repo.cwd);
+
+ await runBackfill([]);
+
+ expect(logs).toEqual(["Usage backfill: imported 1, skipped 0"]);
+ expect(logs[0]).not.toContain("\n");
+ });
+});
From 6433bf3d75e36dcc9c28eef3a600b68f385e8296 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:49:43 -0300
Subject: [PATCH 30/42] fix(usage): Seed legacy usage into unmarked source
Attribute pre-upgrade usage to the incoming source when it has no mark, so the next cumulative observation adds only its increase. Keep the seed source when the incoming source already has a mark on another row.
Co-Authored-By: Codex
---
src/store/usage-ledger.ts | 20 +++--
tests/usage-ledger.test.ts | 146 +++++++++++++++++++++++++++++++++----
2 files changed, 147 insertions(+), 19 deletions(-)
diff --git a/src/store/usage-ledger.ts b/src/store/usage-ledger.ts
index e1628e8..3c19df1 100644
--- a/src/store/usage-ledger.ts
+++ b/src/store/usage-ledger.ts
@@ -61,7 +61,7 @@ export class UsageLedger {
`).get(sessionId) as SessionUsageRow | undefined;
if (!session) throw new Error(`Session ${sessionId} not found`);
- this.seedSessionUsage(sessionId, session);
+ this.seedSessionUsage(sessionId, sourceKey, session);
const now = new Date().toISOString();
this.db.prepare(`
@@ -132,7 +132,11 @@ export class UsageLedger {
).get(sourceKey) !== undefined;
}
- private seedSessionUsage(sessionId: string, session: SessionUsageRow): void {
+ private seedSessionUsage(
+ sessionId: string,
+ sourceKey: string,
+ session: SessionUsageRow,
+ ): void {
const hasUsage = session.usage_input_tokens !== null ||
session.usage_output_tokens !== null ||
session.usage_cached_tokens !== null ||
@@ -144,7 +148,13 @@ export class UsageLedger {
).get(sessionId);
if (attribution) return;
- const sourceKey = `seed:${sessionId}`;
+ const existingSource = this.db.prepare(
+ `SELECT * FROM usage_sources WHERE source_key = ?`,
+ ).get(sourceKey) as unknown as UsageSourceRow | undefined;
+ const sourceHasMark = existingSource !== undefined && fields.some(
+ (field) => existingSource[field.source] !== null,
+ );
+ const attributionSourceKey = sourceHasMark ? `seed:${sessionId}` : sourceKey;
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO usage_sources (source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at)
@@ -156,7 +166,7 @@ export class UsageLedger {
cached_tokens = excluded.cached_tokens,
updated_at = excluded.updated_at
`).run(
- sourceKey,
+ attributionSourceKey,
session.usage_cost,
session.usage_input_tokens,
session.usage_output_tokens,
@@ -169,7 +179,7 @@ export class UsageLedger {
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
sessionId,
- sourceKey,
+ attributionSourceKey,
session.usage_cost,
session.usage_input_tokens ?? 0,
session.usage_output_tokens ?? 0,
diff --git a/tests/usage-ledger.test.ts b/tests/usage-ledger.test.ts
index f6c3c0b..62ebeda 100644
--- a/tests/usage-ledger.test.ts
+++ b/tests/usage-ledger.test.ts
@@ -21,6 +21,12 @@ interface SourceRow {
cached_tokens: number | null;
}
+interface CostInvariantRow {
+ source_key: string;
+ cost: number | null;
+ attributed_cost: number | null;
+}
+
function withLedger(fn: (db: Database, ledger: UsageLedger, sessions: SessionStore) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usage-ledger-"));
const db = new Database(path.join(dir, "test.db"));
@@ -29,12 +35,26 @@ function withLedger(fn: (db: Database, ledger: UsageLedger, sessions: SessionSto
const sessions = new SessionStore(handle);
try {
fn(db, ledger, sessions);
+ expectCostInvariants(db);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}
+function expectCostInvariants(db: Database): void {
+ const rows = db.getHandle().prepare(`
+ SELECT sources.source_key, sources.cost, SUM(attributions.cost) AS attributed_cost
+ FROM usage_sources AS sources
+ LEFT JOIN usage_attributions AS attributions ON attributions.source_key = sources.source_key
+ GROUP BY sources.source_key, sources.cost
+ ORDER BY sources.source_key
+ `).all() as unknown as CostInvariantRow[];
+ for (const row of rows) {
+ expect(row.attributed_cost, `cost attribution for ${row.source_key}`).toBe(row.cost);
+ }
+}
+
function makeSession(id: string, cwd: string, usage?: Session["usage"]): Session {
const now = new Date("2026-09-22T12:00:00.000Z");
return {
@@ -312,7 +332,7 @@ describe("UsageLedger", () => {
});
});
- it("seeds existing usage once before applying a new source", () => {
+ it("seeds existing usage onto an incoming source with no mark", () => {
withLedger((db, ledger, sessions) => {
sessions.create(makeSession("seeded", "/tmp", {
inputTokens: 12,
@@ -326,37 +346,135 @@ describe("UsageLedger", () => {
outputTokens: 4,
model: "claude-opus-4-1",
})).toBe(true);
- expect(sourceFor(db, "seed:seeded")).toEqual({
+ expect(sourceFor(db, "next-process")).toEqual({
cost: 0.75,
input_tokens: 12,
- output_tokens: 3,
+ output_tokens: 4,
cached_tokens: 1,
});
expect(ledger.attributionsFor(["seeded"])).toEqual([
{
sessionId: "seeded",
sourceKey: "next-process",
- cost: null,
- inputTokens: 2,
- outputTokens: 4,
- cachedTokens: 0,
- },
- {
- sessionId: "seeded",
- sourceKey: "seed:seeded",
cost: 0.75,
inputTokens: 12,
- outputTokens: 3,
+ outputTokens: 4,
cachedTokens: 1,
},
]);
expect(usageFor(db, "seeded")).toEqual({
- usage_input_tokens: 14,
- usage_output_tokens: 7,
+ usage_input_tokens: 12,
+ usage_output_tokens: 4,
usage_cached_tokens: 1,
usage_cost: 0.75,
});
expect(sessions.get("seeded")?.model).toBe("claude-opus-4-1");
});
});
+
+ it("seeds the incoming source mark before applying higher or lower cost", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("upgrade-higher", "/tmp", { cost: 0.4 }));
+
+ expect(ledger.observe("upgrade-higher", "claude:process-1", { cost: 0.5 })).toBe(true);
+ expect(sourceFor(db, "claude:process-1")).toEqual({
+ cost: 0.5,
+ input_tokens: null,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["upgrade-higher"])).toEqual([{
+ sessionId: "upgrade-higher",
+ sourceKey: "claude:process-1",
+ cost: 0.5,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ }]);
+ expect(usageFor(db, "upgrade-higher")).toEqual({
+ usage_input_tokens: 0,
+ usage_output_tokens: 0,
+ usage_cached_tokens: 0,
+ usage_cost: 0.5,
+ });
+
+ sessions.create(makeSession("upgrade-lower", "/tmp", { cost: 0.4 }));
+ expect(ledger.observe("upgrade-lower", "claude:process-2", { cost: 0.3 })).toBe(false);
+ expect(sourceFor(db, "claude:process-2")).toEqual({
+ cost: 0.4,
+ input_tokens: null,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["upgrade-lower"])).toEqual([{
+ sessionId: "upgrade-lower",
+ sourceKey: "claude:process-2",
+ cost: 0.4,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ }]);
+ expect(usageFor(db, "upgrade-lower")).toEqual({
+ usage_input_tokens: null,
+ usage_output_tokens: null,
+ usage_cached_tokens: null,
+ usage_cost: 0.4,
+ });
+ });
+ });
+
+ it("keeps the seed source when the incoming source is already marked elsewhere", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("existing-owner", "/tmp"));
+ sessions.create(makeSession("upgrade-seeded", "/tmp", { cost: 0.25 }));
+
+ expect(ledger.observe("existing-owner", "shared-process", { cost: 0.5 })).toBe(true);
+ expect(ledger.observe("upgrade-seeded", "shared-process", { cost: 0.75 })).toBe(true);
+
+ expect(sourceFor(db, "shared-process")).toEqual({
+ cost: 0.75,
+ input_tokens: null,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(sourceFor(db, "seed:upgrade-seeded")).toEqual({
+ cost: 0.25,
+ input_tokens: null,
+ output_tokens: null,
+ cached_tokens: null,
+ });
+ expect(ledger.attributionsFor(["existing-owner", "upgrade-seeded"])).toEqual([
+ {
+ sessionId: "existing-owner",
+ sourceKey: "shared-process",
+ cost: 0.5,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ {
+ sessionId: "upgrade-seeded",
+ sourceKey: "seed:upgrade-seeded",
+ cost: 0.25,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ {
+ sessionId: "upgrade-seeded",
+ sourceKey: "shared-process",
+ cost: 0.25,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ ]);
+ expect(usageFor(db, "upgrade-seeded")).toEqual({
+ usage_input_tokens: 0,
+ usage_output_tokens: 0,
+ usage_cached_tokens: 0,
+ usage_cost: 0.5,
+ });
+ });
+ });
});
From a5aebaa2f32c7481018c7ec66dd4ff4f1288b9ec Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:17:19 -0300
Subject: [PATCH 31/42] fix(open): Skip the link flush await when no watcher
runs
---
src/cli/commands/open.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts
index 70be2b4..9e837d5 100644
--- a/src/cli/commands/open.ts
+++ b/src/cli/commands/open.ts
@@ -734,7 +734,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
- await flushLinkWatcher();
+ if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -835,7 +835,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
- await flushLinkWatcher();
+ if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -908,7 +908,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, opts.resume);
} catch {}
}
- await flushLinkWatcher();
+ if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
@@ -942,7 +942,7 @@ export function registerOpenCommand(program: Command): void {
},
);
} catch (err: unknown) {
- await flushLinkWatcher();
+ if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
try {
await client.request("session.release", {
id: runId,
From 0c7c970d28658f007b672e608e5f60878757db92 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:17:43 -0300
Subject: [PATCH 32/42] docs(usage): Align design with seed rule, backfill flag
and linksFor
---
.specs/features/orchestrator-usage/design.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/.specs/features/orchestrator-usage/design.md b/.specs/features/orchestrator-usage/design.md
index 05bff55..beaa693 100644
--- a/.specs/features/orchestrator-usage/design.md
+++ b/.specs/features/orchestrator-usage/design.md
@@ -96,7 +96,7 @@ Flows:
- **Behavior**:
- For each present field `f`: `delta = obs.f - (mark.f ?? 0)`. If `delta > 0`, add it to the attribution `(sessionId, sourceKey)` and set `mark.f = obs.f`. Absent fields change nothing (ORCH-13/14).
- After any move, rewrite `sessions.usage_*` for that row as `SUM` over its attributions. Cost stays `NULL` when no attribution of the row has a cost.
- - Seed on first use: if a row has non-null `usage_*` and no attribution yet (a row that was live across the upgrade), store those values as source `seed:` before applying the observation, so materialization does not drop them.
+ - Seed on first use: if a row has non-null `usage_*` and no attribution yet (a row that was live across the upgrade), store those values as an attribution before applying the observation, so materialization does not drop them. The seed goes to the incoming source key (and sets its mark) when that source has no mark yet, so the next cumulative observation adds only its increase. When the incoming source already has a mark from another row, the seed goes to `seed:` instead.
- **Dependencies**: the `DatabaseSync` handle; callers own the transaction.
- **Reuses**: `SessionStore.update` for the materialized columns.
@@ -117,6 +117,7 @@ Flows:
- **Interfaces**:
- `link(sessionId, nativeId): { created: boolean; previous: string[] }`, `previous` lists other unreconciled ids for ORCH-16.
- `unreconciled(sessionId): NativeLink[]`
+ - `linksFor(sessionIds): NativeLink[]`, all links of the given rows; `usage.get` passes their states to `aggregateRunUsage` for `orchestrator.costComplete`
- `markReconciled(sessionId, nativeId, state: ReconcileState)`
- `staleOpenRows(isDead: (s: Session) => boolean): Session[]` for ORCH-07.
@@ -176,7 +177,7 @@ Flows:
### Backfill (P2)
-- **Location**: `src/cli/commands/usage-backfill.ts`, registered as `codedeck usage backfill`.
+- **Location**: `src/cli/commands/usage-backfill.ts`, registered as the `codedeck usage --backfill` flag (a `backfill` subcommand would collide with the `[run-id]` argument).
- **Behavior**: collect ids (BF-01), skip worker native ids (BF-02) and ids with a `claude-open:` mark (BF-03), read the transcript, insert one `usage_legacy` row plus the `claude-open:` mark in the same transaction (BF-04). The mark makes a later resume of that id attribute only the increase, and makes a second backfill a no-op (BF-08).
- **Why a table, not session rows**: legacy rows in `sessions` would show up in `ps --all`, `show` and `getByRunId`.
@@ -287,7 +288,7 @@ interface RunUsageSummary {
| Concern | Location (file:line) | Impact | Mitigation |
| --- | --- | --- | --- |
| `updateSessionFromEvent` swallows every error | `src/daemon/daemon.ts:1350` (`catch {}`) | a ledger bug would silently stop usage updates | ledger unit tests cover the arithmetic; the daemon test asserts materialized columns after each worker fixture |
-| Rows live across the upgrade have usage but no attribution | `src/store/sessions.ts` `usage_*` | first observation would overwrite them with a smaller sum | `seed:` attribution in `UsageLedger` |
+| Rows live across the upgrade have usage but no attribution | `src/store/sessions.ts` `usage_*` | first observation would overwrite them with a smaller sum | seed attribution in `UsageLedger` (incoming source when unmarked, else `seed:`) |
| Statusline observation on every render writes to SQLite | `plugin/statusline.sh:180` | write amplification | the no-op path only reads the mark; writes happen only when a field moves |
| Transcript scan cost | `~/.claude/projects` (84 files observed, some above 50 MB) | slow release | `readline` streaming, only unreconciled links, startup reconcile runs off the critical path |
| Three copies of the `open` spawn path | `src/cli/commands/open.ts:652-860` | watcher started on one path only | start it where `sessionFile` and `runId` are both known; one test per path is not needed if the helper is shared, the task checks the three call sites |
From 6ca602c4d4a6474cb7c79af1657ffa27420dfddb Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:33:16 -0300
Subject: [PATCH 33/42] test(open): Cover native session ID cleanup and close
ordering
Prove last-valid sidecar selection and Claude close-time linking order.
Co-Authored-By: Codex
---
tests/open-action.test.ts | 45 +++++++++++++++++++++++++++++++++++
tests/session-runtime.test.ts | 17 +++++++++++++
2 files changed, 62 insertions(+)
diff --git a/tests/open-action.test.ts b/tests/open-action.test.ts
index 368f1aa..5424d94 100644
--- a/tests/open-action.test.ts
+++ b/tests/open-action.test.ts
@@ -403,6 +403,51 @@ describe("opencode effort", () => {
});
describe("claude dispatch", () => {
+ it("flushes native ids before finishing and releasing a Claude session", async () => {
+ const previousRunAgentDir = process.env.RUN_AGENT_DIR;
+ const runAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-claude-close-"));
+ process.env.RUN_AGENT_DIR = runAgentDir;
+ vi.useFakeTimers();
+
+ try {
+ writeConfig({
+ agents: { general: { harness: "claude", model: "m", effort: "high" } },
+ });
+ mockClaudeLaunch();
+
+ await runOpen(["general", "--no-theme"]);
+
+ const [, , opts] = vi.mocked(runtime.spawnHarness).mock.calls[0];
+ const nativeId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ fs.writeFileSync(opts.sessionFile, `${nativeId}\n`);
+ await opts.onClose();
+
+ const request = vi.mocked(IpcClient.prototype.request);
+ const linkIndex = request.mock.calls.findIndex(
+ ([method, params]) =>
+ method === "session.linkNative" &&
+ (params as { nativeId?: string }).nativeId === nativeId,
+ );
+ const releaseIndex = request.mock.calls.findIndex(
+ ([method]) => method === "session.release",
+ );
+ expect(linkIndex).toBeGreaterThanOrEqual(0);
+ expect(releaseIndex).toBeGreaterThanOrEqual(0);
+ expect(runtime.finishOpenSession).toHaveBeenCalledTimes(1);
+
+ const linkOrder = request.mock.invocationCallOrder[linkIndex]!;
+ const finishOrder = vi.mocked(runtime.finishOpenSession).mock.invocationCallOrder[0]!;
+ const releaseOrder = request.mock.invocationCallOrder[releaseIndex]!;
+ expect(linkOrder).toBeLessThan(finishOrder);
+ expect(finishOrder).toBeLessThan(releaseOrder);
+ } finally {
+ vi.useRealTimers();
+ if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
+ else process.env.RUN_AGENT_DIR = previousRunAgentDir;
+ fs.rmSync(runAgentDir, { recursive: true, force: true });
+ }
+ });
+
it("omits --remote-control when config disables it", async () => {
const configDir = process.env.RUN_AGENT_CONFIG_DIR;
if (!configDir) throw new Error("test config directory is missing");
diff --git a/tests/session-runtime.test.ts b/tests/session-runtime.test.ts
index 09c5b98..74f04ac 100644
--- a/tests/session-runtime.test.ts
+++ b/tests/session-runtime.test.ts
@@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SessionRuntime, readSessionProcessMetadata, type RuntimeHooks } from "../src/drivers/session-runtime.js";
+import { finishOpenSession } from "../src/open/runtime.js";
import { processAlive, processStartTime, sleep } from "../src/utils/process.js";
import type { AgentEvent } from "../src/core/events.js";
@@ -25,6 +26,22 @@ async function waitFor(cond: () => boolean, what: string, timeoutMs = 5000): Pro
}
}
+describe("finishOpenSession", () => {
+ it("returns the last valid native id from the session sidecar", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-id-"));
+ const sessionFile = path.join(dir, "session");
+ const firstId = "11111111-1111-4111-8111-111111111111";
+ const lastId = "22222222-2222-4222-8222-222222222222";
+ fs.writeFileSync(sessionFile, `${firstId}\ninvalid\n${lastId}\n`);
+
+ try {
+ expect(finishOpenSession("reviewer", sessionFile, () => {}, false)).toBe(lastId);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
interface Harness {
runtime: SessionRuntime;
events: AgentEvent[];
From a1d1d672e9c454a1542c42a10c5b4dd9116d6af2 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:33:42 -0300
Subject: [PATCH 34/42] fix(usage): Re-read every native link on release
Refresh reconciled transcript totals when an open row is resumed or later
released. The ledger high-water marks absorb unchanged cumulative values.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 5 ++-
tests/orchestrator-usage-daemon.test.ts | 45 +++++++++++++++++++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 0c13358..c321f3f 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -343,8 +343,11 @@ class Daemon {
const session = this.sessions.get(sessionId);
if (!session || session.origin !== "open" || session.agent !== "claude") return;
const selectedIds = nativeIds === undefined ? undefined : new Set(nativeIds);
+ const links = nativeIds === undefined
+ ? this.nativeLinks.linksFor([sessionId])
+ : this.nativeLinks.unreconciled(sessionId);
- for (const link of this.nativeLinks.unreconciled(sessionId)) {
+ for (const link of links) {
if (selectedIds && !selectedIds.has(link.nativeId)) continue;
const transcript = findTranscript(link.nativeId);
if (!transcript) {
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index c652d08..4312389 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -209,6 +209,51 @@ describe("orchestrator usage daemon methods", () => {
.toBe(15);
});
+ it("reconciles a previously linked native id after resuming its row", async () => {
+ const nativeId = "native-resumed";
+ seedOpen("row-resumed", { nativeSessionId: nativeId });
+ installTranscript(nativeId, "cost-state-10.jsonl");
+
+ await request("session.linkNative", { id: "row-resumed", nativeId });
+ await request("session.release", { id: "row-resumed" });
+ expect(seam(daemon!).sessions.get("row-resumed")?.usage?.cost).toBe(10);
+
+ const adopted = await request("session.adopt", {
+ agent: "claude",
+ cwd: "/tmp",
+ resume: nativeId,
+ });
+ expect(adopted.result.session.id).toBe("row-resumed");
+ await request("session.linkNative", { id: "row-resumed", nativeId });
+ installTranscript(nativeId, "cost-state-15.jsonl");
+ await request("session.release", { id: "row-resumed" });
+
+ expect(seam(daemon!).sessions.get("row-resumed")?.usage?.cost).toBe(15);
+ await (daemon as any).reconcileOpenUsage("row-resumed");
+ expect(seam(daemon!).sessions.get("row-resumed")?.usage?.cost).toBe(15);
+ expect((daemon as any).usageLedger.attributionsFor(["row-resumed"])).toMatchObject([
+ { sourceKey: "claude-open:native-resumed", cost: 15 },
+ ]);
+ });
+
+ it("re-reads reconciled links on release and keeps each source total once", async () => {
+ seedOpen("row-multiple-links");
+ installTranscript("native-x", "cost-state-10.jsonl");
+ await request("session.linkNative", { id: "row-multiple-links", nativeId: "native-x" });
+ await request("session.linkNative", { id: "row-multiple-links", nativeId: "native-y" });
+ expect(seam(daemon!).sessions.get("row-multiple-links")?.usage?.cost).toBe(10);
+
+ installTranscript("native-x", "cost-state-15.jsonl");
+ installTranscript("native-y", "cost-state-3.jsonl");
+ await request("session.release", { id: "row-multiple-links" });
+
+ expect(seam(daemon!).sessions.get("row-multiple-links")?.usage?.cost).toBe(18);
+ expect((daemon as any).usageLedger.attributionsFor(["row-multiple-links"])).toMatchObject([
+ { sourceKey: "claude-open:native-x", cost: 15 },
+ { sourceKey: "claude-open:native-y", cost: 3 },
+ ]);
+ });
+
it("reconciles an earlier linked id when another id is added", async () => {
seedOpen("row-relink");
installTranscript("native-x", "cost-state-3.jsonl");
From c4b034d7c803e1fae80227e71d00d6b4adc37c8f Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:36:38 -0300
Subject: [PATCH 35/42] fix(usage): Keep legacy cost outside the next Claude
source
Place unclassified prior usage under a seed source when the latest
usage event came from an earlier Claude process.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 35 +++++++++++++++++++-
src/store/usage-ledger.ts | 14 ++++++--
tests/orchestrator-usage-daemon.test.ts | 43 +++++++++++++++++++++++++
tests/usage-ledger.test.ts | 25 ++++++++++++++
4 files changed, 113 insertions(+), 4 deletions(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index c321f3f..46ce776 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -1467,6 +1467,37 @@ class Daemon {
if (!this.shuttingDown) void this.tryDispatch(sessionId);
}
+ private previousUsageProcessOrdinal(
+ sessionId: string,
+ currentSequence: number,
+ eventSequence?: number,
+ ): number | undefined {
+ const previousUsageEvent = this.db.getHandle().prepare(`
+ SELECT sequence FROM events
+ WHERE session_id = ? AND type = 'usage.updated' AND sequence <= ?
+ AND (? IS NULL OR sequence <> ?)
+ AND (
+ json_extract(normalized_payload, '$.usage.cost') IS NOT NULL OR
+ json_extract(normalized_payload, '$.usage.inputTokens') IS NOT NULL OR
+ json_extract(normalized_payload, '$.usage.outputTokens') IS NOT NULL OR
+ json_extract(normalized_payload, '$.usage.cachedTokens') IS NOT NULL
+ )
+ ORDER BY sequence DESC
+ LIMIT 1
+ `).get(
+ sessionId,
+ currentSequence,
+ eventSequence ?? null,
+ eventSequence ?? null,
+ ) as { sequence: number } | undefined;
+ if (!previousUsageEvent) return;
+
+ return (this.db.getHandle().prepare(`
+ SELECT COUNT(*) AS count FROM events
+ WHERE session_id = ? AND type = 'session.started' AND sequence <= ?
+ `).get(sessionId, previousUsageEvent.sequence) as { count: number }).count;
+ }
+
private updateSessionFromEvent(sessionId: string, ev: AgentEvent, sequence?: number): void {
try {
const current = this.sessions.get(sessionId);
@@ -1500,7 +1531,9 @@ class Daemon {
`).get(sessionId, currentSequence) as { count: number };
const sourceKey = workerSourceKey(sess, processOrdinal.count);
if (sourceKey && !ev.incremental) {
- this.usageLedger.observe(sessionId, sourceKey, next);
+ const seedIntoIncoming = !sourceKey.startsWith("claude:") ||
+ this.previousUsageProcessOrdinal(sessionId, currentSequence, sequence) === processOrdinal.count;
+ this.usageLedger.observe(sessionId, sourceKey, next, seedIntoIncoming);
if (next.model) this.sessions.update(sessionId, { model: next.model });
} else if (ev.incremental) {
const cur = sess.usage || {};
diff --git a/src/store/usage-ledger.ts b/src/store/usage-ledger.ts
index 3c19df1..1bab4fe 100644
--- a/src/store/usage-ledger.ts
+++ b/src/store/usage-ledger.ts
@@ -53,7 +53,12 @@ const fields = [
export class UsageLedger {
constructor(private db: DatabaseSync) {}
- observe(sessionId: string, sourceKey: string, obs: UsageObservation): boolean {
+ observe(
+ sessionId: string,
+ sourceKey: string,
+ obs: UsageObservation,
+ seedIntoIncoming = true,
+ ): boolean {
const session = this.db.prepare(`
SELECT model, usage_input_tokens, usage_output_tokens,
usage_cached_tokens, usage_cost
@@ -61,7 +66,7 @@ export class UsageLedger {
`).get(sessionId) as SessionUsageRow | undefined;
if (!session) throw new Error(`Session ${sessionId} not found`);
- this.seedSessionUsage(sessionId, sourceKey, session);
+ this.seedSessionUsage(sessionId, sourceKey, session, seedIntoIncoming);
const now = new Date().toISOString();
this.db.prepare(`
@@ -136,6 +141,7 @@ export class UsageLedger {
sessionId: string,
sourceKey: string,
session: SessionUsageRow,
+ seedIntoIncoming: boolean,
): void {
const hasUsage = session.usage_input_tokens !== null ||
session.usage_output_tokens !== null ||
@@ -154,7 +160,9 @@ export class UsageLedger {
const sourceHasMark = existingSource !== undefined && fields.some(
(field) => existingSource[field.source] !== null,
);
- const attributionSourceKey = sourceHasMark ? `seed:${sessionId}` : sourceKey;
+ const attributionSourceKey = seedIntoIncoming && !sourceHasMark
+ ? sourceKey
+ : `seed:${sessionId}`;
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO usage_sources (source_key, cost, input_tokens, output_tokens, cached_tokens, updated_at)
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index 4312389..ba8def5 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -126,6 +126,49 @@ describe("orchestrator usage daemon methods", () => {
]);
});
+ it("keeps legacy Claude usage when the next process reports its own cost", () => {
+ const sessionId = "legacy-claude-row";
+ const nativeId = "native-legacy";
+ seed(daemon!, sessionId, "working", {
+ agent: "claude",
+ nativeSessionId: nativeId,
+ usage: { cost: 0.4661592 },
+ });
+ const events = seam(daemon!).events;
+ const timestamp = new Date().toISOString();
+ events.append(sessionId, {
+ type: "session.started",
+ sessionId,
+ timestamp,
+ agent: "claude",
+ nativeSessionId: nativeId,
+ });
+ events.append(sessionId, {
+ type: "usage.updated",
+ sessionId,
+ timestamp,
+ usage: { cost: 0.4661592 },
+ });
+ events.append(sessionId, {
+ type: "session.started",
+ sessionId,
+ timestamp,
+ agent: "claude",
+ nativeSessionId: nativeId,
+ });
+ const nextUsage = {
+ type: "usage.updated" as const,
+ sessionId,
+ timestamp,
+ usage: { cost: 0.1616463 },
+ };
+ const sequence = events.append(sessionId, nextUsage);
+
+ (daemon as any).updateSessionFromEvent(sessionId, nextUsage, sequence);
+
+ expect(seam(daemon!).sessions.get(sessionId)?.usage?.cost).toBeCloseTo(0.6278055, 8);
+ });
+
it.each([-0.01, Number.NaN, Number.POSITIVE_INFINITY])(
"rejects invalid observed cost %s",
async (costUsd) => {
diff --git a/tests/usage-ledger.test.ts b/tests/usage-ledger.test.ts
index 62ebeda..1b9d895 100644
--- a/tests/usage-ledger.test.ts
+++ b/tests/usage-ledger.test.ts
@@ -372,6 +372,31 @@ describe("UsageLedger", () => {
});
});
+ it("seeds legacy usage separately when the incoming Claude process is different", () => {
+ withLedger((db, ledger, sessions) => {
+ sessions.create(makeSession("legacy-claude", "/tmp", { cost: 0.4661592 }));
+ const ledgerWithSeedHint = ledger as unknown as {
+ observe(
+ sessionId: string,
+ sourceKey: string,
+ obs: { cost: number },
+ seedIntoIncoming: boolean,
+ ): boolean;
+ };
+
+ expect(ledgerWithSeedHint.observe(
+ "legacy-claude",
+ "claude:native-legacy#2",
+ { cost: 0.1616463 },
+ false,
+ )).toBe(true);
+
+ expect(sourceFor(db, "seed:legacy-claude").cost).toBe(0.4661592);
+ expect(sourceFor(db, "claude:native-legacy#2").cost).toBe(0.1616463);
+ expect(usageFor(db, "legacy-claude").usage_cost).toBeCloseTo(0.6278055, 8);
+ });
+ });
+
it("seeds the incoming source mark before applying higher or lower cost", () => {
withLedger((db, ledger, sessions) => {
sessions.create(makeSession("upgrade-higher", "/tmp", { cost: 0.4 }));
From 81ee7f3b88a2231fb0491fa11bf010ec6a0ada2f Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:37:58 -0300
Subject: [PATCH 36/42] fix(usage): Mark unresolved open links as unknown cost
Treat open rows linked to missing or unpriced transcripts as incomplete
in usage analytics.
Co-Authored-By: Codex
---
src/store/sessions.ts | 12 +++++++++++-
tests/usage-query.test.ts | 25 +++++++++++++++++++++++++
2 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/src/store/sessions.ts b/src/store/sessions.ts
index 669be28..ec35cd4 100644
--- a/src/store/sessions.ts
+++ b/src/store/sessions.ts
@@ -357,6 +357,15 @@ export class SessionStore {
ORDER BY ended_at ASC
`).all(since, until) as unknown as UsageLegacyRow[]
: [];
+ const unknownOpenCostSessionIds = new Set(
+ (this.db.prepare(`
+ SELECT DISTINCT session_native_links.session_id
+ FROM session_native_links
+ INNER JOIN sessions ON sessions.id = session_native_links.session_id
+ WHERE sessions.origin = 'open'
+ AND session_native_links.state IN ('missing', 'no-price')
+ `).all() as Array<{ session_id: string }>).map((row) => row.session_id),
+ );
const totals: UsageTotals = {
sessionCount: 0,
@@ -436,12 +445,13 @@ export class SessionStore {
const cachedTokens = row.usage_cached_tokens ?? 0;
const totalTokens = inputTokens + outputTokens + cachedTokens;
- const cost = computeSessionCost({
+ const calculatedCost = computeSessionCost({
model: row.model,
cachedInInput: cachedInInputFor(row.agent),
reportedCost: row.usage_cost,
usage: { inputTokens, outputTokens, cachedTokens },
});
+ const cost = unknownOpenCostSessionIds.has(row.id) ? null : calculatedCost;
totals.sessionCount++;
if (isActiveStatus(row.status as SessionStatus)) totals.activeSessionCount++;
diff --git a/tests/usage-query.test.ts b/tests/usage-query.test.ts
index e2c5b08..54e4abe 100644
--- a/tests/usage-query.test.ts
+++ b/tests/usage-query.test.ts
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { Database } from "../src/store/database.js";
import { SessionStore } from "../src/store/sessions.js";
+import { NativeLinkStore } from "../src/store/native-links.js";
import type { Session } from "../src/core/session.js";
let tmpDir: string;
@@ -196,6 +197,30 @@ describe("SessionStore.queryUsage", () => {
expect(result.totals.sessionsWithoutCost).toBe(1);
});
+ it.each(["missing", "no-price"] as const)(
+ "marks an open row with a %s native link as missing cost",
+ (state) => {
+ const sessionId = `open-${state}`;
+ store.create(
+ makeSession(sessionId, {
+ origin: "open",
+ agent: "claude",
+ model: "claude-sonnet-4-6",
+ }),
+ );
+ const nativeLinks = new NativeLinkStore(db.getHandle());
+ nativeLinks.link(sessionId, `native-${state}`);
+ nativeLinks.markReconciled(sessionId, `native-${state}`, state);
+
+ const result = store.queryUsage({ period: "all" });
+
+ expect(result.totals).toMatchObject({ costComplete: false, sessionsWithoutCost: 1 });
+ expect(result.byOrigin).toMatchObject([
+ { key: "orchestrator", costComplete: false },
+ ]);
+ },
+ );
+
it("filters correctly by repository substring", () => {
store.create(makeSession("s1", { repository: "/dev/frontend" }));
store.create(makeSession("s2", { repository: "/dev/backend" }));
From 06a61c48109c45aa5ac014f60c2cfa6aa329c437 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:38:47 -0300
Subject: [PATCH 37/42] fix(usage): Link the final native id before release
Recover transcript usage when the watcher misses its final native link.
Co-Authored-By: Codex
---
src/daemon/daemon.ts | 3 +++
tests/orchestrator-usage-daemon.test.ts | 17 +++++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 46ce776..208b773 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -673,6 +673,9 @@ class Daemon {
}
this.sessions.setStatus(s.id, targetStatus, extra);
if (s.origin === "open" && s.agent === "claude") {
+ if (typeof p.nativeSessionId === "string" && p.nativeSessionId.length > 0) {
+ this.nativeLinks.link(s.id, p.nativeSessionId);
+ }
await this.reconcileOpenUsageSafely(s.id);
}
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index ba8def5..876a919 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -297,6 +297,23 @@ describe("orchestrator usage daemon methods", () => {
]);
});
+ it("links a release native id before final transcript reconciliation", async () => {
+ const nativeId = "native-release-flush";
+ seedOpen("row-release-flush");
+ installTranscript(nativeId, "cost-state-3.jsonl");
+
+ const response = await request("session.release", {
+ id: "row-release-flush",
+ nativeSessionId: nativeId,
+ });
+
+ expect(response.result.session.status).toBe("completed");
+ expect(seam(daemon!).sessions.get("row-release-flush")?.usage?.cost).toBe(3);
+ expect((daemon as any).nativeLinks.linksFor(["row-release-flush"])).toMatchObject([
+ { nativeId, state: "cost-state" },
+ ]);
+ });
+
it("reconciles an earlier linked id when another id is added", async () => {
seedOpen("row-relink");
installTranscript("native-x", "cost-state-3.jsonl");
From 78cc037d907ccc618581f95cec1880b8b83d1b47 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:42:35 -0300
Subject: [PATCH 38/42] docs(usage): Record link re-read and Claude seed hint
in design
---
.specs/features/orchestrator-usage/design.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.specs/features/orchestrator-usage/design.md b/.specs/features/orchestrator-usage/design.md
index beaa693..81fa073 100644
--- a/.specs/features/orchestrator-usage/design.md
+++ b/.specs/features/orchestrator-usage/design.md
@@ -47,7 +47,7 @@ Flows:
1. **Link** (ORCH-01..03). The hook keeps writing the sidecar with `grep` + `printf`, but appends one id per line instead of overwriting. `open` polls the sidecar every second and sends `session.linkNative` for each id it has not sent yet. The hook never talks to the daemon, so ORCH-02 holds by construction.
2. **Live cost** (ORCH-04, RUN-06). The statusline adds `--observe =` to the `codedeck usage` call it already makes. The daemon links the id (idempotent), applies the observation, then returns the aggregate in the same round trip.
-3. **Reconcile** (ORCH-05..12, ORCH-16). On release of an `open` row, at daemon start for stale rows, and when a second id links, the daemon reads each unreconciled linked transcript and records its `cost-state` (or the token fallback) as an observation.
+3. **Reconcile** (ORCH-05..12, ORCH-16). On release of an `open` row, at daemon start for stale rows, and when a second id links, the daemon reads the linked transcripts and records its `cost-state` (or the token fallback) as an observation.
4. **Workers** (SRC-01..04). `updateSessionFromEvent` derives the source key from the row's agent and routes non-incremental `usage.updated` through the ledger. opencode deltas keep the current additive path.
5. **Read** (RUN-01..09). `aggregateRunUsage` splits rows by `origin`. `queryUsage` gains a `byOrigin` bucket and merges legacy entries (P2).
@@ -96,7 +96,7 @@ Flows:
- **Behavior**:
- For each present field `f`: `delta = obs.f - (mark.f ?? 0)`. If `delta > 0`, add it to the attribution `(sessionId, sourceKey)` and set `mark.f = obs.f`. Absent fields change nothing (ORCH-13/14).
- After any move, rewrite `sessions.usage_*` for that row as `SUM` over its attributions. Cost stays `NULL` when no attribution of the row has a cost.
- - Seed on first use: if a row has non-null `usage_*` and no attribution yet (a row that was live across the upgrade), store those values as an attribution before applying the observation, so materialization does not drop them. The seed goes to the incoming source key (and sets its mark) when that source has no mark yet, so the next cumulative observation adds only its increase. When the incoming source already has a mark from another row, the seed goes to `seed:` instead.
+ - Seed on first use: if a row has non-null `usage_*` and no attribution yet (a row that was live across the upgrade), store those values as an attribution before applying the observation, so materialization does not drop them. The seed goes to the incoming source key (and sets its mark) when that source has no mark yet, so the next cumulative observation adds only its increase. When the incoming source already has a mark from another row, the seed goes to `seed:` instead. For `claude:#` keys the daemon also passes `seedIntoIncoming = false` when the row's latest prior usage event came from a different process ordinal, so a new process on a pre-upgrade row does not inherit the old process total as its mark.
- **Dependencies**: the `DatabaseSync` handle; callers own the transaction.
- **Reuses**: `SessionStore.update` for the materialized columns.
@@ -135,10 +135,10 @@ Flows:
### Reconciler (daemon)
-- **Purpose**: run the transcript reader for unreconciled links and feed the ledger.
+- **Purpose**: run the transcript reader for linked native ids and feed the ledger. Release and startup re-read every link of the row (`linksFor`), because a resumed id on a revived row or a `/clear` then `/resume` inside one `open` grows a transcript that was already reconciled; the source mark makes the re-read idempotent. ORCH-16 on a new link reads only the unreconciled previous ids.
- **Location**: private methods on `Daemon` in `src/daemon/daemon.ts`, next to `recover()`.
- **Triggers**:
- - `session.release` on an `open` row with agent `claude`: after `setStatus` (ORCH-12), await the reconcile, then reply. Other harnesses under `open` are untouched. A reader error is caught and logged, and the status stays.
+ - `session.release` on an `open` row with agent `claude`: after `setStatus` (ORCH-12), link `nativeSessionId` from the request when present (covers a failed final watcher flush), await the reconcile, then reply. Other harnesses under `open` are untouched. A reader error is caught and logged, and the status stays.
- Daemon start: after `recover()`, reconcile rows from `staleOpenRows` without blocking startup (ORCH-07).
- `session.linkNative` or `observe` returning `previous` ids: reconcile those ids (ORCH-16).
- **Writes**: `UsageLedger.observe(rowId, "claude-open:", obs)` in one transaction per link, then `markReconciled`.
@@ -290,7 +290,7 @@ interface RunUsageSummary {
| `updateSessionFromEvent` swallows every error | `src/daemon/daemon.ts:1350` (`catch {}`) | a ledger bug would silently stop usage updates | ledger unit tests cover the arithmetic; the daemon test asserts materialized columns after each worker fixture |
| Rows live across the upgrade have usage but no attribution | `src/store/sessions.ts` `usage_*` | first observation would overwrite them with a smaller sum | seed attribution in `UsageLedger` (incoming source when unmarked, else `seed:`) |
| Statusline observation on every render writes to SQLite | `plugin/statusline.sh:180` | write amplification | the no-op path only reads the mark; writes happen only when a field moves |
-| Transcript scan cost | `~/.claude/projects` (84 files observed, some above 50 MB) | slow release | `readline` streaming, only unreconciled links, startup reconcile runs off the critical path |
+| Transcript scan cost | `~/.claude/projects` (84 files observed, some above 50 MB) | slow release | `readline` streaming (a 33 MB transcript read in 58 ms), reads bounded to the row's own links, startup reconcile runs off the critical path |
| Three copies of the `open` spawn path | `src/cli/commands/open.ts:652-860` | watcher started on one path only | start it where `sessionFile` and `runId` are both known; one test per path is not needed if the helper is shared, the task checks the three call sites |
| Plugin copy under `dist/plugin` | `npm run build:plugin` | hook and statusline edits do not reach the running install | the Execute close step runs `npm run build:plugin` |
| Two live `open` processes on one native id | spec "Usage model" known limit | smaller increases can be missed | accepted in the spec, never double counts |
From b60fb3aceaabe5f3b7d4a42a588a56182aee2ab9 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:47:04 -0300
Subject: [PATCH 39/42] fix(usage): Guard link re-read state and Claude seed
fallback
---
src/daemon/daemon.ts | 11 ++++--
tests/orchestrator-usage-daemon.test.ts | 48 +++++++++++++++++++++++++
2 files changed, 56 insertions(+), 3 deletions(-)
diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts
index 208b773..6e8fd09 100644
--- a/src/daemon/daemon.ts
+++ b/src/daemon/daemon.ts
@@ -351,7 +351,10 @@ class Daemon {
if (selectedIds && !selectedIds.has(link.nativeId)) continue;
const transcript = findTranscript(link.nativeId);
if (!transcript) {
- this.nativeLinks.markReconciled(sessionId, link.nativeId, "missing");
+ // A transcript that vanished after a successful read keeps its known state.
+ if (link.state === null || link.state === "missing") {
+ this.nativeLinks.markReconciled(sessionId, link.nativeId, "missing");
+ }
continue;
}
@@ -1534,8 +1537,10 @@ class Daemon {
`).get(sessionId, currentSequence) as { count: number };
const sourceKey = workerSourceKey(sess, processOrdinal.count);
if (sourceKey && !ev.incremental) {
- const seedIntoIncoming = !sourceKey.startsWith("claude:") ||
- this.previousUsageProcessOrdinal(sessionId, currentSequence, sequence) === processOrdinal.count;
+ const previousOrdinal = sourceKey.startsWith("claude:")
+ ? this.previousUsageProcessOrdinal(sessionId, currentSequence, sequence)
+ : undefined;
+ const seedIntoIncoming = previousOrdinal === undefined || previousOrdinal === processOrdinal.count;
this.usageLedger.observe(sessionId, sourceKey, next, seedIntoIncoming);
if (next.model) this.sessions.update(sessionId, { model: next.model });
} else if (ev.incremental) {
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index 876a919..a5415d3 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -126,6 +126,36 @@ describe("orchestrator usage daemon methods", () => {
]);
});
+ it("seeds legacy Claude usage into the incoming source when no prior usage event exists", () => {
+ const sessionId = "legacy-claude-no-prior";
+ const nativeId = "native-no-prior";
+ seed(daemon!, sessionId, "working", {
+ agent: "claude",
+ nativeSessionId: nativeId,
+ usage: { cost: 0.4 },
+ });
+ const events = seam(daemon!).events;
+ const timestamp = new Date().toISOString();
+ events.append(sessionId, {
+ type: "session.started",
+ sessionId,
+ timestamp,
+ agent: "claude",
+ nativeSessionId: nativeId,
+ });
+ const nextUsage = {
+ type: "usage.updated" as const,
+ sessionId,
+ timestamp,
+ usage: { cost: 0.5 },
+ };
+ const sequence = events.append(sessionId, nextUsage);
+
+ (daemon as any).updateSessionFromEvent(sessionId, nextUsage, sequence);
+
+ expect(seam(daemon!).sessions.get(sessionId)?.usage?.cost).toBeCloseTo(0.5, 8);
+ });
+
it("keeps legacy Claude usage when the next process reports its own cost", () => {
const sessionId = "legacy-claude-row";
const nativeId = "native-legacy";
@@ -279,6 +309,24 @@ describe("orchestrator usage daemon methods", () => {
]);
});
+ it("keeps the known state of a reconciled link whose transcript later disappears", async () => {
+ seedOpen("row-vanished");
+ installTranscript("native-gone", "cost-state-10.jsonl");
+ await request("session.linkNative", { id: "row-vanished", nativeId: "native-gone" });
+ await request("session.release", { id: "row-vanished" });
+ expect((daemon as any).nativeLinks.linksFor(["row-vanished"])).toMatchObject([
+ { nativeId: "native-gone", state: "cost-state" },
+ ]);
+
+ fs.rmSync(transcriptFile("native-gone"));
+ await (daemon as any).reconcileOpenUsage("row-vanished");
+
+ expect((daemon as any).nativeLinks.linksFor(["row-vanished"])).toMatchObject([
+ { nativeId: "native-gone", state: "cost-state" },
+ ]);
+ expect(seam(daemon!).sessions.get("row-vanished")?.usage?.cost).toBe(10);
+ });
+
it("re-reads reconciled links on release and keeps each source total once", async () => {
seedOpen("row-multiple-links");
installTranscript("native-x", "cost-state-10.jsonl");
From c25781134ca23879889498c833aebe2feb586925 Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 22:04:22 -0300
Subject: [PATCH 40/42] test(usage): Assert usage.query keeps cost of a
vanished transcript
---
tests/orchestrator-usage-daemon.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/orchestrator-usage-daemon.test.ts b/tests/orchestrator-usage-daemon.test.ts
index a5415d3..d2c46d7 100644
--- a/tests/orchestrator-usage-daemon.test.ts
+++ b/tests/orchestrator-usage-daemon.test.ts
@@ -325,6 +325,9 @@ describe("orchestrator usage daemon methods", () => {
{ nativeId: "native-gone", state: "cost-state" },
]);
expect(seam(daemon!).sessions.get("row-vanished")?.usage?.cost).toBe(10);
+ const query = await request("usage.query", { period: "all" });
+ expect(query.result.byOrigin.find((bucket: { key: string }) => bucket.key === "orchestrator"))
+ .toMatchObject({ costUsd: 10, costComplete: true });
});
it("re-reads reconciled links on release and keeps each source total once", async () => {
From 162c220ee56325e6da3a91b14e63406b5cc1968b Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 22:04:22 -0300
Subject: [PATCH 41/42] docs(usage): Keep known link state when a transcript
vanishes
---
.specs/features/orchestrator-usage/design.md | 2 +-
.specs/features/orchestrator-usage/spec.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.specs/features/orchestrator-usage/design.md b/.specs/features/orchestrator-usage/design.md
index 81fa073..f25dcde 100644
--- a/.specs/features/orchestrator-usage/design.md
+++ b/.specs/features/orchestrator-usage/design.md
@@ -131,7 +131,7 @@ Flows:
- **Mapping**:
- `cost-state` found: cost = `totalCostUSD`. Tokens are summed over `modelUsage`: input = `inputTokens`, output = `outputTokens`, cached = `cacheReadInputTokens + cacheCreationInputTokens`, the same cached rule as `src/drivers/claude/parser.ts:117`. Model = the `modelUsage` key with the highest `costUSD`.
- No `cost-state`: tokens from the deduped assistant sum. Cost = `computeSessionCost` per model when every model has a price (ORCH-09), otherwise no cost field and state `no-price` (ORCH-10).
- - No file: state `missing` (ORCH-11).
+ - No file: state `missing` (ORCH-11), only when the link has no state yet or is already `missing`. A link read before (`cost-state`, `tokens`, `no-price`) keeps its state, so a transcript removed by Claude Code cleanup does not erase a known cost. Growth written after the last read and before the file vanished is not recovered.
### Reconciler (daemon)
diff --git a/.specs/features/orchestrator-usage/spec.md b/.specs/features/orchestrator-usage/spec.md
index 9870a67..879088c 100644
--- a/.specs/features/orchestrator-usage/spec.md
+++ b/.specs/features/orchestrator-usage/spec.md
@@ -122,7 +122,7 @@ Worked example for one native id `X` observed by two `open` rows, used by ORCH-1
8. ORCH-08: IF a linked transcript has no `cost-state` line THEN the system SHALL record as token observation the sum of `message.usage` over `assistant` lines deduplicated by `message.id` + `requestId`.
9. ORCH-09: WHEN ORCH-08 produced the token observation and the model has a price in the static table THEN the system SHALL record the priced tokens as the cost observation.
10. ORCH-10: IF ORCH-08 produced the token observation and the model has no price THEN the system SHALL count the row as without cost in `sessionsWithoutCost`.
-11. ORCH-11: IF no transcript file exists for a linked native id THEN the system SHALL count the row as without cost.
+11. ORCH-11: IF no transcript file exists for a linked native id that was never read successfully THEN the system SHALL count the row as without cost. A link already read with a known state keeps that state when its file later disappears.
12. ORCH-12: IF no transcript file exists for a linked native id THEN `session.release` SHALL still set the row to the status the caller requested (`completed` or `failed`).
13. ORCH-13: WHEN an observation is lower than or equal to the source's high-water mark THEN the system SHALL leave every row's attributed usage unchanged.
14. ORCH-14: WHEN a row observes a value above the source's high-water mark THEN the system SHALL add the difference to that row's attributed usage.
From 39ea0e11830c8522ca66b1f929caaf301132a04c Mon Sep 17 00:00:00 2001
From: 4ndreello <4ndreello@users.noreply.github.com>
Date: Tue, 22 Sep 2026 22:13:11 -0300
Subject: [PATCH 42/42] test(plugin): Ignore EPIPE when the hook exits before
reading stdin
---
tests/session-id-hook.test.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tests/session-id-hook.test.ts b/tests/session-id-hook.test.ts
index 38ea01d..93cd680 100644
--- a/tests/session-id-hook.test.ts
+++ b/tests/session-id-hook.test.ts
@@ -16,6 +16,11 @@ async function runHook(input: string, env: NodeJS.ProcessEnv): Promise<{ exitCod
const exitCode = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
+ // The hook exits without reading stdin when the session file is unset, so
+ // the write can hit a closed pipe; that is expected, not a failure.
+ child.stdin.on("error", (error: NodeJS.ErrnoException) => {
+ if (error.code !== "EPIPE") reject(error);
+ });
child.stdin.end(input);
});