Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b972f62
docs(usage): Add orchestrator usage spec, design and tasks
4ndreello Sep 22, 2026
5edad1a
fix(pricing): Price Codex cached tokens once
4ndreello Sep 22, 2026
a61f320
feat(usage): Add usage ledger tables
4ndreello Sep 22, 2026
508942c
feat(usage): Split run totals by origin
4ndreello Sep 22, 2026
e46f6cb
feat(usage): Add origin buckets to usage queries
4ndreello Sep 22, 2026
05425ff
feat(usage): Add worker source keys
4ndreello Sep 22, 2026
4af261d
feat(usage): Implement the high-water usage ledger
4ndreello Sep 22, 2026
9c3f1c3
feat(usage): Add the native session link store
4ndreello Sep 22, 2026
7f872ae
feat(usage): Read Claude transcript usage
4ndreello Sep 22, 2026
afa060c
feat(usage): Pass run ids to worker environments
4ndreello Sep 22, 2026
b6dd150
fix(usage): Record zero-valued observations
4ndreello Sep 22, 2026
aea08de
ref(usage): Use SessionStore for ledger materialization
4ndreello Sep 22, 2026
4334963
fix(usage): Ignore empty synthetic transcript usage
4ndreello Sep 22, 2026
17244b5
test(usage): Increase transcript memory fixture size
4ndreello Sep 22, 2026
1cc3fca
fix(plugin): Append session IDs to the sidecar
4ndreello Sep 22, 2026
4890631
fix(open): Read the last session id from the sidecar
4ndreello Sep 22, 2026
fadefa6
feat(open): Poll native session ids for linking
4ndreello Sep 22, 2026
f64db67
fix(statusline): Report live orchestrator usage
4ndreello Sep 22, 2026
60de66b
feat(usage): Route cumulative worker events through ledger
4ndreello Sep 22, 2026
e3a71b4
feat(usage): Add native link and live observation IPC
4ndreello Sep 22, 2026
4597c72
feat(usage): Reconcile linked Claude transcripts
4ndreello Sep 22, 2026
72be0da
feat(usage): Reconcile stale open sessions at startup
4ndreello Sep 23, 2026
7f0a07a
fix(usage): Preserve run id when resuming workers
4ndreello Sep 23, 2026
bd471cf
fix(usage): Retry terminal transcript reconciliations
4ndreello Sep 23, 2026
5c63a4d
fix(usage): Use persisted process ordinal for source keys
4ndreello Sep 23, 2026
b366cd9
feat(usage): Add live observations and origin output
4ndreello Sep 22, 2026
b3fc894
feat(open): Flush native links before release
4ndreello Sep 22, 2026
ea58f01
feat(usage): Merge legacy orchestrator entries
4ndreello Sep 23, 2026
3693f1b
feat(usage): Add historical orchestrator backfill
4ndreello Sep 23, 2026
6433bf3
fix(usage): Seed legacy usage into unmarked source
4ndreello Sep 22, 2026
a5aebaa
fix(open): Skip the link flush await when no watcher runs
4ndreello Sep 23, 2026
0c7c970
docs(usage): Align design with seed rule, backfill flag and linksFor
4ndreello Sep 23, 2026
6ca602c
test(open): Cover native session ID cleanup and close ordering
4ndreello Sep 23, 2026
a1d1d67
fix(usage): Re-read every native link on release
4ndreello Sep 23, 2026
c4b034d
fix(usage): Keep legacy cost outside the next Claude source
4ndreello Sep 23, 2026
81ee7f3
fix(usage): Mark unresolved open links as unknown cost
4ndreello Sep 23, 2026
06a61c4
fix(usage): Link the final native id before release
4ndreello Sep 23, 2026
78cc037
docs(usage): Record link re-read and Claude seed hint in design
4ndreello Sep 23, 2026
b60fb3a
fix(usage): Guard link re-read state and Claude seed fallback
4ndreello Sep 23, 2026
c257811
test(usage): Assert usage.query keeps cost of a vanished transcript
4ndreello Sep 23, 2026
162c220
docs(usage): Keep known link state when a transcript vanishes
4ndreello Sep 23, 2026
39ea0e1
test(plugin): Ignore EPIPE when the hook exits before reading stdin
4ndreello Sep 23, 2026
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
311 changes: 311 additions & 0 deletions .specs/features/orchestrator-usage/design.md

Large diffs are not rendered by default.

331 changes: 331 additions & 0 deletions .specs/features/orchestrator-usage/spec.md

Large diffs are not rendered by default.

624 changes: 624 additions & 0 deletions .specs/features/orchestrator-usage/tasks.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .specs/features/run-usage-statusline/spec.md
Original file line number Diff line number Diff line change
@@ -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 <run-id> --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.
Expand Down
2 changes: 1 addition & 1 deletion plugin/hooks/session-id.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
43 changes: 40 additions & 3 deletions plugin/statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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);
};
Expand Down
32 changes: 32 additions & 0 deletions src/cli/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -656,6 +657,27 @@ export function registerOpenCommand(program: Command): void {
...(opts.resume !== undefined ? { resume: opts.resume } : {}),
});
const runId = adoptRes.session.id;
let linkWatcher: ReturnType<typeof startLinkWatcher> | undefined;
let linkWatcherFlush: Promise<void> | 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<unknown> | undefined;
try {
Expand Down Expand Up @@ -712,6 +734,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
Expand Down Expand Up @@ -812,6 +835,7 @@ export function registerOpenCommand(program: Command): void {
fs.writeFileSync(sessionFile, id);
} catch {}
}
if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
Expand Down Expand Up @@ -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 {}
}
if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
const nativeSessionId = finishOpenSession(role, sessionFile);
try {
if (patchPromise) await patchPromise;
Expand Down Expand Up @@ -911,6 +942,7 @@ export function registerOpenCommand(program: Command): void {
},
);
} catch (err: unknown) {
if (linkWatcher !== undefined || linkWatcherFlush !== undefined) await flushLinkWatcher();
try {
await client.request("session.release", {
id: runId,
Expand Down
177 changes: 177 additions & 0 deletions src/cli/commands/usage-backfill.ts
Original file line number Diff line number Diff line change
@@ -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<string>, value: string): void {
const nativeId = value.trim();
if (SESSION_ID_PATTERN.test(nativeId)) ids.add(nativeId);
}

function collectNativeIds(
db: DatabaseSync,
sessionsDir: string,
): { ids: Set<string>; workerIds: Set<string> } {
const ids = new Set<string>();
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<ReturnType<typeof readTranscriptUsage>>,
): 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<UsageBackfillSummary> {
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<ReturnType<typeof readTranscriptUsage>>;
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();
}
}
Loading
Loading