@pify/usage 0.9.2 → 0.11.0

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 CHANGED
@@ -18,6 +18,10 @@ Spend is invisible until the invoice arrives, and by then you cannot tell which
18
18
 
19
19
  Folded from each message's `usage.cost`, which pi already computes. It survives `/reload` by replaying the session branch rather than keeping a running total in memory.
20
20
 
21
+ When [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow) run child agents, those children are separate in-memory sessions whose spend never reaches this branch — a session that delegates used to look cheaper than it was. Those packages now add every child message's cost to a process-wide tally, and the footer shows it as a separate ` · agents $0.12` beside the session's own figure (the `/usage` session block has the same line, with tokens). It is kept apart on purpose: the session numbers stay what the branch says, and the agents figure is what the delegation cost on top. The tally survives `/reload` and starts over with a new session.
22
+
23
+ On a Claude subscription (OAuth Pro/Max) the footer also carries the wall you are approaching: the headers `/usage quota` reads were already captured on every response, but only that command showed them, so the weekly or 5-hour limit used to arrive as a stalled turn. Now a `⚠ 7d 8% · resets 14:05` segment appears once the binding window is below 15%, or whenever Anthropic marks the subscription as anything but allowed, and stays out of the way otherwise. Other providers carry no such headers, so they never show it.
24
+
21
25
  The gauge on the right is how full the context window is — the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `⚠` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
22
26
 
23
27
  ## `/usage`
@@ -31,10 +31,11 @@ import { readFileSync } from "node:fs";
31
31
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
32
32
  import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
33
33
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
34
+ import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
34
35
  import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
35
36
  import { redact } from "../src/redact.ts";
36
37
  import { scanSessions } from "../src/sessions.ts";
37
- import { parseRateLimit, formatRateLimit, type RateLimitSnapshot } from "../src/ratelimit.ts";
38
+ import { parseRateLimit, formatRateLimit, type RateLimitSnapshot, footerQuotaSegment } from "../src/ratelimit.ts";
38
39
  import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
39
40
 
40
41
  type UiContext = ExtensionContext;
@@ -66,15 +67,26 @@ export default function usage(pi: ExtensionAPI) {
66
67
  if (snap) rateLimits.set(provider, snap);
67
68
  });
68
69
 
70
+ /**
71
+ * The ctx the footer is drawn into, for the child-spend listener: a child
72
+ * finishing a turn fires no pi event on the parent, so the tally calls back
73
+ * and the footer redraws into the latest session context.
74
+ */
75
+ let footerCtx: UiContext | null = null;
76
+ let stopChildSpend: (() => void) | null = null;
77
+
69
78
  function updateFooter(ctx: UiContext): void {
70
79
  if (!ctx.hasUI) return;
71
- const base = footerText(session);
80
+ const base = footerText(session, childSpendTotal());
72
81
  if (!base) {
73
82
  ctx.ui.setStatus("usage", undefined);
74
83
  return;
75
84
  }
76
85
  const gauge = contextGauge(contextInfo(ctx).pct);
77
- ctx.ui.setStatus("usage", gauge ? `${base} · ${gauge}` : base);
86
+ // The subscription wall, from the headers already captured for this
87
+ // provider: quiet while healthy, a short warning once it is close.
88
+ const quota = footerQuotaSegment(rateLimits.get((ctx.model as { provider?: string } | null)?.provider ?? ""));
89
+ ctx.ui.setStatus("usage", [base, gauge, quota].filter(Boolean).join(" · "));
78
90
  }
79
91
 
80
92
  /**
@@ -105,7 +117,7 @@ export default function usage(pi: ExtensionAPI) {
105
117
  const scan = scanSessions(join(getAgentDir(), "sessions"));
106
118
  return [scan.records, scan.files] as const;
107
119
  })());
108
- return [sessionBlock(session, contextInfo(ctx).pct), historyBlock(history, Date.now())].join("\n\n");
120
+ return [sessionBlock(session, contextInfo(ctx).pct, childSpendTotal()), historyBlock(history, Date.now())].join("\n\n");
109
121
  }
110
122
 
111
123
  // ── Live tracking ────────────────────────────────────────────────────
@@ -124,10 +136,23 @@ export default function usage(pi: ExtensionAPI) {
124
136
  updateFooter(ctx);
125
137
  });
126
138
 
127
- pi.on("session_start", async (_event, ctx) => {
139
+ pi.on("session_start", async (event, ctx) => {
128
140
  // Rebuild the session totals from the branch so /reload keeps the count.
129
141
  session = emptyTotals();
130
142
  lastPromptTokens = 0;
143
+ // Child spend is not in the branch, so a /reload must keep the tally; a
144
+ // new, resumed or forked session starts its own.
145
+ if ((event as { reason?: string }).reason !== "reload") resetChildSpend();
146
+ footerCtx = ctx;
147
+ stopChildSpend?.();
148
+ stopChildSpend = onChildSpend(() => {
149
+ if (!footerCtx) return;
150
+ try {
151
+ updateFooter(footerCtx);
152
+ } catch {
153
+ // a stale ctx after a switch; the next session_start rebinds it
154
+ }
155
+ });
131
156
  for (const entry of ctx.sessionManager.getBranch()) {
132
157
  const record = recordFromEntry(entry);
133
158
  if (record) addRecord(session, record);
@@ -161,6 +186,9 @@ export default function usage(pi: ExtensionAPI) {
161
186
  });
162
187
 
163
188
  pi.on("session_shutdown", async (_event, ctx) => {
189
+ stopChildSpend?.();
190
+ stopChildSpend = null;
191
+ footerCtx = null;
164
192
  if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
165
193
  });
166
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What the children cost, across every @pify extension in the process.
3
+ *
4
+ * subagent, swarm and workflow run their children as separate in-memory pi
5
+ * sessions, so a child's spend never reaches the parent session's branch —
6
+ * and the usage footer, which folds the branch, under-reports every session
7
+ * that delegates. This is the one place they all add to and the footer reads.
8
+ *
9
+ * The state lives on `globalThis` under a cross-realm `Symbol.for` key, not
10
+ * in a module variable: pi's loader gives each extension its own module realm
11
+ * (jiti, `moduleCache: false`), so a module singleton would be a different
12
+ * object in every package that vendors this file. Same reasoning as
13
+ * ui-lock.ts. Vendored per package, byte-identical, zero dependencies; every
14
+ * function is safe to call whether or not any other package is installed.
15
+ */
16
+
17
+ const KEY = Symbol.for("pify.child-cost");
18
+
19
+ export interface ChildSpend {
20
+ /** USD, as pi priced each child message (usage.cost.total). */
21
+ cost: number;
22
+ /** usage.totalTokens summed over child messages. */
23
+ tokens: number;
24
+ }
25
+
26
+ interface Store {
27
+ bySource: Map<string, ChildSpend>;
28
+ listeners: Set<() => void>;
29
+ }
30
+
31
+ function store(): Store {
32
+ const g = globalThis as unknown as { [KEY]?: Store };
33
+ return (g[KEY] ??= { bySource: new Map(), listeners: new Set() });
34
+ }
35
+
36
+ /** Record one child message's spend under its package ("subagent", "swarm", "workflow"). Non-finite or negative parts are ignored. */
37
+ export function addChildSpend(source: string, spend: { cost?: number; tokens?: number }): void {
38
+ const cost = typeof spend.cost === "number" && Number.isFinite(spend.cost) && spend.cost > 0 ? spend.cost : 0;
39
+ const tokens =
40
+ typeof spend.tokens === "number" && Number.isFinite(spend.tokens) && spend.tokens > 0 ? Math.round(spend.tokens) : 0;
41
+ if (cost === 0 && tokens === 0) return;
42
+ const s = store();
43
+ const prev = s.bySource.get(source) ?? { cost: 0, tokens: 0 };
44
+ s.bySource.set(source, { cost: prev.cost + cost, tokens: prev.tokens + tokens });
45
+ for (const listener of s.listeners) {
46
+ try {
47
+ listener();
48
+ } catch {
49
+ // A footer that cannot redraw is not the child's problem.
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Everything the children have spent this session, and which packages spent it. */
55
+ export function childSpendTotal(): ChildSpend & { sources: string[] } {
56
+ const s = store();
57
+ let cost = 0;
58
+ let tokens = 0;
59
+ for (const spend of s.bySource.values()) {
60
+ cost += spend.cost;
61
+ tokens += spend.tokens;
62
+ }
63
+ return { cost, tokens, sources: [...s.bySource.keys()].sort() };
64
+ }
65
+
66
+ /** Start a new tally — a new session's children, not the last one's. */
67
+ export function resetChildSpend(): void {
68
+ store().bySource.clear();
69
+ }
70
+
71
+ /** Be told after every addition; returns the unsubscribe. */
72
+ export function onChildSpend(listener: () => void): () => void {
73
+ const s = store();
74
+ s.listeners.add(listener);
75
+ return () => {
76
+ s.listeners.delete(listener);
77
+ };
78
+ }
package/src/format.ts CHANGED
@@ -14,9 +14,20 @@ export function formatCost(n: number): string {
14
14
  }
15
15
 
16
16
  /** Footer text: short, live. Null clears the indicator. */
17
- export function footerText(session: UsageTotals): string | undefined {
18
- if (session.messages === 0) return undefined;
19
- return `📊 ${formatTokens(session.totalTokens)} tok · ${formatCost(session.cost)}`;
17
+ /** Spend of child agent sessions (subagent/swarm/workflow), which is not in the session totals. */
18
+ export interface ChildSpendLike {
19
+ cost: number;
20
+ tokens: number;
21
+ }
22
+
23
+ function hasChildSpend(children: ChildSpendLike | undefined): children is ChildSpendLike {
24
+ return !!children && (children.cost > 0 || children.tokens > 0);
25
+ }
26
+
27
+ export function footerText(session: UsageTotals, children?: ChildSpendLike): string | undefined {
28
+ const agents = hasChildSpend(children) ? ` · agents ${formatCost(children.cost)}` : "";
29
+ if (session.messages === 0) return agents ? `📊 0 tok · $0${agents}` : undefined;
30
+ return `📊 ${formatTokens(session.totalTokens)} tok · ${formatCost(session.cost)}${agents}`;
20
31
  }
21
32
 
22
33
  const GAUGE_CELLS = 6;
@@ -38,12 +49,17 @@ export function contextGauge(pct: number | null): string {
38
49
  return `${clamped >= 90 ? "⚠ " : ""}ctx ${bar} ${Math.round(clamped)}%`;
39
50
  }
40
51
 
41
- export function sessionBlock(session: UsageTotals, contextPct: number | null): string {
52
+ export function sessionBlock(session: UsageTotals, contextPct: number | null, children?: ChildSpendLike): string {
42
53
  const lines = [
43
54
  "Session",
44
55
  ` tokens in ${formatTokens(session.input)} · out ${formatTokens(session.output)} · cache ${formatTokens(session.cacheRead)} read / ${formatTokens(session.cacheWrite)} write`,
45
56
  ` cost ${formatCost(session.cost)} (${session.messages} responses)`,
46
57
  ];
58
+ if (hasChildSpend(children)) {
59
+ // Child sessions of subagent/swarm/workflow run outside this branch; their
60
+ // spend is real and is NOT in the two lines above.
61
+ lines.push(` agents ${formatCost(children.cost)} · ${formatTokens(children.tokens)} tok in child sessions, on top of the above`);
62
+ }
47
63
  if (contextPct !== null) {
48
64
  lines.push(` context ~${Math.round(contextPct)}% of the window`);
49
65
  }
package/src/ratelimit.ts CHANGED
@@ -166,6 +166,46 @@ function formatReset(snap: RateLimitSnapshot): string {
166
166
  return raw;
167
167
  }
168
168
 
169
+ /** A reset time short enough for the footer: HH:MM today, otherwise MM-DD HH:MM; "" when unknown or unwieldy. */
170
+ export function shortReset(snap: RateLimitSnapshot, now: number = Date.now()): string {
171
+ const raw = snap.resets;
172
+ if (!raw) return "";
173
+ if (snap.source === "anthropic" && /^\d{9,}$/.test(raw)) {
174
+ const d = new Date(Number.parseInt(raw, 10) * 1000);
175
+ if (!Number.isFinite(d.getTime())) return "";
176
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
177
+ const sameDay = new Date(now).toDateString() === d.toDateString();
178
+ return sameDay ? hm : `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${hm}`;
179
+ }
180
+ return raw.length <= 12 ? raw : "";
181
+ }
182
+
183
+ /** Below this share of the binding window left, the footer starts warning. */
184
+ export const FOOTER_WARN_FRACTION = 0.15;
185
+
186
+ /**
187
+ * The footer's quota warning, or "" while the session is healthy. A Claude
188
+ * subscription user used to learn about the 5h/7d wall only when a turn
189
+ * stalled, or by opening `/usage quota` — the headers were captured either
190
+ * way. This shows the binding window once it is nearly gone, or whenever
191
+ * Anthropic marks the subscription as anything but "allowed". Non-Anthropic
192
+ * providers carry none of these fields, so it stays empty for them.
193
+ */
194
+ export function footerQuotaSegment(snap: RateLimitSnapshot | null | undefined, now: number = Date.now()): string {
195
+ if (!snap) return "";
196
+ const binding =
197
+ snap.unifiedBinding ?? (snap.unified7dRemaining !== null ? "7d" : snap.unified5hRemaining !== null ? "5h" : null);
198
+ const remaining = binding === "7d" ? snap.unified7dRemaining : binding === "5h" ? snap.unified5hRemaining : null;
199
+ const walled = Boolean(snap.unifiedStatus && snap.unifiedStatus !== "allowed");
200
+ const low = remaining !== null && remaining < FOOTER_WARN_FRACTION;
201
+ if (!walled && !low) return "";
202
+ const parts = [`⚠ ${binding ?? "subscription"}`];
203
+ if (remaining !== null) parts.push(`${Math.max(0, Math.round(remaining * 100))}%`);
204
+ if (walled) parts.push(snap.unifiedStatus!.replace(/_/g, " "));
205
+ const reset = shortReset(snap, now);
206
+ return reset ? `${parts.join(" ")} · resets ${reset}` : parts.join(" ");
207
+ }
208
+
169
209
  /** One human line for the /usage quota report; null when nothing was captured. */
170
210
  export function formatRateLimit(snap: RateLimitSnapshot | null): string | null {
171
211
  if (!snap) return null;