@volter/supercode-interchange 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/package.json +1 -1
- package/session-read.d.ts +10 -1
- package/session-read.mjs +6 -3
- package/session-spend-files.mjs +97 -0
- package/session-spend-metadata.mjs +74 -0
- package/session-spend.mjs +169 -0
- package/session-usage.mjs +4 -1
package/README.md
CHANGED
|
@@ -131,13 +131,31 @@ price. Costs use the pinned 2026-10-03 native rate snapshot, including cache TTL
|
|
|
131
131
|
fast-mode and inference-geo factors; this is not a live pricing feed. Exact u64
|
|
132
132
|
wire counts are retained independently of JavaScript number rounding. A separate
|
|
133
133
|
`usage` marker refuses older read services without closing their catalog lane.
|
|
134
|
-
|
|
134
|
+
Runtime budgets retain their separate native consumers.
|
|
135
135
|
[Private Mac/Windows conversations](../../docs/evidence/session-usage-port-2026-10-08.md)
|
|
136
136
|
record small Claude/Codex usage and refusal examples through npm stdio and
|
|
137
137
|
loopback operator streams. Other-format/runtime corpus, concurrent replacement,
|
|
138
138
|
scale, remote revocation, all pricing/time branches and a new Windows native
|
|
139
139
|
artifact remain unqualified; these records do not establish an installed set.
|
|
140
140
|
|
|
141
|
+
`sessions.spend` reads recently modified Claude/Codex transcript tails in the
|
|
142
|
+
caller-owned load worker and counts child use on each parent. Its tail stop is
|
|
143
|
+
ten minutes before the requested window; Codex additionally keeps a preceding
|
|
144
|
+
total and model. Machine spend filters Claude usage before the last-response-id
|
|
145
|
+
fold, while per-session usage filters afterward, preserving their distinct
|
|
146
|
+
contracts. Costs use the same pinned rate snapshot. Counts stay exact on raw
|
|
147
|
+
wire frames; SDK JavaScript numbers can round. Totals count all sessions before
|
|
148
|
+
the requested row limit. Live registry names and enrolled-machine addresses are
|
|
149
|
+
display metadata, never proof of caller authority. A separate `spend` marker
|
|
150
|
+
refuses an older service. The npm `sessions usage` command uses this reader
|
|
151
|
+
without requiring a native accounting handler. Runtime budgets, shared native
|
|
152
|
+
record readers and pricing retain their own consumers.
|
|
153
|
+
[Private Mac/Windows spend records](../../docs/evidence/session-machine-spend-2026-10-08.md)
|
|
154
|
+
cover small parent/child, tail-boundary, sort/limit and refusal cases through
|
|
155
|
+
npm and loopback operator streams, plus the new Mac native CLI handoff.
|
|
156
|
+
Large corpus, concurrent changes, every live-name/address branch, installed
|
|
157
|
+
sets and new Windows native forwarding remain unqualified.
|
|
158
|
+
|
|
141
159
|
`resolveHarnessHomes` follows native HOME-first defaults and preserves explicitly empty harness-specific
|
|
142
160
|
home values. `projectMetadata` applies workspace scope, native lineage, repository-family, profile, time and
|
|
143
161
|
metadata-search filters with stable newest-first ordering. It clones raw rows before descendant roll-up.
|
package/package.json
CHANGED
package/session-read.d.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { SessionFollower } from './session-codecs.js';
|
|
|
3
3
|
import type { ProjectedSessionResult, NormalizedSession, RecordedSessionProfile, RecordedSessionProfileChange } from './session-model.js';
|
|
4
4
|
export const SESSION_READ_METHODS: readonly string[];
|
|
5
5
|
export const SESSION_READ_EVENT: 'harness.v1.sessions.event';
|
|
6
|
-
export const SESSION_READ_CAPABILITY: Readonly<{ version: 1; owner: 'caller'; language: 'typescript'; native_parser: false; recorded_config: true; launch_agent: true; usage: true }>;
|
|
6
|
+
export const SESSION_READ_CAPABILITY: Readonly<{ version: 1; owner: 'caller'; language: 'typescript'; native_parser: false; recorded_config: true; launch_agent: true; usage: true; spend: true }>;
|
|
7
7
|
export interface SessionReadContext { cwd?: string; env?: Record<string, string | undefined> }
|
|
8
8
|
export function parseSessionWire(text: string): unknown;
|
|
9
9
|
export function stringifySessionWire(value: unknown): string;
|
|
10
|
+
export function prettySessionWire(value: unknown): string;
|
|
10
11
|
export function parseSessionReadParams(params: unknown, context?: SessionReadContext): { locator: ReadLocator; fidelity: string; view: (SessionFollowOptions & { fromByte: number | null }) | null; options: import('./session-model.js').SessionLoadOptions | null };
|
|
11
12
|
export function loadSessionRead(params: unknown, context?: SessionReadContext): Promise<ProjectedSessionResult | { session: NormalizedSession & { resolved_profile: RecordedSessionProfile; resolved_profile_history: RecordedSessionProfileChange[] } }>;
|
|
12
13
|
export function openSessionReadFollower(params: unknown, context?: SessionReadContext): Promise<SessionFollower>;
|
|
@@ -23,3 +24,11 @@ export interface SessionTokenUsage {
|
|
|
23
24
|
}
|
|
24
25
|
/** Recorded tokens and the pinned rate snapshot; exact native integers survive wire serialization. */
|
|
25
26
|
export function loadSessionUsage(params: unknown, context?: SessionReadContext): Promise<{ harness: string; session_id: string | null; since?: string; usage: SessionTokenUsage[] }>;
|
|
27
|
+
export type RecordedTokenSpend = Omit<SessionTokenUsage, 'at' | 'model'>;
|
|
28
|
+
export interface MachineTokenSpend extends RecordedTokenSpend {
|
|
29
|
+
harness: string; session_id: string; address: string | null; name: string | null;
|
|
30
|
+
cwd: string | null; subagents: number; last_at: string | null;
|
|
31
|
+
models: Array<RecordedTokenSpend & { model: string }>;
|
|
32
|
+
}
|
|
33
|
+
/** Recently modified Claude/Codex tails, including child use on the parent. */
|
|
34
|
+
export function loadSessionSpend(params: unknown, context?: SessionReadContext): Promise<{ since: string; until: string; session_count: number; sessions: MachineTokenSpend[]; total: RecordedTokenSpend }>;
|
package/session-read.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Caller-owned public read operations. Parsing never goes to a native service
|
|
2
2
|
// or to a machine daemon carrying another caller's home/environment.
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson } from './native-json.mjs';
|
|
4
|
+
import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson, stringifyNativeJsonPretty } from './native-json.mjs';
|
|
5
5
|
import { loadSessionLocator } from './session-path.mjs';
|
|
6
6
|
import { loadSessionDisplayLocator, loadSessionDisplayFrom } from './session-display.mjs';
|
|
7
7
|
import { withClaudeReadIndex, supportsClaudeReadIndex } from './session-claude-index.mjs';
|
|
@@ -11,12 +11,15 @@ import { boundSessionView, projectedMessageWindow, projectedSessionJson, project
|
|
|
11
11
|
import { recordedSessionConfiguration } from './session-recorded-config.mjs';
|
|
12
12
|
import { recordedLaunchAgent } from './session-launch-agent.mjs';
|
|
13
13
|
import { recordedSessionUsage } from './session-usage.mjs';
|
|
14
|
+
import { loadMachineSpend } from './session-spend.mjs';
|
|
14
15
|
import { parseUsageSince, usageMoment } from './session-usage-time.mjs';
|
|
15
16
|
|
|
16
|
-
export const SESSION_READ_METHODS = Object.freeze(['harness.v1.sessions.load', 'harness.v1.sessions.follow', 'harness.v1.sessions.unfollow', 'harness.v1.sessions.recorded_config', 'harness.v1.sessions.launch_agent', 'harness.v1.sessions.usage']);
|
|
17
|
+
export const SESSION_READ_METHODS = Object.freeze(['harness.v1.sessions.load', 'harness.v1.sessions.follow', 'harness.v1.sessions.unfollow', 'harness.v1.sessions.recorded_config', 'harness.v1.sessions.launch_agent', 'harness.v1.sessions.usage', 'harness.v1.sessions.spend']);
|
|
17
18
|
export const SESSION_READ_EVENT = 'harness.v1.sessions.event';
|
|
18
|
-
export const SESSION_READ_CAPABILITY = Object.freeze({ version: 1, owner: 'caller', language: 'typescript', native_parser: false, recorded_config: true, launch_agent: true, usage: true });
|
|
19
|
+
export const SESSION_READ_CAPABILITY = Object.freeze({ version: 1, owner: 'caller', language: 'typescript', native_parser: false, recorded_config: true, launch_agent: true, usage: true, spend: true });
|
|
20
|
+
export { loadMachineSpend as loadSessionSpend };
|
|
19
21
|
export { parseNativeJson as parseSessionWire, stringifyNativeJson as stringifySessionWire };
|
|
22
|
+
export { stringifyNativeJsonPretty as prettySessionWire };
|
|
20
23
|
const invalid = (message) => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
|
|
21
24
|
const object = (value, name) => {
|
|
22
25
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) invalid(`${name} must be an object`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The machine reading uses the retained native tail boundary, independently
|
|
2
|
+
// of the complete-session loader. Unread/malformed records contribute no use.
|
|
3
|
+
import { openSync, closeSync, fstatSync, readSync, opendirSync, statSync } from 'node:fs';
|
|
4
|
+
import { sep } from 'node:path';
|
|
5
|
+
import { parseNativeJson } from './native-json.mjs';
|
|
6
|
+
import { field, stringField } from './session-codec-utils.mjs';
|
|
7
|
+
|
|
8
|
+
const blockSize = 256 * 1024;
|
|
9
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
10
|
+
export function textName(bytes) { try { return utf8.decode(bytes); } catch { return null; } }
|
|
11
|
+
export function childPath(directory, name) {
|
|
12
|
+
const root = Buffer.isBuffer(directory) ? directory : Buffer.from(directory);
|
|
13
|
+
const driveOnly = process.platform === 'win32' && root.length === 2 && /^[A-Za-z]:$/.test(root.toString('utf8'));
|
|
14
|
+
return Buffer.concat([root, root.length && !driveOnly && root.at(-1) !== 47 && (process.platform !== 'win32' || root.at(-1) !== 92) ? Buffer.from(sep) : Buffer.alloc(0), Buffer.isBuffer(name) ? name : Buffer.from(name)]);
|
|
15
|
+
}
|
|
16
|
+
export function* directoryEntries(path) {
|
|
17
|
+
let directory;
|
|
18
|
+
try { directory = opendirSync(path, { encoding: 'buffer' }); } catch { return; }
|
|
19
|
+
try {
|
|
20
|
+
for (;;) {
|
|
21
|
+
let entry; try { entry = directory.readSync(); } catch { return; }
|
|
22
|
+
if (entry == null) return;
|
|
23
|
+
yield { name: entry.name, path: childPath(path, entry.name) };
|
|
24
|
+
}
|
|
25
|
+
} finally { try { directory.closeSync(); } catch { /* already closed */ } }
|
|
26
|
+
}
|
|
27
|
+
export function isDirectory(path) { try { return statSync(path).isDirectory(); } catch { return false; } }
|
|
28
|
+
export function modifiedSince(path, since) {
|
|
29
|
+
try { const at = statSync(path, { bigint: true }).mtimeNs; return at >= 0n && BigInt.asIntN(64, at / 1000000n) >= since; }
|
|
30
|
+
catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
export const jsonl = (name) => name.lastIndexOf(46) > 0 && name.subarray(name.lastIndexOf(46)).equals(Buffer.from('.jsonl'));
|
|
33
|
+
export const stem = (name) => name.subarray(0, name.lastIndexOf(46));
|
|
34
|
+
|
|
35
|
+
function readExact(fd, bytes, position) {
|
|
36
|
+
let done = 0;
|
|
37
|
+
while (done < bytes.length) {
|
|
38
|
+
const read = readSync(fd, bytes, done, bytes.length - done, position + done);
|
|
39
|
+
if (!read) return false;
|
|
40
|
+
done += read;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function parsed(bytes) { const text = textName(bytes); if (text == null) return undefined; try { return parseNativeJson(text); } catch { return undefined; } }
|
|
45
|
+
|
|
46
|
+
/** Newest-first boundary callbacks include the stopping record; results are oldest first. */
|
|
47
|
+
export function tailRecords(path, start) {
|
|
48
|
+
let fd;
|
|
49
|
+
try { fd = openSync(path, 'r'); } catch { return []; }
|
|
50
|
+
const newest = [];
|
|
51
|
+
try {
|
|
52
|
+
let end; try { end = fstatSync(fd).size; } catch { return []; }
|
|
53
|
+
if (!Number.isSafeInteger(end)) throw new Error('transcript size exceeds the filesystem reader position range');
|
|
54
|
+
let carry = Buffer.alloc(0);
|
|
55
|
+
while (end > 0) {
|
|
56
|
+
const begin = Math.max(0, end - blockSize), bytes = Buffer.alloc(end - begin);
|
|
57
|
+
try { if (!readExact(fd, bytes, begin)) break; } catch { break; }
|
|
58
|
+
const block = Buffer.concat([bytes, carry]);
|
|
59
|
+
const first = begin === 0 ? -1 : block.indexOf(10);
|
|
60
|
+
if (begin !== 0 && first < 0) { carry = block; end = begin; continue; }
|
|
61
|
+
const from = first + 1;
|
|
62
|
+
let lineEnd = block.length, reached = false;
|
|
63
|
+
for (let at = block.length - 1; at >= from - 1; at--) {
|
|
64
|
+
if (at >= from && block[at] !== 10) continue;
|
|
65
|
+
const record = parsed(block.subarray(at + 1, lineEnd)); lineEnd = at;
|
|
66
|
+
if (record === undefined) continue;
|
|
67
|
+
reached = start(record); newest.push(record);
|
|
68
|
+
if (reached) break;
|
|
69
|
+
}
|
|
70
|
+
if (reached) break;
|
|
71
|
+
carry = first < 0 ? Buffer.alloc(0) : block.subarray(0, first); end = begin;
|
|
72
|
+
}
|
|
73
|
+
return newest.reverse();
|
|
74
|
+
} finally { closeSync(fd); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Codex's first session_meta line, bounded to the native 256 KiB header read. */
|
|
78
|
+
export function rolloutSession(path) {
|
|
79
|
+
let fd;
|
|
80
|
+
try {
|
|
81
|
+
fd = openSync(path, 'r'); const bytes = Buffer.alloc(blockSize);
|
|
82
|
+
let length = 0;
|
|
83
|
+
while (length < bytes.length) {
|
|
84
|
+
const count = readSync(fd, bytes, length, bytes.length - length, length);
|
|
85
|
+
if (!count) break;
|
|
86
|
+
const newline = bytes.indexOf(10, length); length += count;
|
|
87
|
+
if (newline >= 0 && newline < length) { length = newline + 1; break; }
|
|
88
|
+
}
|
|
89
|
+
const record = parsed(bytes.subarray(0, length));
|
|
90
|
+
if (stringField(record, 'type') !== 'session_meta') return null;
|
|
91
|
+
const payload = field(record, 'payload'), thread = stringField(payload, 'id'); if (thread == null) return null;
|
|
92
|
+
const nested = field(field(field(field(payload, 'source'), 'subagent'), 'thread_spawn'), 'parent_thread_id');
|
|
93
|
+
const parent = nested === undefined ? field(payload, 'parent_thread_id') : nested;
|
|
94
|
+
return { thread, parent: typeof parent === 'string' ? parent : null, cwd: stringField(payload, 'cwd') };
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
finally { if (fd != null) closeSync(fd); }
|
|
97
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Display metadata only: these names and addresses establish no caller grant.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, parse, sep } from 'node:path';
|
|
4
|
+
import { hostname, userInfo } from 'node:os';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { nativeInteger, parseNativeJson } from './native-json.mjs';
|
|
7
|
+
import { field, stringField } from './session-codec-utils.mjs';
|
|
8
|
+
import { childPath, directoryEntries } from './session-spend-files.mjs';
|
|
9
|
+
import { nativeTrim } from './text.mjs';
|
|
10
|
+
|
|
11
|
+
function json(path) { try { return parseNativeJson(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(readFileSync(path))); } catch { return null; } }
|
|
12
|
+
function live(pid) {
|
|
13
|
+
try { process.kill(process.platform === 'win32' ? pid : Number(BigInt.asIntN(32, BigInt(pid))), 0); return true; }
|
|
14
|
+
catch (error) { return process.platform !== 'win32' && error.code === 'EPERM'; }
|
|
15
|
+
}
|
|
16
|
+
const optionalText = (row, key) => row[key] == null || typeof row[key] === 'string';
|
|
17
|
+
const words = (text) => nativeTrim(text).split(/[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/u).join(' ');
|
|
18
|
+
function path(...parts) {
|
|
19
|
+
let result = '';
|
|
20
|
+
for (const part of parts) result = isAbsolute(part) || process.platform === 'win32' && /^[A-Za-z]:/.test(part) ? part : childPath(result, part).toString('utf8');
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
const selected = (path, cwd) => !path || isAbsolute(path) || process.platform === 'win32' && /^[A-Za-z]:/.test(path) ? path : `${cwd}${sep}${path}`;
|
|
24
|
+
|
|
25
|
+
export async function claudeSpendNames(homes, env, cwd) {
|
|
26
|
+
const entries = [];
|
|
27
|
+
const root = homes.claude_code, parent = root && root !== parse(root).root ? dirname(root) : '.';
|
|
28
|
+
for (const file of directoryEntries(selected(path(parent, 'sessions'), cwd))) {
|
|
29
|
+
const dot = file.name.lastIndexOf(46);
|
|
30
|
+
if (dot < 1 || !file.name.subarray(dot).equals(Buffer.from('.json'))) continue;
|
|
31
|
+
const row = json(file.path); if (row == null || typeof row !== 'object' || Array.isArray(row)) continue;
|
|
32
|
+
const pid = nativeInteger(row, 'pid');
|
|
33
|
+
if (pid == null || pid > 0xffffffffn || typeof row.sessionId !== 'string' || !row.sessionId || !row.name
|
|
34
|
+
|| typeof row.messagingSocketPath !== 'string' || row.parkedJobId != null
|
|
35
|
+
|| !['cwd', 'name', 'messagingSocketPath', 'status', 'version', 'tmux', 'parkedJobId', 'procStart'].every(key => optionalText(row, key))
|
|
36
|
+
|| row.updatedAt != null && nativeInteger(row, 'updatedAt') == null || !live(Number(pid))) continue;
|
|
37
|
+
entries.push({ pid: Number(pid), id: row.sessionId, name: row.name, start: row.procStart ?? null });
|
|
38
|
+
}
|
|
39
|
+
const starts = new Map();
|
|
40
|
+
if (process.platform === 'darwin' && entries.some(entry => entry.start != null)) {
|
|
41
|
+
// One process-table read for this display query; an unread start is not a mismatch.
|
|
42
|
+
await new Promise(resolve => execFile('ps', ['-o', 'pid=,lstart=', '-p', entries.map(entry => entry.pid).join(',')],
|
|
43
|
+
{ env: { ...env, TZ: 'UTC', LC_ALL: 'C' }, timeout: 2000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
44
|
+
if (!error) for (const line of stdout.split('\n')) { const match = /^\s*(\d+)\s+(.+)$/.exec(line); if (match) starts.set(Number(match[1]), words(match[2])); }
|
|
45
|
+
resolve();
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
const names = new Map();
|
|
49
|
+
for (const entry of entries.sort((a, b) => a.pid - b.pid)) {
|
|
50
|
+
let actual = starts.get(entry.pid);
|
|
51
|
+
if (process.platform === 'linux' && entry.start != null) {
|
|
52
|
+
try { const stat = readFileSync(`/proc/${entry.pid}/stat`, 'utf8'); actual = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)[19]; } catch { /* unread establishes no mismatch */ }
|
|
53
|
+
}
|
|
54
|
+
if (entry.start != null && actual != null && actual !== (process.platform === 'darwin' ? words(entry.start) : nativeTrim(entry.start))) continue;
|
|
55
|
+
names.set(entry.id, entry.name);
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function spendMachineName(env, cwd) {
|
|
61
|
+
let home; try { home = env.HOME || (process.platform === 'win32' && env.USERPROFILE) || userInfo().homedir || '.'; } catch { home = '.'; }
|
|
62
|
+
const supercodeHome = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? path(env.XDG_CONFIG_HOME, 'supercode') : path(home, '.config', 'supercode'));
|
|
63
|
+
const workspaces = selected(path(env.SUPERCODE_TEAMS_HOME || path(supercodeHome, 'teams'), 'workspaces'), cwd);
|
|
64
|
+
const contexts = json(path(workspaces, 'contexts.json')), current = stringField(contexts, 'current');
|
|
65
|
+
const context = current == null ? null : field(field(contexts, 'contexts'), current), server = stringField(context, 'server_id'), team = stringField(context, 'team_id');
|
|
66
|
+
const enrolled = server != null && team != null ? stringField(field(json(path(workspaces, 'connectors', server, team, 'enrollment.json')), 'machine'), 'name') : null;
|
|
67
|
+
let host; try { host = process.platform === 'win32' ? env.COMPUTERNAME : nativeTrim(hostname()) || null; } catch { host = null; }
|
|
68
|
+
const name = (enrolled ?? host ?? 'localhost').split('.')[0];
|
|
69
|
+
return [...name].map(c => /^[A-Z]$/.test(c) ? c.toLowerCase() : /^[a-z0-9_-]$/.test(c) ? c : '-').join('') || 'localhost';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function spendAddress(machine, harness, id) {
|
|
73
|
+
return id && !/[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u.test(id) ? `sc:${machine}:${harness}:${id}` : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Machine-wide recorded spend, preserving token_spend's bounded tail and
|
|
2
|
+
// parent/child fold. Runtime-budget readers remain with their native owners.
|
|
3
|
+
import { isAbsolute, sep } from 'node:path';
|
|
4
|
+
import { nativeInteger, nativeNumberKind, setNativeFloat, setNativeInteger } from './native-json.mjs';
|
|
5
|
+
import { field, stringField } from './session-codec-utils.mjs';
|
|
6
|
+
import { resolveHarnessHomes } from './homes.mjs';
|
|
7
|
+
import { usageTimestamp, parseUsageSince, usageMoment } from './session-usage-time.mjs';
|
|
8
|
+
import { claudeUse, price, recorded, sorted, sub, wrap } from './session-usage.mjs';
|
|
9
|
+
import { directoryEntries, childPath, isDirectory, modifiedSince, jsonl, stem, textName, tailRecords, rolloutSession } from './session-spend-files.mjs';
|
|
10
|
+
import { claudeSpendNames, spendMachineName, spendAddress } from './session-spend-metadata.mjs';
|
|
11
|
+
|
|
12
|
+
const invalid = message => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
|
|
13
|
+
const outOfOrder = 600000n, signed = n => BigInt.asIntN(64, n);
|
|
14
|
+
const atMs = record => usageTimestamp(stringField(record, 'timestamp'));
|
|
15
|
+
function sum() { return { counts: [0n, 0n, 0n, 0n, 0n], cost: 0, priced: false, unpriced: false }; }
|
|
16
|
+
function add(target, source) {
|
|
17
|
+
for (let i = 0; i < 5; i++) target.counts[i] = wrap(target.counts[i] + source.counts[i]);
|
|
18
|
+
target.cost += source.cost; target.priced ||= source.priced; target.unpriced ||= source.unpriced;
|
|
19
|
+
}
|
|
20
|
+
function count(target, record) {
|
|
21
|
+
for (let i = 0; i < 5; i++) target.counts[i] = wrap(target.counts[i] + record.counts[i]);
|
|
22
|
+
const rate = price(record.model);
|
|
23
|
+
if (rate == null) { target.unpriced = true; return; }
|
|
24
|
+
let cost = Number(record.counts[0]) / 1e6 * rate[0];
|
|
25
|
+
for (let i = 1; i < 5; i++) cost += Number(record.counts[i]) / 1e6 * rate[i];
|
|
26
|
+
target.cost += cost * (record.fast ? rate[5] : 1) * (record.usOnly ? rate[6] : 1); target.priced = true;
|
|
27
|
+
}
|
|
28
|
+
function fields(sum, row = {}) {
|
|
29
|
+
for (const [key, n] of [['input_tokens', sum.counts[0]], ['output_tokens', sum.counts[1]], ['cache_read_tokens', sum.counts[2]],
|
|
30
|
+
['cache_write_tokens', wrap(sum.counts[3] + sum.counts[4])], ['cache_write_5m_tokens', sum.counts[3]], ['cache_write_1h_tokens', sum.counts[4]]]) setNativeInteger(row, key, n);
|
|
31
|
+
if (sum.priced) { row.cost = { currency: 'USD', source: 'rate_table' }; setNativeFloat(row.cost, 'amount', Math.round(sum.cost * 1e6) / 1e6); }
|
|
32
|
+
if (sum.unpriced) row.cost_unrecorded = true;
|
|
33
|
+
return row;
|
|
34
|
+
}
|
|
35
|
+
function sessionOf(sessions, harness, id) {
|
|
36
|
+
const key = JSON.stringify([harness, id]); let session = sessions.get(key);
|
|
37
|
+
if (!session) { session = { harness, id, models: new Map(), cwd: null, children: new Set(), last: 0n }; sessions.set(key, session); }
|
|
38
|
+
return session;
|
|
39
|
+
}
|
|
40
|
+
function recordUse(session, record) {
|
|
41
|
+
let model = session.models.get(record.model); if (!model) { model = sum(); session.models.set(record.model, model); }
|
|
42
|
+
count(model, record); if (record.atMs != null && record.atMs > session.last) session.last = record.atMs;
|
|
43
|
+
}
|
|
44
|
+
function claudeFile(path, since, id, child, sessions) {
|
|
45
|
+
const records = tailRecords(path, record => { const at = atMs(record); return at != null && at < signed(since - outOfOrder); });
|
|
46
|
+
const responses = new Map(); let cwd = null;
|
|
47
|
+
for (const record of records) {
|
|
48
|
+
cwd = stringField(record, 'cwd') ?? cwd;
|
|
49
|
+
const response = claudeUse(record);
|
|
50
|
+
// Machine spend filters before the last-record fold; per-session usage
|
|
51
|
+
// filters after that fold. They are distinct retained native contracts.
|
|
52
|
+
if (response && response[1].atMs != null && response[1].atMs >= since) responses.set(...response);
|
|
53
|
+
}
|
|
54
|
+
if (!responses.size) return;
|
|
55
|
+
const session = sessionOf(sessions, 'claude-code', id);
|
|
56
|
+
for (const id of [...responses.keys()].sort(sorted)) recordUse(session, responses.get(id));
|
|
57
|
+
if (child == null || session.cwd == null) session.cwd = cwd ?? session.cwd;
|
|
58
|
+
if (child != null) session.children.add(child);
|
|
59
|
+
}
|
|
60
|
+
function claudeSpend(root, since, sessions) {
|
|
61
|
+
for (const project of directoryEntries(root)) for (const entry of directoryEntries(project.path)) {
|
|
62
|
+
if (isDirectory(entry.path)) {
|
|
63
|
+
const parent = textName(entry.name); if (parent == null) continue;
|
|
64
|
+
for (const child of directoryEntries(childPath(entry.path, 'subagents'))) if (jsonl(child.name) && modifiedSince(child.path, since)) {
|
|
65
|
+
claudeFile(child.path, since, parent, stem(child.name).toString('utf8'), sessions);
|
|
66
|
+
}
|
|
67
|
+
} else if (jsonl(entry.name) && modifiedSince(entry.path, since)) {
|
|
68
|
+
const id = textName(stem(entry.name)); if (id != null) claudeFile(entry.path, since, id, null, sessions);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function codexFile(path, since, sessions) {
|
|
73
|
+
const meta = rolloutSession(path); if (!meta) return;
|
|
74
|
+
let totalBefore = false, modelBefore = false;
|
|
75
|
+
const records = tailRecords(path, record => {
|
|
76
|
+
const at = atMs(record);
|
|
77
|
+
if (at != null && at < signed(since - outOfOrder)) {
|
|
78
|
+
totalBefore ||= stringField(field(record, 'payload'), 'type') === 'token_count';
|
|
79
|
+
modelBefore ||= stringField(record, 'type') === 'turn_context';
|
|
80
|
+
}
|
|
81
|
+
return totalBefore && modelBefore;
|
|
82
|
+
});
|
|
83
|
+
let model = 'unknown', previous = [0n, 0n, 0n], session = null;
|
|
84
|
+
for (const record of records) {
|
|
85
|
+
const payload = field(record, 'payload');
|
|
86
|
+
if (stringField(record, 'type') === 'turn_context') { model = stringField(payload, 'model') ?? model; continue; }
|
|
87
|
+
if (stringField(payload, 'type') !== 'token_count') continue;
|
|
88
|
+
const total = field(field(payload, 'info'), 'total_token_usage'); if (total === undefined) continue;
|
|
89
|
+
const now = ['input_tokens', 'output_tokens', 'cached_input_tokens'].map(key => nativeInteger(total, key) ?? 0n);
|
|
90
|
+
if (now.every((n, i) => n === previous[i])) continue;
|
|
91
|
+
const [input, output, cached] = now.map((n, i) => sub(n, previous[i])); previous = now;
|
|
92
|
+
const used = recorded(record, model, [sub(input, cached), output, cached, 0n, 0n]);
|
|
93
|
+
if (used.atMs == null || used.atMs < since) continue;
|
|
94
|
+
session ??= sessionOf(sessions, 'codex', meta.parent ?? meta.thread); recordUse(session, used);
|
|
95
|
+
}
|
|
96
|
+
if (!session) return;
|
|
97
|
+
if (meta.parent != null) session.children.add(meta.thread);
|
|
98
|
+
if ((session.cwd == null || meta.parent == null) && meta.cwd != null) session.cwd = meta.cwd;
|
|
99
|
+
}
|
|
100
|
+
function codexSpend(root, since, sessions) {
|
|
101
|
+
const stack = [[root, 0]];
|
|
102
|
+
while (stack.length) {
|
|
103
|
+
const [path, depth] = stack.pop();
|
|
104
|
+
for (const entry of directoryEntries(path)) {
|
|
105
|
+
if (isDirectory(entry.path)) { if (depth < 4) stack.push([entry.path, depth + 1]); continue; }
|
|
106
|
+
const name = textName(entry.name);
|
|
107
|
+
if (name?.startsWith('rollout-') && name.endsWith('.jsonl') && modifiedSince(entry.path, since)) codexFile(entry.path, since, sessions);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function query(params, context) {
|
|
113
|
+
if (params == null || typeof params !== 'object' || Array.isArray(params)) invalid('spend params must be an object');
|
|
114
|
+
for (const key of Object.keys(params)) if (!['since', 'harnesses', 'sort', 'limit', 'homes'].includes(key)) invalid(`unknown field ${key}`);
|
|
115
|
+
if (typeof params.since !== 'string') invalid('since must be a string');
|
|
116
|
+
const harnesses = Object.hasOwn(params, 'harnesses') ? params.harnesses : [];
|
|
117
|
+
if (!Array.isArray(harnesses) || !harnesses.every(h => typeof h === 'string')) invalid('harnesses must be a string array');
|
|
118
|
+
const sort = params.sort ?? 'cost'; if (typeof sort !== 'string') invalid('sort must be a string');
|
|
119
|
+
if (!['cost', 'tokens'].includes(sort)) invalid(`sort \`${sort}\`: the spend reading sorts by \`cost\` or \`tokens\``);
|
|
120
|
+
const bad = harnesses.find(h => !['claude-code', 'codex'].includes(h));
|
|
121
|
+
if (bad != null) invalid(`harness \`${bad}\`: the spend reading reads \`claude-code\` and \`codex\``);
|
|
122
|
+
let limit = null;
|
|
123
|
+
if (params.limit != null) {
|
|
124
|
+
let n = nativeInteger(params, 'limit');
|
|
125
|
+
if (n == null && nativeNumberKind(params, 'limit') == null && Number.isSafeInteger(params.limit) && params.limit >= 0) n = BigInt(params.limit);
|
|
126
|
+
if (n == null) invalid('limit must be an unsigned 64-bit integer');
|
|
127
|
+
limit = Number(n > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : n);
|
|
128
|
+
}
|
|
129
|
+
const homes = resolveHarnessHomes(context.env);
|
|
130
|
+
if (Object.hasOwn(params, 'homes')) {
|
|
131
|
+
if (params.homes == null || typeof params.homes !== 'object' || Array.isArray(params.homes)) invalid('homes must be an object');
|
|
132
|
+
// HarnessHomes ignores unknown fields but rejects a supplied known field
|
|
133
|
+
// that is not a path string, including null.
|
|
134
|
+
for (const key of Object.keys(homes)) if (Object.hasOwn(params.homes, key)) {
|
|
135
|
+
if (typeof params.homes[key] !== 'string') invalid(`homes.${key} must be a string`);
|
|
136
|
+
homes[key] = params.homes[key];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(homes)) {
|
|
140
|
+
const home = homes[key];
|
|
141
|
+
if (home && !isAbsolute(home) && !(process.platform === 'win32' && /^[A-Za-z]:/.test(home))) homes[key] = `${context.cwd}${sep}${home}`;
|
|
142
|
+
}
|
|
143
|
+
return { since: params.since, harnesses, sort, limit, homes };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function loadMachineSpend(params, { env = process.env, cwd = process.cwd() } = {}) {
|
|
147
|
+
const selected = query(params, { env, cwd }), now = BigInt(Date.now()), since = parseUsageSince(selected.since, now), sessions = new Map();
|
|
148
|
+
if (!selected.harnesses.length || selected.harnesses.includes('claude-code')) claudeSpend(selected.homes.claude_code, since, sessions);
|
|
149
|
+
if (!selected.harnesses.length || selected.harnesses.includes('codex')) codexSpend(selected.homes.codex, since, sessions);
|
|
150
|
+
const names = await claudeSpendNames(selected.homes, env, cwd), machine = spendMachineName(env, cwd), rows = [];
|
|
151
|
+
const ordered = [...sessions.values()].sort((a, b) => sorted(a.harness, b.harness) || sorted(a.id, b.id));
|
|
152
|
+
for (const session of ordered) {
|
|
153
|
+
const total = sum(), models = [];
|
|
154
|
+
for (const model of [...session.models.keys()].sort(sorted)) { const value = session.models.get(model); add(total, value); models.push(fields(value, { model })); }
|
|
155
|
+
const row = fields(total, { harness: session.harness, session_id: session.id, address: spendAddress(machine, session.harness, session.id),
|
|
156
|
+
name: session.harness === 'codex' ? `codex-${[...session.id].slice(0, 8).join('')}` : names.get(session.id) ?? null,
|
|
157
|
+
cwd: session.cwd, subagents: session.children.size, last_at: session.last > 0n ? usageMoment(session.last) : null, models });
|
|
158
|
+
rows.push({ total, row });
|
|
159
|
+
}
|
|
160
|
+
const tokens = value => value.counts.reduce((total, n) => wrap(total + n), 0n);
|
|
161
|
+
rows.sort((a, b) => {
|
|
162
|
+
if (selected.sort !== 'tokens' && a.total.priced !== b.total.priced) return a.total.priced ? -1 : 1;
|
|
163
|
+
if (selected.sort === 'tokens' || !a.total.priced) { const x = tokens(a.total), y = tokens(b.total); return x === y ? 0 : x > y ? -1 : 1; }
|
|
164
|
+
return a.total.cost === b.total.cost ? 0 : a.total.cost > b.total.cost ? -1 : 1;
|
|
165
|
+
});
|
|
166
|
+
const total = sum(); for (const value of rows) add(total, value.total);
|
|
167
|
+
return { since: usageMoment(since), until: usageMoment(now), session_count: rows.length,
|
|
168
|
+
sessions: rows.slice(0, selected.limit ?? rows.length).map(value => value.row), total: fields(total) };
|
|
169
|
+
}
|
package/session-usage.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The public session reader's accounting, ported from token_spend.rs and the
|
|
2
2
|
// 2026-10-03 pricing.rs snapshot. These are parity rates, not a live price feed.
|
|
3
|
-
//
|
|
3
|
+
// Native runtime budgets retain their independent consumers.
|
|
4
4
|
import { nativeInteger, parseNativeJson, setNativeFloat, setNativeInteger } from './native-json.mjs';
|
|
5
5
|
import { field, stringField } from './session-codec-utils.mjs';
|
|
6
6
|
import { usageTimestamp } from './session-usage-time.mjs';
|
|
@@ -56,6 +56,9 @@ function claudeUse(record) {
|
|
|
56
56
|
split === undefined ? written : wrap(write5m + sub(written, wrap(write5m + write1h))), write1h],
|
|
57
57
|
stringField(usage, 'speed') === 'fast', stringField(usage, 'inference_geo') === 'us')];
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
// Internal accounting primitives shared by the bounded machine-tail reader.
|
|
61
|
+
export { claudeUse, price, recorded, sorted, sub, wrap };
|
|
59
62
|
function dayPrefix(at) {
|
|
60
63
|
if (at == null) return 'unknown';
|
|
61
64
|
let bytes = 0, result = '';
|