Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@

- **hoosage:** sync linked projects across hosts (#44) (3d57001)

## Unreleased

### Features

- **hoosage:** opt-in shared-folder sync for linking usage across local and remote hosts

## 0.12.1 (2026-09-23)

### Bug Fixes
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ Cache reads are displayed separately as reported by Copilot; they are **not adde

The extension does **not** report your invoice, remaining monthly allowance, premium requests, inline completion usage, history VS Code has already removed, other machines or GitHub cloud-agent activity. Background agents are included only when their chat spans reach the configured endpoint.

The dashboard offers 7, 14 and 30-day presets plus a **Custom** from/to range. Open the date-range picker to choose the start and end on a calendar; its month and year controls can jump to older history. The previous/next buttons move the whole selected period, stopping at today. The calendar supports arrow keys, Home/End, Page Up/Down (hold Shift to jump a year), Enter and Escape. Long ranges are grouped into legible chart intervals without changing totals. The project list always shows all projects for the selected period. CSV export uses the same period and project selection as the page. Chat calls are assigned to their start date; CLI summaries are assigned to their shutdown date. Chat usage updates after Copilot exports completed spans; CLI usage appears after session shutdown. hoosage polls local history every five seconds.
The dashboard offers 7, 14 and 30-day presets plus a **Custom** from/to range. Open the date-range picker to choose the start and end on a calendar; its month and year controls can jump to older history. The previous/next buttons move the whole selected period, stopping at today. The calendar supports arrow keys, Home/End, Page Up/Down (hold Shift to jump a year), Enter and Escape. Long ranges are grouped into legible chart intervals without changing totals. The project list always shows all projects for the selected period. CSV export uses the same period and project selection as the page. Chat calls are assigned to their start date; CLI tokens and request counts are assigned to their shutdown date, and CLI cost to the usage checkpoint or shutdown that reported it. Chat usage updates after Copilot exports completed spans; CLI cost appears with the CLI's periodic usage checkpoints (including still-running sessions), tokens and request counts after session shutdown. hoosage polls local history every five seconds.

## Costs in US dollars

When a chat span includes `copilot_chat.copilot_usage_nano_aiu`, hoosage uses that reported per-request value. Nano-AIU / 1,000,000,000 gives AI credits; one AI credit is $0.01. An explicit reported zero is preserved. Session-wide cost attributes are never added to per-request costs.

For Copilot CLI and JetBrains session summaries, hoosage also prefers the reported per-model `totalNanoAiu` increment when consecutive cumulative snapshots permit a reliable difference. Otherwise, **≈** marks an estimate using the [GitHub Copilot model price table](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing), checked **2026-09-23**. The bundled table includes cache-read/write rates and long-context tiers. Cache reads and writes are subsets of OTel input tokens: `(input − reads − writes) × input rate + reads × cache rate + writes × write rate + output × output rate`, divided by one million.
For Copilot CLI and JetBrains sessions, hoosage uses the reported session-wide cumulative `totalNanoAiu` from usage checkpoints and shutdowns when present. The CLI's per-model `modelMetrics` restart after context compaction or an interrupted run and leave out sub-agent calls, so cost they do not cover is added to the session's current model; per-model tokens and request counts still come from `modelMetrics`. Older sessions without the session total fall back to the per-model `totalNanoAiu` increment when consecutive cumulative snapshots permit a reliable difference. Otherwise, **≈** marks an estimate using the [GitHub Copilot model price table](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing), checked **2026-09-23**. The bundled table includes cache-read/write rates and long-context tiers. Cache reads and writes are subsets of OTel input tokens: `(input − reads − writes) × input rate + reads × cache rate + writes × write rate + output × output rate`, divided by one million.

Missing cache details are assumed zero and flagged in cost details. Unknown models, incomplete token counts and inconsistent cache counts remain unpriced. A CLI session aggregate also stays unpriced when its total could cross a per-request long-context threshold. **—** means no amount is available; **+** marks a subtotal that excludes unpriced usage. Expired promotional rates are not used for later calls. Historical calls without reported cost are estimated at the snapshot rates, not historical prices.

Expand All @@ -113,7 +113,7 @@ To aggregate one known checkout across Windows and WSL, run **hoosage: Configure

A saved or multi-root workspace is one **Workspace group**. Copilot's telemetry cannot reliably divide a single request across roots, so hoosage does not invent that precision. Open roots in separate VS Code windows to track them independently.

Copilot CLI sessions are read from `~/.copilot/session-state` (or `$COPILOT_HOME/session-state`) — no setup required. Completed sessions automatically discover projects from their recorded working directory, including projects never opened in VS Code. Sessions launched in a Git subdirectory group under that Git root; sessions under an already registered workspace use its project group. Missing or ambiguous working directories stay in **Copilot CLI**. Sessions created by the GitHub Copilot plugin for JetBrains IDEs (`client_name: copilot-intellij` in `workspace.yaml`) are labelled JetBrains and follow the same discovery rule; unresolved sessions stay under **Copilot (JetBrains)**. Project names and local path hashes are kept on this host; raw paths and repository URLs are not exported.
Copilot CLI sessions are read from `~/.copilot/session-state` (or `$COPILOT_HOME/session-state`) — no setup required. Sessions with reported usage automatically discover projects from their recorded working directory, including projects never opened in VS Code. Sessions launched in a Git subdirectory group under that Git root; sessions under an already registered workspace use its project group. Missing or ambiguous working directories stay in **Copilot CLI**. Sessions created by the GitHub Copilot plugin for JetBrains IDEs (`client_name: copilot-intellij` in `workspace.yaml`) are labelled JetBrains and follow the same discovery rule; unresolved sessions stay under **Copilot (JetBrains)**. Project names and local path hashes are kept on this host; raw paths and repository URLs are not exported.

## Local data and settings

Expand Down
193 changes: 159 additions & 34 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ interface FileState {
baselines: Map<string, ModelSnapshot>;
/** workspace.yaml probe: undefined until read, null when absent/unreadable. */
workspace?: { clientName?: string; cwd?: string } | null;
/** Latest model the session named; receives session-level cost. */
model?: string;
/** Last raw session-level cumulative nano-AIU counter value. */
totalRaw?: number;
/** Sum of earlier counter runs (older CLIs restart the counter on resume). */
totalOffset: number;
/** Session-level nano-AIU already emitted as entries. */
accounted: number;
}
const freshState = (): FileState => ({
offset: 0,
Expand All @@ -60,6 +68,8 @@ const freshState = (): FileState => ({
seq: 0,
emitted: new Set(),
baselines: new Map(),
totalOffset: 0,
accounted: 0,
});

/** Minimal field extraction from workspace.yaml — a flat CLI-written file.
Expand Down Expand Up @@ -110,14 +120,25 @@ const record = (value: unknown): Record<string, unknown> | undefined =>
? (value as Record<string, unknown>)
: undefined;

const modelName = (value: unknown): string | undefined =>
typeof value === "string"
? value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 160) || undefined
: undefined;

type Resolve = (cwd: string) => string | undefined;

/** Incremental reader for Copilot CLI session-state event streams
* (<root>/<session-id>/events.jsonl). Only type, id, timestamp,
* data.context.cwd and data.modelMetrics are ever extracted — prompts and
* tool arguments in these files are never retained. Shutdown metrics are
* cumulative per (session, model), so each event emits the delta against
* the stored baseline; inputTokens stays inclusive of cache tokens. */
* data.context.cwd, model names, data.modelMetrics and the session-level
* totalNanoAiu are ever extracted — prompts and tool arguments in these files
* are never retained. Shutdown metrics are cumulative per (session, model),
* so each event emits the delta against the stored baseline; inputTokens
* stays inclusive of cache tokens. The CLI resets modelMetrics on compaction
* and omits sub-agent calls, so when the session-level cumulative
* totalNanoAiu (usage checkpoints and shutdowns) is present it is
* authoritative for cost: checkpoints emit cost-only increments (tokens and
* requests are counted at shutdown) and each shutdown adds what is still
* missing to the current model. */
export class CliUsageScanner {
readonly calls = new Map<string, UsageCall>();
skippedLines = 0;
Expand Down Expand Up @@ -316,22 +337,57 @@ export class CliUsageScanner {
}
return;
}
if (event.type !== "session.shutdown") return;
const timestamp = Date.parse(event.timestamp as string);
if (!Number.isFinite(timestamp) || timestamp > Date.now() + MAX_SKEW) {
this.skippedLines++;
if (event.type === "session.model_change") {
const model = modelName(record(event.data)?.newModel);
if (model) st.model = model;
return;
}
const metrics = record(record(event.data)?.modelMetrics);
if (!metrics) return;
const eventId =
typeof event.id === "string" && event.id
? event.id
: typeof event.id === "number" && Number.isFinite(event.id)
? String(event.id)
: // Events without an id fall back to timestamp + file ordinal, so
// same-millisecond shutdowns cannot overwrite each other.
`t${timestamp}-${st.seq}`;
if (event.type === "session.usage_checkpoint") {
const data = record(event.data);
const reported = num(data?.totalNanoAiu);
if (reported === undefined) return;
const stamp = this.stamp(event, st);
if (!stamp) return;
const caches = data?.modelCacheState;
const model = modelName(
Array.isArray(caches) ? record(caches[0])?.modelId : undefined,
);
if (model) st.model = model;
const total = this.sessionTotal(st, reported);
const increment = total - st.accounted;
if (increment <= 0) return;
st.accounted = total;
// Cost only: tokens and requests of this interval arrive with the
// shutdown's modelMetrics, so zero keeps them from being counted twice.
this.emit(sessionId, st, resolve, {
id: `cli:${sessionId}:${stamp.eventId}:${st.model ?? "unknown"}`,
timestamp: stamp.timestamp,
model: st.model ?? "unknown",
input: 0,
output: 0,
requests: 0,
nanoAiu: increment,
});
return;
}
if (event.type !== "session.shutdown") return;
const stamp = this.stamp(event, st);
if (!stamp) return;
const { timestamp, eventId } = stamp;
const data = record(event.data);
const currentModel = modelName(data?.currentModel);
if (currentModel) st.model = currentModel;
// Session-level cost not yet emitted by earlier checkpoints/shutdowns.
// Undefined keeps the per-model cost behaviour of CLIs without the total.
let unassigned: number | undefined;
const reportedTotal = num(data?.totalNanoAiu);
if (reportedTotal !== undefined) {
const total = this.sessionTotal(st, reportedTotal);
unassigned = Math.max(0, total - st.accounted);
st.accounted = Math.max(st.accounted, total);
}
const emitted = new Map<string, UsageCall>();
const metrics = record(data?.modelMetrics) ?? {};
for (const [model, raw] of Object.entries(metrics)) {
if (!model) continue;
const entry = record(raw);
Expand Down Expand Up @@ -379,26 +435,95 @@ export class CliUsageScanner {
!delta.nanoAiu
)
continue;
const id = `cli:${sessionId}:${eventId}:${model}`;
this.calls.set(id, {
id,
projectId: this.projectIdFor(st, resolve),
let nanoAiu = delta.nanoAiu;
if (unassigned !== undefined) {
// The session total already covers this model's cost; never exceed
// it and never fall back to a token estimate on top of it.
nanoAiu = Math.min(nanoAiu ?? 0, unassigned);
unassigned -= nanoAiu;
}
emitted.set(
model,
this.emit(sessionId, st, resolve, {
id: `cli:${sessionId}:${eventId}:${model}`,
timestamp,
model,
input: delta.input,
output: delta.output,
cacheRead: delta.cacheRead,
cacheWrite: delta.cacheWrite,
requests: delta.requests,
nanoAiu,
}),
);
}
if (!unassigned) return;
const model = st.model ?? "unknown";
const call = emitted.get(model);
if (call) call.nanoAiu = (call.nanoAiu ?? 0) + unassigned;
else
this.emit(sessionId, st, resolve, {
id: `cli:${sessionId}:${eventId}:${model}`,
timestamp,
model,
sessionId,
source:
st.workspace?.clientName === JETBRAINS_CLIENT ? "jetbrains" : "cli",
input: delta.input,
output: delta.output,
cacheRead: delta.cacheRead,
cacheWrite: delta.cacheWrite,
requests: delta.requests,
nanoAiu: delta.nanoAiu,
failed: false,
input: 0,
output: 0,
requests: 0,
nanoAiu: unassigned,
});
if (st.cwd) this.callCwds.set(id, st.cwd);
st.emitted.add(id);
}

/** Validated timestamp and a stable per-file event id. */
private stamp(
event: Record<string, unknown>,
st: FileState,
): { timestamp: number; eventId: string } | undefined {
const timestamp = Date.parse(event.timestamp as string);
if (!Number.isFinite(timestamp) || timestamp > Date.now() + MAX_SKEW) {
this.skippedLines++;
return undefined;
}
const eventId =
typeof event.id === "string" && event.id
? event.id
: typeof event.id === "number" && Number.isFinite(event.id)
? String(event.id)
: // Events without an id fall back to timestamp + file ordinal, so
// same-millisecond events cannot overwrite each other.
`t${timestamp}-${st.seq}`;
return { timestamp, eventId };
}

/** Advances the session-level cumulative counter and returns the session
* total. A drop means the CLI restarted its counters (older versions do on
* resume): earlier runs stay counted and per-model baselines restart. */
private sessionTotal(st: FileState, reported: number): number {
if (st.totalRaw !== undefined && reported < st.totalRaw) {
st.totalOffset += st.totalRaw;
st.baselines.clear();
}
st.totalRaw = reported;
return st.totalOffset + reported;
}

private emit(
sessionId: string,
st: FileState,
resolve: Resolve,
call: Omit<UsageCall, "projectId" | "sessionId" | "source" | "failed">,
): UsageCall {
const full: UsageCall = {
...call,
projectId: this.projectIdFor(st, resolve),
sessionId,
source:
st.workspace?.clientName === JETBRAINS_CLIENT ? "jetbrains" : "cli",
failed: false,
};
this.calls.set(full.id, full);
if (st.cwd) this.callCwds.set(full.id, st.cwd);
st.emitted.add(full.id);
return full;
}

/** Use the current project index; stored intervals are rechecked each poll. */
Expand Down
Loading
Loading