Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation
|
|
// reads `~/.claude/projects/<projDir>/<sessionId>.jsonl` and sums the token counts each assistant
|
|
// message reports; this ports the token extraction and DROPS the per-message account-attribution
|
|
// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so
|
|
// there is nothing to attribute per message.
|
|
//
|
|
// The fields are ported from the port map: assistant lines carry
|
|
// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`,
|
|
// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`.
|
|
|
|
import { createInterface } from "node:readline";
|
|
import { createReadStream } from "node:fs";
|
|
|
|
/** One session's totals — the session-grain usage row ADR 0054 fixes. */
|
|
export interface SessionUsage {
|
|
sessionId: string;
|
|
model: string | null;
|
|
gitBranch: string | null;
|
|
cwd: string | null;
|
|
turns: number;
|
|
inputTokens: number;
|
|
cacheCreationTokens: number;
|
|
cacheReadTokens: number;
|
|
outputTokens: number;
|
|
costUSD: number;
|
|
startedAt: string | null;
|
|
lastActive: string | null;
|
|
}
|
|
|
|
interface Line {
|
|
type?: string;
|
|
timestamp?: string;
|
|
cwd?: string;
|
|
gitBranch?: string;
|
|
costUSD?: number;
|
|
message?: {
|
|
model?: string;
|
|
usage?: {
|
|
input_tokens?: number;
|
|
cache_creation_input_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
output_tokens?: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
function empty(sessionId: string): SessionUsage {
|
|
return {
|
|
sessionId,
|
|
model: null,
|
|
gitBranch: null,
|
|
cwd: null,
|
|
turns: 0,
|
|
inputTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
cacheReadTokens: 0,
|
|
outputTokens: 0,
|
|
costUSD: 0,
|
|
startedAt: null,
|
|
lastActive: null,
|
|
};
|
|
}
|
|
|
|
/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */
|
|
export function foldLine(acc: SessionUsage, raw: string): SessionUsage {
|
|
const line = parse(raw);
|
|
if (!line) return acc;
|
|
|
|
if (line.timestamp) {
|
|
if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp;
|
|
if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp;
|
|
}
|
|
if (line.type === "user") {
|
|
if (line.cwd) acc.cwd = line.cwd;
|
|
if (line.gitBranch) acc.gitBranch = line.gitBranch;
|
|
}
|
|
if (line.type === "assistant") {
|
|
acc.turns += 1;
|
|
const u = line.message?.usage ?? {};
|
|
acc.inputTokens += u.input_tokens ?? 0;
|
|
acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
|
|
acc.cacheReadTokens += u.cache_read_input_tokens ?? 0;
|
|
acc.outputTokens += u.output_tokens ?? 0;
|
|
acc.costUSD += line.costUSD ?? 0;
|
|
if (!acc.model && line.message?.model) acc.model = line.message.model;
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
function parse(raw: string): Line | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
return JSON.parse(trimmed) as Line;
|
|
} catch {
|
|
// A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and
|
|
// a half-written last line is ordinary.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */
|
|
export function foldTranscript(sessionId: string, text: string): SessionUsage {
|
|
return text.split("\n").reduce(foldLine, empty(sessionId));
|
|
}
|
|
|
|
/** Stream one `<sessionId>.jsonl` file line by line, so a large transcript never loads whole. */
|
|
export async function readSessionFile(path: string, sessionId: string): Promise<SessionUsage> {
|
|
const acc = empty(sessionId);
|
|
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
for await (const line of rl) foldLine(acc, line);
|
|
return acc;
|
|
}
|