devin-usage 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 betim-hodza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # devin-usage
2
+
3
+ Account plan, quota and credit usage for the [Devin](https://devin.ai) (Cognition) provider
4
+ in [pi](https://github.com/mariozechner/pi) — quota bars in a transcript card
5
+ plus a compact footer status.
6
+
7
+ ```
8
+ /devin-usage # full report card (plan, daily/weekly quota, credit buckets)
9
+ /devin-models # per-model credit multipliers (burn rate vs 1×)
10
+ ```
11
+
12
+ A summary also lands in the footer status on session start
13
+ (`devin: Pro · day 82% · wk 91%`).
14
+
15
+ ## What you get
16
+
17
+ - **Plan**: Devin Pro / Teams / Free, org, billing window
18
+ - **Daily & weekly quota**: % remaining with reset countdowns
19
+ - **Credit buckets**: prompt / flow / flex credits used and left
20
+ - **Model burn rates**: every registered Devin model's `credit_multiplier`
21
+ (×2 cheap, ×230 premium) sorted cheapest-first, with cost tier and pricing
22
+ type, flagging your active model
23
+ - **Overage**: any overage balance surfaced when present
24
+
25
+ ## How it works
26
+
27
+ Devin ships no REST usage endpoint; everything comes from one unary Connect
28
+ RPC the native CLI issues at startup, ported here from oh-my-pi:
29
+
30
+ ```
31
+ POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
32
+ Content-Type: application/proto
33
+ Connect-Protocol-Version: 1
34
+ Body: raw (unframed) GetUserStatusRequest protobuf
35
+ ```
36
+
37
+ The backend gates the response on the `Metadata` identity tuple — the request
38
+ must announce itself as the released Devin CLI ("chisel"), not the Windsurf
39
+ identity `pi-devin-auth` uses for chat:
40
+
41
+ ```
42
+ ide_name "devin-cli" · ide_type "chisel" · ide_version "3000.6.2"
43
+ extension_name "chisel" · extension_version "3000.6.2"
44
+ ```
45
+
46
+ **Caveat:** this is an undocumented internal endpoint guarded by a client
47
+ identity check. It works today; Cognition could change or gate it at any
48
+ time, at which point the command will fail loudly until updated.
49
+
50
+ Requires the Devin provider to be signed in (`/login devin`) — the API key
51
+ comes from pi's own model registry, nothing is stored by this extension.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pi install git:github.com/betim-hodza/devin-usage
57
+ ```
58
+
59
+ or try it once without installing:
60
+
61
+ ```bash
62
+ pi -e git:github.com/betim-hodza/devin-usage
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,560 @@
1
+ /**
2
+ * devin-usage — account plan/quota/credit usage for the Devin provider.
3
+ *
4
+ * Ported from oh-my-pi's `packages/ai/src/usage/devin.ts`. Devin ships no REST
5
+ * usage endpoint; everything comes from one unary Connect RPC that the native
6
+ * CLI issues at startup:
7
+ *
8
+ * POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
9
+ * Content-Type: application/proto
10
+ * Connect-Protocol-Version: 1
11
+ * Body: raw (unframed) GetUserStatusRequest protobuf
12
+ *
13
+ * The backend gates the response on the Metadata identity tuple — it must
14
+ * announce itself as the released Devin CLI ("chisel" client), not the
15
+ * Windsurf identity pi-devin-auth uses for chat:
16
+ *
17
+ * ide_name "devin-cli", ide_type "chisel", ide_version "3000.6.2",
18
+ * extension_name "chisel", extension_version "3000.6.2"
19
+ *
20
+ * Adds:
21
+ * /devin-usage — full report as a transcript card + footer summary
22
+ * session_start — refreshes the footer status (best effort)
23
+ */
24
+
25
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
26
+ import { Box, Text } from "@mariozechner/pi-tui";
27
+ import { gunzipSync } from "node:zlib";
28
+
29
+ // ── minimal proto wire helpers (same hand-rolled approach as pi-devin-auth) ──
30
+
31
+ function encodeVarint(value: number | bigint): Buffer {
32
+ let v = BigInt(value);
33
+ if (v < 0n) v = BigInt.asUintN(64, v); // int64 two's-complement
34
+ const bytes: number[] = [];
35
+ while (v > 127n) {
36
+ bytes.push(Number(v & 0x7fn) | 0x80);
37
+ v >>= 7n;
38
+ }
39
+ bytes.push(Number(v));
40
+ return Buffer.from(bytes);
41
+ }
42
+
43
+ function encodeTag(fieldNum: number, wire: number): Buffer {
44
+ return encodeVarint((fieldNum << 3) | wire);
45
+ }
46
+
47
+ function encodeString(fieldNum: number, s: string): Buffer {
48
+ const buf = Buffer.from(s, "utf8");
49
+ return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]);
50
+ }
51
+
52
+ function encodeMessage(fieldNum: number, body: Buffer): Buffer {
53
+ return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]);
54
+ }
55
+
56
+ interface ProtoField {
57
+ num: number;
58
+ wire: number;
59
+ value: bigint | Buffer;
60
+ }
61
+
62
+ function decodeVarint(buf: Buffer, offset: number): [bigint, number] {
63
+ let res = 0n;
64
+ let shift = 0n;
65
+ let i = offset;
66
+ while (i < buf.length) {
67
+ const b = buf[i++];
68
+ res |= BigInt(b & 0x7f) << shift;
69
+ if (!(b & 0x80)) return [res, i];
70
+ shift += 7n;
71
+ }
72
+ throw new Error("truncated varint");
73
+ }
74
+
75
+ function* iterFields(buf: Buffer): Generator<ProtoField> {
76
+ let i = 0;
77
+ while (i < buf.length) {
78
+ const [tagBig, ai] = decodeVarint(buf, i);
79
+ i = ai;
80
+ const num = Number(tagBig >> 3n);
81
+ const wire = Number(tagBig & 7n);
82
+ if (wire === 0) {
83
+ const [v, bi] = decodeVarint(buf, i);
84
+ i = bi;
85
+ yield { num, wire, value: v };
86
+ } else if (wire === 1) {
87
+ if (i + 8 > buf.length) return;
88
+ yield { num, wire, value: buf.subarray(i, i + 8) };
89
+ i += 8;
90
+ } else if (wire === 2) {
91
+ const [n, ci] = decodeVarint(buf, i);
92
+ i = ci;
93
+ const len = Number(n);
94
+ if (len < 0 || i + len > buf.length) return;
95
+ yield { num, wire, value: buf.subarray(i, i + len) };
96
+ i += len;
97
+ } else if (wire === 5) {
98
+ if (i + 4 > buf.length) return;
99
+ yield { num, wire, value: buf.subarray(i, i + 4) };
100
+ i += 4;
101
+ } else {
102
+ return;
103
+ }
104
+ }
105
+ }
106
+
107
+ function fieldMsg(buf: Buffer, num: number): Buffer | undefined {
108
+ for (const f of iterFields(buf)) if (f.num === num && f.wire === 2) return f.value as Buffer;
109
+ return undefined;
110
+ }
111
+ function fieldStr(buf: Buffer, num: number): string | undefined {
112
+ for (const f of iterFields(buf))
113
+ if (f.num === num && f.wire === 2) return (f.value as Buffer).toString("utf8");
114
+ return undefined;
115
+ }
116
+ /** Signed decode: proto int32/int64 negatives arrive sign-extended to 64 bits. */
117
+ function fieldInt(buf: Buffer, num: number): number | undefined {
118
+ for (const f of iterFields(buf))
119
+ if (f.num === num && f.wire === 0) return Number(BigInt.asIntN(64, f.value as bigint));
120
+ return undefined;
121
+ }
122
+ function fieldBool(buf: Buffer, num: number): boolean | undefined {
123
+ const v = fieldInt(buf, num);
124
+ return v === undefined ? undefined : v !== 0;
125
+ }
126
+ function fieldFloat(buf: Buffer, num: number): number | undefined {
127
+ for (const f of iterFields(buf)) if (f.num === num && f.wire === 5) return (f.value as Buffer).readFloatLE(0);
128
+ return undefined;
129
+ }
130
+ /** All length-delimited occurrences of `num` (repeated message fields). */
131
+ function fieldMsgs(buf: Buffer, num: number): Buffer[] {
132
+ const out: Buffer[] = [];
133
+ for (const f of iterFields(buf)) if (f.num === num && f.wire === 2) out.push(f.value as Buffer);
134
+ return out;
135
+ }
136
+
137
+ // ── Devin RPC ────────────────────────────────────────────────────────────────
138
+
139
+ const HOST = "https://server.codeium.com";
140
+ const PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus";
141
+ const TOKEN_PREFIX = "devin-session-token$";
142
+
143
+ function osString(): string {
144
+ switch (process.platform) {
145
+ case "darwin": return "darwin";
146
+ case "win32": return "windows";
147
+ default: return "linux";
148
+ }
149
+ }
150
+
151
+ /** Metadata message carrying the released-CLI ("chisel") identity. */
152
+ function buildCliMetadata(apiKey: string): Buffer {
153
+ const token = apiKey.startsWith(TOKEN_PREFIX) ? apiKey : `${TOKEN_PREFIX}${apiKey}`;
154
+ return Buffer.concat([
155
+ encodeString(1, "devin-cli"), // ide_name
156
+ encodeString(28, "chisel"), // ide_type — unlocks the CLI surface
157
+ encodeString(7, "3000.6.2"), // ide_version
158
+ encodeString(12, "chisel"), // extension_name
159
+ encodeString(2, "3000.6.2"), // extension_version
160
+ encodeString(3, token), // api_key (scheme prefix required)
161
+ encodeString(4, "en"), // locale
162
+ encodeString(5, osString()), // os
163
+ ]);
164
+ }
165
+
166
+ interface Timestamp { seconds: bigint; nanos: number }
167
+ function parseTimestamp(buf: Buffer): Timestamp | undefined {
168
+ let seconds = 0n, nanos = 0;
169
+ for (const f of iterFields(buf)) {
170
+ if (f.num === 1 && f.wire === 0) seconds = f.value as bigint;
171
+ if (f.num === 2 && f.wire === 0) nanos = Number(f.value);
172
+ }
173
+ return { seconds, nanos };
174
+ }
175
+ const tsMs = (t?: Timestamp) => (t ? Number(t.seconds) * 1000 + t.nanos / 1e6 : undefined);
176
+
177
+ const TEAMS_TIER: Record<number, string> = {
178
+ 1: "Teams", 2: "Pro", 3: "Enterprise Saas", 4: "Hybrid", 5: "Enterprise Self Hosted",
179
+ 6: "Waitlist Pro", 7: "Teams Ultimate", 9: "Trial", 10: "Enterprise Self Serve",
180
+ 12: "Devin Enterprise", 14: "Devin Teams", 15: "Devin Teams V2", 16: "Devin Pro",
181
+ 17: "Devin Max", 18: "Max", 19: "Devin Free", 20: "Devin Trial",
182
+ };
183
+
184
+ interface UsageReport {
185
+ email?: string;
186
+ userId?: string;
187
+ orgName?: string;
188
+ orgId?: string;
189
+ planName?: string;
190
+ billingStrategy?: number;
191
+ planStartMs?: number;
192
+ planEndMs?: number;
193
+ prompt?: { limit: number; used: number; available: number };
194
+ flow?: { limit: number; used: number; available: number };
195
+ flex?: { limit: number; used: number; available: number };
196
+ dailyQuotaPercent?: number; // remaining 0..100
197
+ dailyResetUnix?: number;
198
+ weeklyQuotaPercent?: number;
199
+ weeklyResetUnix?: number;
200
+ overageUsd?: number;
201
+ models?: ModelCost[];
202
+ }
203
+
204
+ interface ModelCost {
205
+ label: string;
206
+ uid: string;
207
+ creditMultiplier?: number;
208
+ pricingType?: number; // ModelPricingType enum
209
+ costTier?: number; // ModelCostTier enum
210
+ premium: boolean;
211
+ disabled: boolean;
212
+ /** Set when this is the session's active model. */
213
+ active?: boolean;
214
+ }
215
+
216
+ const PRICING_TYPE: Record<number, string> = {
217
+ 1: "static-credit", 2: "api", 3: "byok", 4: "acu-token", 5: "acu-credit",
218
+ };
219
+ const COST_TIER: Record<number, string> = { 1: "low", 2: "medium", 3: "high", 4: "free" };
220
+
221
+ /** user_status.cascade_model_config_data (field 33) → client_model_configs (field 1). */
222
+ function parseModelConfigs(userStatusBuf: Buffer): ModelCost[] {
223
+ const data = fieldMsg(userStatusBuf, 33);
224
+ if (!data) return [];
225
+ const out: ModelCost[] = [];
226
+ for (const cfg of fieldMsgs(data, 1)) {
227
+ const label = fieldStr(cfg, 1)?.trim();
228
+ const uid = fieldStr(cfg, 22)?.trim();
229
+ if (!label && !uid) continue;
230
+ out.push({
231
+ label: label || uid || "?",
232
+ uid: uid || "?",
233
+ creditMultiplier: fieldFloat(cfg, 3),
234
+ pricingType: fieldInt(cfg, 13),
235
+ costTier: fieldInt(cfg, 24),
236
+ premium: fieldBool(cfg, 7) === true,
237
+ disabled: fieldBool(cfg, 4) === true,
238
+ });
239
+ }
240
+ return out;
241
+ }
242
+
243
+ async function fetchDevinUsage(apiKey: string, signal?: AbortSignal): Promise<UsageReport> {
244
+ const body = encodeMessage(1, buildCliMetadata(apiKey)); // GetUserStatusRequest.metadata
245
+ const res = await fetch(`${HOST}${PATH}`, {
246
+ method: "POST",
247
+ headers: {
248
+ "content-type": "application/proto",
249
+ "connect-protocol-version": "1",
250
+ accept: "*/*",
251
+ },
252
+ body,
253
+ signal,
254
+ });
255
+ if (!res.ok) throw new Error(`GetUserStatus failed: HTTP ${res.status}`);
256
+
257
+ let payload = Buffer.from(await res.arrayBuffer());
258
+ let userStatusBuf: Buffer | undefined;
259
+ let planInfoBuf: Buffer | undefined;
260
+ try {
261
+ userStatusBuf = fieldMsg(payload, 1);
262
+ planInfoBuf = fieldMsg(payload, 2);
263
+ } catch {
264
+ // Edges sometimes return gzipped protobuf
265
+ payload = gunzipSync(payload);
266
+ userStatusBuf = fieldMsg(payload, 1);
267
+ planInfoBuf = fieldMsg(payload, 2);
268
+ }
269
+ if (!userStatusBuf) throw new Error("GetUserStatus: empty user_status in response");
270
+
271
+ const report: UsageReport = {};
272
+ report.email = fieldStr(userStatusBuf, 7)?.trim() || undefined;
273
+ report.userId = fieldStr(userStatusBuf, 36)?.trim() || undefined;
274
+ const userTier = fieldInt(userStatusBuf, 10);
275
+
276
+ report.models = parseModelConfigs(userStatusBuf);
277
+
278
+ const planStatusBuf = fieldMsg(userStatusBuf, 13);
279
+ // PlanInfo: response field 2 is authoritative; plan_status field 1 is a fallback copy.
280
+ const planBuf = planInfoBuf ?? (planStatusBuf ? fieldMsg(planStatusBuf, 1) : undefined);
281
+ if (planBuf) {
282
+ const tier = fieldInt(planBuf, 1);
283
+ report.planName =
284
+ fieldStr(planBuf, 2)?.trim() ||
285
+ (tier !== undefined ? TEAMS_TIER[tier] : undefined) ||
286
+ (userTier !== undefined ? TEAMS_TIER[userTier] : undefined);
287
+ report.billingStrategy = fieldInt(planBuf, 35);
288
+ const devinInfo = fieldMsg(planBuf, 33);
289
+ if (devinInfo) {
290
+ report.orgId = fieldStr(devinInfo, 4)?.trim() || undefined;
291
+ report.orgName = fieldStr(devinInfo, 8)?.trim() || undefined;
292
+ }
293
+ if (!report.orgId) report.orgId = fieldStr(userStatusBuf, 5)?.trim() || undefined;
294
+
295
+ if (planStatusBuf) {
296
+ report.planStartMs = tsMs(parseTimestamp(fieldMsg(planStatusBuf, 2)!));
297
+ report.planEndMs = tsMs(parseTimestamp(fieldMsg(planStatusBuf, 3)!));
298
+ // -1 means the plan grants no bucket / unlimited — normalize to 0.
299
+ const nn = (v: number | undefined) => (v !== undefined && v > 0 ? v : 0);
300
+ const mk = (limit: number | undefined, used?: number, avail?: number) =>
301
+ ({ limit: nn(limit), used: nn(used), available: nn(avail) });
302
+ report.prompt = mk(fieldInt(planBuf, 12), fieldInt(planStatusBuf, 6), fieldInt(planStatusBuf, 8));
303
+ report.flow = mk(fieldInt(planBuf, 13), fieldInt(planStatusBuf, 5), fieldInt(planStatusBuf, 9));
304
+ report.flex = mk(fieldInt(planBuf, 14), fieldInt(planStatusBuf, 7), fieldInt(planStatusBuf, 4));
305
+
306
+ const dailyP = fieldInt(planStatusBuf, 14);
307
+ const weeklyP = fieldInt(planStatusBuf, 15);
308
+ const dailyReset = fieldInt(planStatusBuf, 17) ?? 0;
309
+ const weeklyReset = fieldInt(planStatusBuf, 18) ?? 0;
310
+ const hideDaily = fieldBool(planBuf, 36) === true;
311
+ const hideWeekly = fieldBool(planBuf, 37) === true;
312
+ const isQuotaPlan = report.billingStrategy === 2; // BillingStrategy.QUOTA
313
+ // Credit plans leave percents at proto defaults — only surface when
314
+ // server-dated or explicitly quota-billed (omp's devinQuotaApplies).
315
+ if (!hideDaily && dailyP !== undefined && (dailyReset > 0 || isQuotaPlan)) {
316
+ report.dailyQuotaPercent = Math.max(0, Math.min(100, dailyP));
317
+ report.dailyResetUnix = dailyReset;
318
+ }
319
+ if (!hideWeekly && weeklyP !== undefined && (weeklyReset > 0 || isQuotaPlan)) {
320
+ report.weeklyQuotaPercent = Math.max(0, Math.min(100, weeklyP));
321
+ report.weeklyResetUnix = weeklyReset;
322
+ }
323
+ const overageMicros = fieldInt(planStatusBuf, 16) ?? 0;
324
+ if (overageMicros !== 0) report.overageUsd = overageMicros / 1_000_000;
325
+ }
326
+ } else if (userTier !== undefined) {
327
+ report.planName = TEAMS_TIER[userTier];
328
+ }
329
+ return report;
330
+ }
331
+
332
+ // ── formatting (styled after omp's /usage provider cards) ───────────────────
333
+
334
+ type UsageStatus = "ok" | "warning" | "exhausted" | "unknown";
335
+
336
+ function usageStatus(usedFraction: number | undefined): UsageStatus {
337
+ if (usedFraction === undefined) return "unknown";
338
+ if (usedFraction >= 1) return "exhausted";
339
+ if (usedFraction >= 0.9) return "warning";
340
+ return "ok";
341
+ }
342
+
343
+ /** Compact duration like omp's formatDuration: "30m", "2h30m", "3d2h". */
344
+ function formatDuration(ms: number): string {
345
+ if (!Number.isFinite(ms) || ms <= 0) return "0m";
346
+ const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
347
+ if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
348
+ if (ms < DAY) {
349
+ const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
350
+ return m > 0 ? `${h}h${m}m` : `${h}h`;
351
+ }
352
+ const d = Math.floor(ms / DAY), h = Math.floor((ms % DAY) / HOUR);
353
+ return h > 0 ? `${d}d${h}h` : `${d}d`;
354
+ }
355
+
356
+ interface CardRow {
357
+ label: string;
358
+ /** 0..1 used; undefined → no fraction, show usedText instead. */
359
+ fraction?: number;
360
+ status: UsageStatus;
361
+ resetMs?: number;
362
+ usedText?: string;
363
+ }
364
+
365
+ /** Normalize a UsageReport into omp-style card rows (sorted most-pressing first). */
366
+ function cardRows(r: UsageReport, nowMs: number): CardRow[] {
367
+ const rows: CardRow[] = [];
368
+ if (r.dailyQuotaPercent !== undefined) {
369
+ const fraction = 1 - r.dailyQuotaPercent / 100;
370
+ const resetMs = r.dailyResetUnix && r.dailyResetUnix * 1000 > nowMs
371
+ ? r.dailyResetUnix * 1000 - nowMs : undefined;
372
+ rows.push({ label: "Daily", fraction, status: usageStatus(fraction), resetMs });
373
+ }
374
+ if (r.weeklyQuotaPercent !== undefined) {
375
+ const fraction = 1 - r.weeklyQuotaPercent / 100;
376
+ const resetMs = r.weeklyResetUnix && r.weeklyResetUnix * 1000 > nowMs
377
+ ? r.weeklyResetUnix * 1000 - nowMs : undefined;
378
+ rows.push({ label: "Weekly", fraction, status: usageStatus(fraction), resetMs });
379
+ }
380
+ const credits: [string, UsageReport["prompt"]][] = [
381
+ ["Prompt credits", r.prompt],
382
+ ["Flow credits", r.flow],
383
+ ["Flex credits", r.flex],
384
+ ];
385
+ for (const [label, b] of credits) {
386
+ if (!b || (b.limit === 0 && b.used === 0 && b.available === 0)) continue;
387
+ if (b.limit > 0) {
388
+ const fraction = b.used / b.limit;
389
+ const resetMs = r.planEndMs && r.planEndMs > nowMs ? r.planEndMs - nowMs : undefined;
390
+ rows.push({
391
+ label, fraction, status: usageStatus(fraction), resetMs,
392
+ usedText: `${b.available.toLocaleString()} left`,
393
+ });
394
+ } else {
395
+ rows.push({ label, status: "unknown", usedText: `${b.available.toLocaleString()} left` });
396
+ }
397
+ }
398
+ rows.sort((a, b) => (b.fraction ?? -1) - (a.fraction ?? -1));
399
+ return rows;
400
+ }
401
+
402
+ const LABEL_W = 14;
403
+ const BAR_W = 20;
404
+
405
+ function miniBar(fraction: number, status: UsageStatus, theme: { fg(c: string, t: string): string }): string {
406
+ const clamped = Math.min(Math.max(fraction, 0), 1);
407
+ const filled = Math.round(clamped * BAR_W);
408
+ const color = status === "exhausted" ? "error" : status === "warning" ? "warning" : "success";
409
+ return theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(BAR_W - filled));
410
+ }
411
+
412
+ function rowLine(row: CardRow, theme: { fg(c: string, t: string): string }): string {
413
+ const label = theme.fg("muted", row.label.padEnd(LABEL_W));
414
+ if (row.fraction === undefined) {
415
+ return ` ${label} ${theme.fg("dim", row.usedText ?? "no data")}`;
416
+ }
417
+ const freePct = Math.max(0, Math.round((1 - row.fraction) * 100));
418
+ const color = row.status === "exhausted" ? "error" : row.status === "warning" ? "warning" : "success";
419
+ const pct = theme.fg(color, `${freePct}% free`.padStart(7));
420
+ const reset = row.resetMs !== undefined ? theme.fg("dim", ` ${formatDuration(row.resetMs)}`) : "";
421
+ return ` ${label} ${miniBar(row.fraction, row.status, theme)} ${pct}${reset}`;
422
+ }
423
+
424
+ function fmtDate(ms?: number): string {
425
+ return ms ? new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "?";
426
+ }
427
+
428
+ function statusSummary(r: UsageReport): string {
429
+ const parts: string[] = [];
430
+ if (r.planName) parts.push(r.planName);
431
+ if (r.dailyQuotaPercent !== undefined) parts.push(`day ${r.dailyQuotaPercent}%`);
432
+ if (r.weeklyQuotaPercent !== undefined) parts.push(`wk ${r.weeklyQuotaPercent}%`);
433
+ if (r.prompt && r.prompt.limit > 0) parts.push(`credits ${r.prompt.available}/${r.prompt.limit}`);
434
+ return parts.length ? `devin: ${parts.join(" · ")}` : "";
435
+ }
436
+
437
+ // ── extension ────────────────────────────────────────────────────────────────
438
+
439
+ export default async function (pi: ExtensionAPI): Promise<void> {
440
+ let lastReport: UsageReport | undefined;
441
+
442
+ pi.registerEntryRenderer("devin-usage", (entry, _opts, theme) => {
443
+ const r = entry.data as UsageReport;
444
+ const now = Date.now();
445
+ const rows = cardRows(r, now);
446
+ const worst = rows.reduce<UsageStatus>((w, row) => {
447
+ const rank = { unknown: 0, ok: 1, warning: 2, exhausted: 3 };
448
+ return rank[row.status] > rank[w] ? row.status : w;
449
+ }, "unknown");
450
+ const dotColor = worst === "exhausted" ? "error" : worst === "warning" ? "warning" : worst === "ok" ? "success" : "dim";
451
+
452
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
453
+ const title = theme.bold(`Devin${r.planName ? ` · ${r.planName}` : ""}`);
454
+ const who = [r.email, r.orgName].filter(Boolean).join(" · ");
455
+ box.addChild(new Text(`${theme.fg(dotColor, "●")} ${title}${who ? theme.fg("dim", ` ${who}`) : ""}`));
456
+ for (const row of rows) box.addChild(new Text(rowLine(row, theme)));
457
+ if (rows.length === 0) box.addChild(new Text(theme.fg("dim", " no limits reported")));
458
+
459
+ const meta: string[] = [];
460
+ if (r.planStartMs || r.planEndMs) meta.push(`plan ${fmtDate(r.planStartMs)} → ${fmtDate(r.planEndMs)}`);
461
+ if (r.overageUsd) meta.push(`overage $${r.overageUsd.toFixed(2)}`);
462
+ if (meta.length) box.addChild(new Text(theme.fg("dim", ` ${meta.join(" · ")}`)));
463
+ return box;
464
+ });
465
+
466
+ pi.registerEntryRenderer("devin-models", (entry, _opts, theme) => {
467
+ const models = (entry.data as UsageReport).models ?? [];
468
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
469
+ box.addChild(new Text(theme.bold("Devin model cost")));
470
+ if (models.length === 0) {
471
+ box.addChild(new Text(theme.fg("dim", " no model configs in response")));
472
+ return box;
473
+ }
474
+ box.addChild(new Text(theme.fg("dim", " burn rate vs 1× — lower is cheaper")));
475
+ const sorted = [...models].sort((a, b) => (a.creditMultiplier ?? 99) - (b.creditMultiplier ?? 99));
476
+ const nameW = Math.min(34, Math.max(...sorted.map(m => m.label.length)) + 1);
477
+ for (const m of sorted) {
478
+ const label = m.active ? `${m.label} ←` : m.label;
479
+ const name = theme.fg(m.active ? "accent" : "muted", label.padEnd(nameW));
480
+ const mult = m.creditMultiplier !== undefined
481
+ ? theme.fg(m.creditMultiplier <= 0.5 ? "success" : m.creditMultiplier <= 1.5 ? "warning" : "error",
482
+ `×${m.creditMultiplier.toFixed(2).replace(/\.?0+$/, "").padStart(5)}`)
483
+ : theme.fg("dim", " —");
484
+ const tier = m.costTier !== undefined && COST_TIER[m.costTier] ? COST_TIER[m.costTier] : "";
485
+ const pricing = m.pricingType !== undefined ? (PRICING_TYPE[m.pricingType] ?? "") : "";
486
+ const extra = [tier, pricing, m.premium ? "premium" : ""].filter(Boolean).join(" · ");
487
+ box.addChild(new Text(` ${name} ${mult}${extra ? theme.fg("dim", ` ${extra}`) : ""}`));
488
+ }
489
+ return box;
490
+ });
491
+
492
+ interface RefreshCtx {
493
+ modelRegistry: {
494
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
495
+ getAvailable(): { provider: string; id: string }[];
496
+ };
497
+ model?: { provider: string; id: string };
498
+ ui: {
499
+ setStatus(id: string, s?: string): void;
500
+ notify(message: string, kind?: "info" | "warning" | "error"): void;
501
+ };
502
+ }
503
+
504
+ async function refresh(ctx: RefreshCtx): Promise<UsageReport | undefined> {
505
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider("devin");
506
+ if (!apiKey) {
507
+ ctx.ui.notify("Devin: not signed in. Run /login devin", "warning");
508
+ return undefined;
509
+ }
510
+ const report = await fetchDevinUsage(apiKey);
511
+ // Trim the 200+ server configs to the models actually registered in pi,
512
+ // and flag the currently active one for the card renderer.
513
+ const devinIds = new Set(
514
+ ctx.modelRegistry.getAvailable().filter(m => m.provider === "devin").map(m => m.id),
515
+ );
516
+ if (devinIds.size > 0) {
517
+ report.models = report.models?.filter(m => devinIds.has(m.uid));
518
+ }
519
+ const active = ctx.model;
520
+ if (active?.provider === "devin") {
521
+ for (const m of report.models ?? []) if (m.uid === active.id) m.active = true;
522
+ }
523
+ lastReport = report;
524
+ const summary = statusSummary(report);
525
+ if (summary) ctx.ui.setStatus("devin-usage", summary);
526
+ return report;
527
+ }
528
+
529
+ pi.registerCommand("devin-usage", {
530
+ description: "Show Devin plan, quota and credit usage",
531
+ handler: async (_args, ctx) => {
532
+ try {
533
+ const report = await refresh(ctx);
534
+ if (report) pi.appendEntry("devin-usage", report);
535
+ } catch (e) {
536
+ ctx.ui.notify(`Devin usage: ${e instanceof Error ? e.message : String(e)}`, "error");
537
+ }
538
+ },
539
+ });
540
+
541
+ pi.registerCommand("devin-models", {
542
+ description: "Show Devin model credit multipliers (burn rate)",
543
+ handler: async (_args, ctx) => {
544
+ try {
545
+ const report = lastReport ?? (await refresh(ctx));
546
+ if (report) pi.appendEntry("devin-models", report);
547
+ } catch (e) {
548
+ ctx.ui.notify(`Devin models: ${e instanceof Error ? e.message : String(e)}`, "error");
549
+ }
550
+ },
551
+ });
552
+
553
+ pi.on("session_start", async (_e, ctx) => {
554
+ try {
555
+ await refresh(ctx);
556
+ } catch {
557
+ // best-effort footer only
558
+ }
559
+ });
560
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "devin-usage",
3
+ "version": "0.1.0",
4
+ "description": "Devin account plan, quota and credit usage in pi — from the Connect RPC the native Devin CLI issues at startup.",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "author": "betim-hodza",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Betim-Hodza/devin-usage.git"
13
+ },
14
+ "files": [
15
+ "extensions",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions"
22
+ ]
23
+ },
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": "*",
26
+ "@mariozechner/pi-tui": "*"
27
+ }
28
+ }