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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
- Fixed `/usage` and `/usage check` omitting provider limits for stored OAuth accounts. Cache-only snapshots now use the provider's resolved base URL when reading usage, and explicit checks render the successful probe report directly instead of depending on a cache-key-identical readback (#4634).
- `/usage` shows quota resets again. Canonicalizing multi-account management (`364f14022`) rewired the interactive `/usage` handler from the graphical panel to the account-inventory text view, and that view rendered only `label: N% used (M% left)` — no bars, no reset countdown — leaving the command unable to answer when a quota comes back and stranding `handleUsageCommand`/`renderUsageReports` as unreachable code. Plain `/usage` in the TUI renders the panel again, sourced from the same cache-only inventory snapshot the text view reads, so the cache-only contract is preserved and no fetch or probe is reintroduced; `/usage check` keeps the text path, where the per-credential health verdict is the point. Account rows on every surface (TUI, ACP, Telegram) now carry `resets in <countdown> (<absolute time>)`, and the panel itself gained multi-account reset lines, hour-precision countdowns past 48h (`6d 14h`, previously rounded to a bare `7d` at anywhere from 6.6 to 7.4 days), and set-aware account-label truncation so pooled credentials sharing a domain no longer collapse into identical columns.
- Fixed resume listing scaling its read-syscall count with total transcript bytes. The trailing `header_patch` scan walks back to BOF whenever `cwd`/`title` stay unresolved (#3633), which is the common case because only `/rename` and workspace moves ever emit a patch; because the scan borrowed the caller's 4 KiB prefix buffer, that walk cost one `read` per 4 KiB of every candidate transcript on each `--resume`, `--continue`, and picker open. The scan now owns a 64 KiB buffer, so the same bytes are covered in ~16x fewer syscalls. Measured on a real 31-session workspace holding 105 MB of transcripts (largest 41 MB): 25,715 reads / 61.9 s before, 1,652 reads / 0.5 s after, with all 24 recovered titles unchanged. Buried-title recovery, the bytes examined, the `header_patch` marker prefilter, and listing results are unchanged.
- `gjc team` worker auto-checkpoints no longer commit and merge root-level worker runtime state (`.gjc/state/**`, e.g. SDK broker endpoints like `.gjc/state/sdk/<session-id>.json` and settings migration markers) into the leader repo's default branch. The checkpoint classifier's protected prefixes now cover both GJC runtime roots — `.gjc/_session-*/` and `.gjc/state/` — while user-owned `.gjc/` content (config, agents, skills) stays eligible as reviewable worker work. The worker-runtime-state e2e guard now asserts absence at the actual leader merge-target path instead of an unrelated session-scoped path, and matcher boundary cases (`.gjc/state` bare entry vs `.gjc/state-*` siblings) are pinned (#4603).
- Fixed Telegram forum topics freezing after the identity header: an attached, trusted session whose topic-host lease expired (20 s `HEARTBEAT_TTL_MS`) could never renew it, because `renewActiveTopicLeases` only renewed sessions that already passed the trusted-lease gate, so every later `turn_stream`/`context_update`/tool frame was rejected pre-send with "trusted attachment lease is stale" and the topic never updated again (#4647). A live attachment that still owns its exact logical session and holds an authorized recovery lease may now re-arm its own expired host lease — from the ownership heartbeat and once more before the publication gate — mirroring `acquireLease` admission (expired-but-owned active lease, or a same-owner resume inside the disconnect-grace window, which also covers the incident's persisted `disconnect_grace` record). Dropped sessions, closed endpoints, foreign lease owners, archive-fenced/inactive topics, malformed bindings, and cross-session ownership checks all still fail closed. Daemon generation bumped 169→170.
Expand Down
126 changes: 114 additions & 12 deletions packages/coding-agent/src/modes/controllers/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,9 @@ export class CommandController {
if (!usageReports) {
const provider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
if (!provider.fetchUsageReports) {
this.ctx.showWarning("Usage reporting is not configured for this session.");
this.ctx.showWarning(
"Usage reporting is not configured for this session: no provider exposes usage limits here.",
);
return;
}
try {
Expand All @@ -641,7 +643,9 @@ export class CommandController {
}

if (!usageReports || usageReports.length === 0) {
this.ctx.showWarning("No usage data available.");
this.ctx.showWarning(
"No usage data available: the configured providers reported no limits. Subscription/OAuth credentials report limits; plain API keys usually do not.",
);
return;
}

Expand Down Expand Up @@ -1604,13 +1608,101 @@ function formatUnlimitedReportLabel(report: UsageReport, reportIndex: number): s
return `account ${reportIndex + 1}`;
}

/**
* Two-unit countdown for usage windows. `formatDuration` collapses everything
* past 48h to a single rounded unit, so a weekly window reads `7d` whether 6.6
* or 7.4 days remain — useless when the question is "how long until I get my
* quota back". Kept local to the usage panel so job/elapsed rendering keeps the
* coarse label.
*/
function formatResetCountdown(ms: number): string {
const totalMinutes = Math.max(0, Math.floor(ms / 60_000));
if (totalMinutes < 1) return "<1m";
if (totalMinutes < 60) return `${totalMinutes}m`;
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 48) {
const minutes = totalMinutes % 60;
return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`;
}
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}

/** Absolute local reset time, so a long countdown maps onto a real calendar day. */
function formatResetAt(resetsAt: number, nowMs: number): string {
const date = new Date(resetsAt);
const withinADay = resetsAt - nowMs < 24 * 3_600_000;
return date.toLocaleString(undefined, {
month: withinADay ? undefined : "short",
day: withinADay ? undefined : "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
if (limit.window?.resetsAt !== undefined) {
return formatDuration(limit.window.resetsAt - nowMs);
}
return undefined;
}

/** Squeeze a label from the middle, keeping both ends. */
function squeezeMiddle(label: string, maxWidth: number): string {
if (visibleWidth(label) <= maxWidth) return label;
if (maxWidth <= 3) return truncateJobLabel(label, maxWidth);
const chars = [...label];
const headBudget = Math.ceil((maxWidth - 1) / 2);
const tailBudget = maxWidth - 1 - headBudget;
let head = "";
for (const char of chars) {
if (visibleWidth(head + char) > headBudget) break;
head += char;
}
let tail = "";
for (let index = chars.length - 1; index >= 0; index--) {
const next = chars[index]! + tail;
if (visibleWidth(next) > tailBudget) break;
tail = next;
}
return `${head}…${tail}`;
}

function accountLocalPart(label: string): string {
const at = label.lastIndexOf("@");
return at > 0 ? label.slice(0, at) : label;
}

/**
* Fit account labels into `maxWidth` while keeping them mutually
* distinguishable. Credentials pooled on one provider share a domain and often
* a prefix, so truncating each label in isolation collapses every column into
* the same stub — which defeats the only reason the panel shows accounts at
* all. Try progressively cheaper representations and keep the first one that is
* still unique; if nothing is, tag the columns with an index so the rows can at
* least be told apart.
*/
function truncateAccountLabels(labels: string[], maxWidth: number): string[] {
const distinctInput = new Set(labels).size;
const strategies: ((label: string) => string)[] = [
label => label,
label => accountLocalPart(label),
label => truncateJobLabel(accountLocalPart(label), maxWidth),
label => squeezeMiddle(accountLocalPart(label), maxWidth),
];
for (const strategy of strategies) {
const candidate = labels.map(strategy);
const fits = candidate.every(entry => visibleWidth(entry) <= maxWidth);
if (fits && new Set(candidate).size === distinctInput) return candidate;
}
return labels.map((label, index) => {
const tag = `#${index + 1}`;
const budget = Math.max(1, maxWidth - visibleWidth(tag));
return `${truncateJobLabel(accountLocalPart(label), budget)}${tag}`;
});
}

function formatAccountHeaderRow(
limits: UsageLimit[],
reports: UsageReport[],
Expand Down Expand Up @@ -1638,9 +1730,13 @@ function formatAccountHeaderRow(
});
}

return parts.map(p => {
const prefix = truncateJobLabel(p.label, prefixBudget);
const prefixCell = prefix + " ".repeat(prefixBudget - visibleWidth(prefix));
const prefixes = truncateAccountLabels(
parts.map(p => p.label),
prefixBudget,
);
return parts.map((p, index) => {
const prefix = prefixes[index]!;
const prefixCell = prefix + " ".repeat(Math.max(0, prefixBudget - visibleWidth(prefix)));
if (!p.suffix) return prefixCell + " ".repeat(maxSuffixWidth + gap);
const suffixPad = " ".repeat(maxSuffixWidth - visibleWidth(p.suffix));
return `${prefixCell} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`;
Expand Down Expand Up @@ -1688,18 +1784,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
return `${limits.length} accts`;
}

/**
* Reset line for one window. Renders a range when the accounts in the window
* reset at materially different times, and pins the earliest reset to an
* absolute local time so "when do I get my quota back" is answerable without
* doing date arithmetic in your head.
*/
function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
const absolute = limits
.map(limit => limit.window?.resetsAt)
.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
if (absolute.length === 0) return null;
const offsets = absolute.map(value => value - nowMs);
const minReset = Math.min(...offsets);
const maxReset = Math.max(...offsets);
if (maxReset - minReset > 60_000) {
return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
const earliest = Math.min(...absolute);
const latest = Math.max(...absolute);
const at = formatResetAt(earliest, nowMs);
if (latest - earliest > 60_000) {
return `resets in ${formatResetCountdown(earliest - nowMs)}–${formatResetCountdown(latest - nowMs)} (first ${at})`;
}
return `resets in ${formatDuration(minReset)}`;
return `resets in ${formatResetCountdown(earliest - nowMs)} (${at})`;
}

function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
Expand Down Expand Up @@ -1868,7 +1970,7 @@ export function renderUsageReports(
padColumn(renderUsageBar(limit, uiTheme, sectionColumnWidth), sectionColumnWidth),
);
lines.push(` ${bars.join(" ")} ${amountText}`.trimEnd());
const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null;
const resetText = resolveResetRange(sortedLimits, nowMs);
if (resetText) {
lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
}
Expand Down
13 changes: 11 additions & 2 deletions packages/coding-agent/src/slash-commands/builtin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { buildFastStatusReport } from "./helpers/fast-status-report";
import { formatDuration } from "./helpers/format";
import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
import { handleSshAcp } from "./helpers/ssh";
import { buildUsageReportText } from "./helpers/usage-report";
import { buildUsageReportText, collectCachedUsageReports } from "./helpers/usage-report";
import type {
BuiltinSlashCommand,
ParsedSlashCommand,
Expand Down Expand Up @@ -1348,7 +1348,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
runtime.ctx.showError("Usage: /usage [check]");
} else {
const adapted = toSlashCommandRuntime(runtime);
await adapted.output(await buildUsageReportText(adapted, { check: args === "check" }));
// Plain `/usage` renders the graphical panel from the cache-only
// inventory snapshot — same data the text view reads, no fetch or
// probe. `/usage check` stays on the text path because its value is
// the per-credential health verdict, not the bars.
const cached = args === "" ? collectCachedUsageReports(adapted) : [];
if (cached.length > 0) {
await runtime.ctx.handleUsageCommand(cached);
} else {
await adapted.output(await buildUsageReportText(adapted, { check: args === "check" }));
}
}
runtime.ctx.editor.setText("");
},
Expand Down
58 changes: 56 additions & 2 deletions packages/coding-agent/src/slash-commands/helpers/usage-report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { UsageLimit } from "@gajae-code/ai/core";
import type { UsageLimit, UsageReport } from "@gajae-code/ai/core";
import { sanitizeText } from "@gajae-code/utils";
import {
type AccountInventoryRow,
Expand Down Expand Up @@ -34,6 +34,60 @@ function healthLabel(row: AccountInventoryRow): string {
return "unknown";
}

/**
* Reset detail for one limit. The account rows dropped every reset signal when
* the panel was replaced, which left `/usage` unable to answer the question it
* exists for: how long until the quota comes back. Two-unit precision, because
* a single rounded unit reads `7d` at both 6.6 and 7.4 days remaining.
*/
function formatLimitReset(limit: UsageLimit, nowMs: number): string {
const resetsAt = limit.window?.resetsAt;
if (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return "";
const totalMinutes = Math.floor((resetsAt - nowMs) / 60_000);
const totalHours = Math.floor(totalMinutes / 60);
let countdown: string;
if (totalMinutes < 1) countdown = "<1m";
else if (totalMinutes < 60) countdown = `${totalMinutes}m`;
else if (totalHours < 48) {
const minutes = totalMinutes % 60;
countdown = minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`;
} else {
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
countdown = hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}
const withinADay = resetsAt - nowMs < 24 * 3_600_000;
const at = new Date(resetsAt).toLocaleString(undefined, {
month: withinADay ? undefined : "short",
day: withinADay ? undefined : "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `, resets in ${countdown} (${at})`;
}

/** One limit line: how much is left, and when it comes back. */
export function formatLimitDetail(limit: UsageLimit, nowMs: number): string {
return `${formatUsageAmount(limit)}${formatLimitReset(limit, nowMs)}`;
}

/**
* Cache-only usage reports for the interactive panel. Mirrors the plain
* `/usage` contract exactly — reads the account inventory snapshot, never
* fetches or probes — so the graphical view can be restored without
* reintroducing the network call that motivated replacing it.
*/
export function collectCachedUsageReports(runtime: SlashCommandRuntime): UsageReport[] {
const session = runtime.session;
const modelRegistry = session.modelRegistry;
const snapshot = buildAccountInventorySnapshot({
authStorage: modelRegistry.authStorage,
modelRegistry,
sessionId: session.credentialSessionId ?? session.sessionId,
});
return snapshot.rows.flatMap(row => (row.usage ? [row.usage.report] : []));
}

function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked: boolean): string {
const lines = [`Accounts${checked ? " (checked)" : " (cache only)"}`];
if (rows.length === 0) {
Expand All @@ -52,7 +106,7 @@ function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked:
);
if (row.usage?.report.limits.length) {
for (const limit of row.usage.report.limits.slice(0, 8)) {
lines.push(` ${sanitizeText(limit.label)}: ${formatUsageAmount(limit)}`);
lines.push(` ${sanitizeText(limit.label)}: ${formatLimitDetail(limit, nowMs)}`);
}
}
}
Expand Down
79 changes: 79 additions & 0 deletions packages/coding-agent/test/usage-report-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, test } from "bun:test";
import type { UsageLimit, UsageReport } from "@gajae-code/ai";
import { renderUsageReports } from "@gajae-code/coding-agent/modes/controllers/command-controller";
import { getThemeByName, setThemeInstance, theme } from "@gajae-code/coding-agent/modes/theme/theme";
import { formatLimitDetail } from "@gajae-code/coding-agent/slash-commands/helpers/usage-report";

function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
Expand Down Expand Up @@ -115,3 +116,81 @@ describe("usage report column ordering", () => {
}
});
});

describe("usage report reset visibility", () => {
beforeAll(async () => {
const loaded = await getThemeByName("red-claw");
if (loaded) setThemeInstance(loaded);
});

test("multi-account windows still render a reset line", () => {
const reports = [report("alice@example.com", 0.2, 0.6), report("bob@example.com", 0.5, 0.1)];
const output = stripAnsi(renderUsageReports(reports, theme, NOW, 100));

expect(output.match(/resets in /g)).toHaveLength(2);
});

test("divergent resets render a range, identical resets render one value", () => {
const skewed = {
...report("carol@example.com", 0.3, 0.3),
limits: [
limit("5h", "Claude 5 Hour", "5 Hour", 4 * 3_600_000, 0.3),
limit("7d", "Claude 7 Day", "7 Day", 5 * 86_400_000, 0.3),
],
} as UsageReport;
const lines = stripAnsi(renderUsageReports([report("alice@example.com", 0.2, 0.6), skewed], theme, NOW, 100))
.split("\n")
.filter(line => line.includes("resets in "));

expect(lines).toHaveLength(2);
// 5h window: alice resets in 2h, carol in 4h → a range.
expect(lines[0]).toMatch(/resets in 2h–4h \(first .+\)/);
// 7d window: both reset at the same instant → a single value.
expect(lines[1]).toMatch(/^\s*resets in 5d \(.+\)$/);
});

test("windows past 48h keep hour precision instead of collapsing to one unit", () => {
const coarse = {
...report("dave@example.com", 0.1, 0.1),
limits: [limit("7d", "Claude 7 Day", "7 Day", 6 * 86_400_000 + 14 * 3_600_000, 0.1)],
} as UsageReport;
const output = stripAnsi(renderUsageReports([coarse], theme, NOW, 100));

expect(output).toContain("resets in 6d 14h");
});

test("account labels stay distinguishable when columns are tight", () => {
const crowded = ["one", "two", "three", "four", "five"].map(name =>
report(`${name}.longlocalpart@example.com`, 0.2, 0.2),
);
const lines = stripAnsi(renderUsageReports(crowded, theme, NOW, 80)).split("\n");
const header = lines[lines.findIndex(line => line.includes("Claude 5 Hour")) + 1] ?? "";
// Strip the shared `(reset)` suffix so uniqueness is judged on identity alone.
const labels = header
.trim()
.split(/\s*\([^)]*\)\s*/)
.map(cell => cell.trim())
.filter(Boolean);

expect(labels).toHaveLength(crowded.length);
expect(new Set(labels).size).toBe(crowded.length);
});
});

describe("usage text rows", () => {
test("limit lines carry the reset countdown and an absolute reset time", () => {
const weekly = limit("7d", "Claude 7 Day", "7 Day", 6 * 86_400_000 + 14 * 3_600_000, 0.24);
const detail = formatLimitDetail(weekly, NOW);

expect(detail).toContain("24.00% used");
expect(detail).toContain("resets in 6d 14h");
});

test("expired or missing reset windows add no reset text", () => {
const past = limit("5h", "Claude 5 Hour", "5 Hour", -3_600_000, 0.5);
const windowless = { ...past, window: undefined } as UsageLimit;

expect(formatLimitDetail(past, NOW)).not.toContain("resets in");
expect(formatLimitDetail(windowless, NOW)).not.toContain("resets in");
});
});
Loading