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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,15 +382,29 @@ moshcode cost --json # for a script
```

```
session engine model in out cache cost age
api claude claude-opus-5 1.2k 27k 10.5M $9.91~ 42m
audit codex gpt-5.6-sol 400 200 600 — 12m
session engine model in out cache cost age pr
api claude claude-opus-5 1.2k 27k 10.5M $9.91~ 42m view #128
audit codex gpt-5.6-sol 400 200 600 — 12m

total $9.91~ 1.6k in · 27k out · 10.5M cached
~ estimated from published rates; unmarked figures are the engine's own.
⚠ no rate for gpt-5.6-sol — tokens counted, cost omitted.
```

**`view` is a link — click it and the PR opens in your browser.** The cost table
is where you notice a session that cost $300, and the next thing you want is
the thing it produced, which lives on GitHub rather than on this machine. The
cell is an [OSC 8](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
hyperlink: the label stays four characters wide while the click target is the
full URL, so the column costs nothing to carry. Claude Code writes a `pr-link`
record when a session opens a pull request, and that is where this comes from —
other engines leave the column blank because they record nothing like it.

Not every terminal speaks OSC 8, and there is no way to ask one whether it does.
Piped output prints the raw URL instead, and `MOSHCODE_HYPERLINKS=0` forces that
same plain form in a terminal that would otherwise paint a "view" nobody can
click. `moshcode cost --json` always carries `pr` and `prs` in full.

| engine | where the number comes from |
|---|---|
| claude | per-message `usage` in `~/.claude/projects/**/*.jsonl` |
Expand Down
32 changes: 25 additions & 7 deletions src/cost-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "./cost.mjs";
import { pricingFile } from "./cost-pricing.mjs";
import { EXIT, humanAge, roster } from "./herd-cli.mjs";
import { acid, ash, bone, dim, err, info, table, warn } from "./ui.mjs";
import { acid, ash, bone, dim, err, info, link, table, warn } from "./ui.mjs";

const tilde = (p) => {
const home = process.env.HOME || "";
Expand Down Expand Up @@ -60,6 +60,22 @@ function costCell(cost, source) {
*/
const cacheTokens = (u) => u.cacheRead + u.cacheWrite5m + u.cacheWrite1h;

/**
* The PR this session opened, as one clickable word.
*
* A cost table is where you notice a session that cost $300, and the next thing
* you want is the thing it produced — which lives on github.com, not on this
* machine. OSC 8 lets the cell read "view #123" while the click target is the
* full URL, so the column stays four characters wide instead of sixty. The URL
* itself is the fallback when hyperlinks are off (piped output, or a terminal
* without OSC 8), because an unreachable "view" would be worse than a long cell.
*/
function prCell(pr) {
if (!pr?.url) return ash("—");
const label = pr.number == null ? "view" : `view #${pr.number}`;
return link(acid(label), pr.url, { fallback: pr.url });
}

/** The per-session table, shared by the one-shot report and `--watch`. */
export function renderCost(rows, { indent = " " } = {}) {
if (!rows.length) return "";
Expand All @@ -73,8 +89,9 @@ export function renderCost(rows, { indent = " " } = {}) {
dim(formatTokens(cacheTokens(r.usage))),
costCell(r.cost, r.costSource),
dim(humanAge(r.age)),
prCell(r.pr),
]),
{ columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age"], header: true, indent: indent.length },
{ columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age", "pr"], header: true, indent: indent.length },
);
}

Expand All @@ -91,8 +108,9 @@ function renderRuns(runs, { indent = " " } = {}) {
dim(formatTokens(r.usage.output)),
dim(formatTokens(cacheTokens(r.usage))),
costCell(r.cost, r.costSource),
prCell(r.pr),
]),
{ columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost"], header: true, indent: indent.length },
{ columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost", "pr"], header: true, indent: indent.length },
);
}

Expand Down Expand Up @@ -164,12 +182,12 @@ export async function costCommand(argv = [], { write = console.log } = {}) {
if (asJson) {
write(JSON.stringify({
since,
sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, runs }) => ({
name: n, engine, cwd, state, models, usage, cost, costSource, unpriced,
runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end })),
sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs, runs }) => ({
name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs,
runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, pr: r.pr ?? null })),
})),
unattributed: report.unattributed.map((r) => ({
id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end,
id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, pr: r.pr ?? null,
})),
totals: totals([...rows, ...report.unattributed]),
}, null, 2));
Expand Down
46 changes: 43 additions & 3 deletions src/cost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ function claudeUsageOf(message) {
};
}

/**
* A `pr-link` record → the shape the table renders, or null.
*
* The URL is the only required field, because it is the only one the terminal
* can act on; a record without one is a row we cannot make clickable and is
* better dropped than shown as a dead link. Only github.com and GitHub
* Enterprise-shaped https URLs are accepted — this string ends up in an OSC 8
* escape handed to the user's browser, and a transcript is a file on disk that
* other tools write to.
*/
function claudePrOf(entry) {
const url = String(entry?.prUrl || "").trim();
if (!/^https:\/\/[A-Za-z0-9.-]+\/[^\s]*$/.test(url)) return null;
const number = Number(entry?.prNumber);
return {
number: Number.isFinite(number) ? number : null,
url,
repository: String(entry?.prRepository || ""),
at: stamp(entry?.timestamp),
};
}

/** One Claude Code transcript → one run, or null when it holds no usage. */
function readClaudeTranscript(file, { since }) {
let text;
Expand All @@ -165,11 +187,23 @@ function readClaudeTranscript(file, { since }) {
let end = null;
let cwd = "";
let id = path.basename(file, ".jsonl");
let pr = null;

for (const line of text.split("\n")) {
if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{"
const entry = parseJson(line);
if (!entry || entry.type !== "assistant") continue;
if (!entry) continue;
// Claude Code writes a `pr-link` record when a session opens a pull
// request. It is the only place the transcript says what the work turned
// into, and it is deliberately read outside the `since` window: a PR opened
// yesterday still belongs to the session that shows up in today's table.
// Latest wins — a session that opened two PRs points at the newer one.
if (entry.type === "pr-link") {
const found = claudePrOf(entry);
if (found && (!pr || (found.at ?? 0) >= (pr.at ?? 0))) pr = found;
continue;
}
if (entry.type !== "assistant") continue;
const at = stamp(entry.timestamp);
if (at != null && since != null && at < since) continue;

Expand Down Expand Up @@ -199,7 +233,7 @@ function readClaudeTranscript(file, { since }) {
}

if (!seen.size) return null;
return { engine: "claude", id, cwd, usage, byModel, start, end, engineCost: hasEngineCost ? engineCost : null };
return { engine: "claude", id, cwd, usage, byModel, start, end, pr, engineCost: hasEngineCost ? engineCost : null };
}

function claudeRuns({ since, cwd } = {}) {
Expand Down Expand Up @@ -678,7 +712,7 @@ export async function engineRuns({
* line.
*/
export function attributeRuns(sessions = [], runs = []) {
const rows = sessions.map((s) => ({ ...s, runs: [], usage: { ...EMPTY_USAGE }, cost: null, costSource: null, unpriced: [] }));
const rows = sessions.map((s) => ({ ...s, runs: [], usage: { ...EMPTY_USAGE }, cost: null, costSource: null, unpriced: [], pr: null, prs: [] }));
const unattributed = [];

for (const run of runs) {
Expand Down Expand Up @@ -707,6 +741,12 @@ export function attributeRuns(sessions = [], runs = []) {
}
row.unpriced = [...new Set(row.unpriced)];
row.models = [...new Set(row.runs.flatMap((r) => r.models))];
// A session can resume across several transcripts and open a PR from any of
// them. All of them are kept for `--json`; the table has one cell, so it
// gets the newest — the PR this session is working on now.
row.prs = [...new Map(row.runs.map((r) => r.pr).filter(Boolean).map((p) => [p.url, p])).values()]
.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
row.pr = row.prs.length ? row.prs[row.prs.length - 1] : null;
}

return { rows, unattributed };
Expand Down
74 changes: 67 additions & 7 deletions src/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,14 @@ export function hr() {
// it wrong is how a table's right edge goes ragged the moment one cell is
// coloured, and every caller here paints cells.
const ANSI = /\x1b\[[0-9;]*m/g;
// OSC 8 hyperlinks wrap a label in two escape sequences whose payload is a URL
// — tens of characters that print nothing. They are not SGR, so ANSI above does
// not match them, and a table measured without this would size its columns to
// the length of a GitHub URL and blow the layout apart.
const OSC8 = /\x1b\]8;.*?(?:\x07|\x1b\\)/g;

/** `text` with every SGR sequence removed. */
export const strip = (s) => String(s ?? "").replace(ANSI, "");
/** `text` with every SGR sequence and OSC 8 hyperlink wrapper removed. */
export const strip = (s) => String(s ?? "").replace(ANSI, "").replace(OSC8, "");

/** Printable width of `text` in terminal columns, colour codes not counted. */
export const visible = (s) => strip(s).length;
Expand Down Expand Up @@ -109,13 +114,25 @@ export function clip(text, width, { collapse = true } = {}) {
let out = "";
let printed = 0;
let painted = false;
let linked = false;
for (let i = 0; i < s.length && printed < room; i++) {
if (s[i] === "\x1b") {
const match = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
if (match) {
out += match[0];
const colour = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
if (colour) {
out += colour[0];
painted = true;
i += match[0].length - 1;
i += colour[0].length - 1;
continue;
}
// An OSC 8 wrapper is copied whole for the same reason a colour code is:
// half of one is garbage on screen. `linked` tracks whether an opener has
// gone past without its closer, because a cut landing inside a hyperlink
// would otherwise leave the terminal linking everything printed after it.
const href = /^\x1b\]8;.*?(?:\x07|\x1b\\)/.exec(s.slice(i));
if (href) {
out += href[0];
linked = !LINK_CLOSE_RE.test(href[0]);
i += href[0].length - 1;
continue;
}
}
Expand All @@ -125,7 +142,50 @@ export function clip(text, width, { collapse = true } = {}) {
// A full reset rather than ui.mjs's narrower `\x1b[39m`: the cut may have
// landed inside dim, or inside a colour some caller opened around us, and
// leaking either is the bug this function exists to avoid.
return out + (painted ? "\x1b[0m" : "") + "…";
return out + (linked ? LINK_CLOSE : "") + (painted ? "\x1b[0m" : "") + "…";
}

/* ------------------------------------------------------------ hyperlinks */

// OSC 8: ESC ]8;<params>;<uri> ST <label> ESC ]8;; ST. The label is arbitrary
// text — "view" — while the target has to be a real URI, because it is the
// terminal's own link handler that opens it, not us. That split is the point: a
// table can carry a click target without spending a column on a GitHub URL.
const LINK_OPEN = "\x1b]8;;";
const ST = "\x1b\\";
const LINK_CLOSE = `${LINK_OPEN}${ST}`;
/** A wrapper whose URI is empty is the closer, not another opener. */
const LINK_CLOSE_RE = /^\x1b\]8;[^;]*;(?:\x07|\x1b\\)$/;

/**
* Whether hyperlinks are worth emitting.
*
* Piped output is read by something that wants text rather than escapes, so the
* URL itself is the more useful thing there. And OSC 8 is not universal — a
* terminal without it paints the label and drops the target on the floor,
* leaving a "view" nobody can reach — so `MOSHCODE_HYPERLINKS=0` forces the
* plain URL back. There is no way to feature-detect this, which is why it is a
* knob and not a guess.
*/
const useLinks = () => {
const flag = process.env.MOSHCODE_HYPERLINKS;
if (flag != null) return !/^(0|off|no|false)$/i.test(flag.trim());
return process.stdout.isTTY === true;
};

/**
* `label`, clickable, pointing at `url`.
*
* Falls back to `fallback` — the raw URL, for callers with nowhere else to put
* it — when hyperlinks are off, and to the bare label when they gave none. A
* missing url is not an error: it is a row that has no PR yet.
*/
export function link(label, url, { fallback = null } = {}) {
const text = String(label ?? "");
const href = String(url ?? "").trim();
if (!href) return text;
if (!useLinks()) return fallback == null ? text : String(fallback);
return `${LINK_OPEN}${href}${ST}${text}${LINK_CLOSE}`;
}

/* --------------------------------------------------------------- layout */
Expand Down
74 changes: 74 additions & 0 deletions test/cost.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ const claudeAssistant = ({ id, requestId, model = "claude-opus-5", at, cwd, sess
message: { id, model, usage },
});

/** The record Claude Code appends when a session opens a pull request. */
const claudePrLink = ({ number, at, repository = "profullstack/moshcode", sessionId = "s" }) => JSON.stringify({
type: "pr-link", sessionId, prNumber: number,
prUrl: `https://github.com/${repository}/pull/${number}`,
prRepository: repository, timestamp: at,
});

const USAGE = {
input_tokens: 1000,
output_tokens: 2000,
Expand Down Expand Up @@ -117,6 +124,73 @@ test("claude transcripts", async (t) => {
assert.equal(run.cost, priceUsage("claude-opus-5", run.usage));
}));

await t.test("a pr-link record becomes the run's clickable target", () => withHome(async (home) => {
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
write(path.join(dir, "s.jsonl"), [
claudeAssistant({ id: "m1", requestId: "r1", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE }),
claudePrLink({ number: 42, at: new Date().toISOString() }),
].join("\n"));

const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] });
assert.equal(run.pr.number, 42);
assert.equal(run.pr.url, "https://github.com/profullstack/moshcode/pull/42");
assert.equal(run.pr.repository, "profullstack/moshcode");
}));

await t.test("a PR opened before the window still belongs to the run", () => withHome(async (home) => {
// The window is about which requests to bill, not about which PR the
// session produced. A run in today's table that opened its PR yesterday is
// the ordinary case for anything that ran overnight.
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
write(path.join(dir, "s.jsonl"), [
claudePrLink({ number: 7, at: new Date(Date.now() - 86400e3 * 3).toISOString() }),
claudeAssistant({ id: "m1", requestId: "r1", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE }),
].join("\n"));

const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] });
assert.equal(run.pr.number, 7);
}));

await t.test("two PRs from one session: the newest is the one shown", () => withHome(async (home) => {
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
write(path.join(dir, "s.jsonl"), [
claudePrLink({ number: 1, at: new Date(Date.now() - 7200e3).toISOString() }),
claudeAssistant({ id: "m1", requestId: "r1", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE }),
claudePrLink({ number: 2, at: new Date(Date.now() - 60e3).toISOString() }),
].join("\n"));

const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] });
assert.equal(run.pr.number, 2);
}));

await t.test("a pr-link with no usable url is dropped, not shown as a dead link", () => withHome(async (home) => {
// This string is handed to the terminal's link handler and then to a
// browser. A transcript is a file on disk, so it is not trusted to hold a
// scheme we are willing to open.
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
for (const url of ["", "javascript:alert(1)", "file:///etc/passwd", "http://example.com/pull/1", "not a url"]) {
write(path.join(dir, "s.jsonl"), [
claudeAssistant({ id: "m1", requestId: "r1", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE }),
JSON.stringify({ type: "pr-link", sessionId: "s", prNumber: 3, prUrl: url, timestamp: new Date().toISOString() }),
].join("\n"));
const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] });
assert.equal(run.pr, null, `accepted ${JSON.stringify(url)}`);
}
}));

await t.test("a transcript with no PR reports null rather than guessing one", () => withHome(async (home) => {
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
write(path.join(dir, "s.jsonl"), claudeAssistant({ id: "m1", requestId: "r1", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE }));

const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] });
assert.equal(run.pr, null);
}));

await t.test("a replayed message is counted once", () => withHome(async (home) => {
const cwd = "/home/anthony/src/api";
const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]);
Expand Down
Loading
Loading