@astrosheep/pi-quota 0.8.1 → 0.10.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,7 +18,7 @@ Reload Pi after installing or changing settings. Use `/usage` for a persistent,
18
18
 
19
19
  A percentage always means **remaining**, not used. Unknown is `[?] ?`; an expired reset is `↺ due` with the stale percentage hidden until the next successful query. A wallet balance never becomes a percentage. Historical spend is not a quota.
20
20
 
21
- The footer shows at most two windows and `+N` for additional ones, for the current provider only. `/usage` queries all configured supported providers and shows successful results with all windows, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Query failures replace stale values in the footer and appear in `/usage --all`.
21
+ The footer shows at most two windows — set `quotaUsage.footerWindows` to a positive integer or `"all"` to show more — and `+N` for additional ones, for the current provider only. `/usage` queries all configured supported providers and shows successful results with all windows, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Query failures replace stale values in the footer and appear in `/usage --all`.
22
22
 
23
23
  ## Configure providers
24
24
 
@@ -32,7 +32,8 @@ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not proje
32
32
  "my-new-api": { "adapter": "new-api", "quotaPerUnit": 500000, "currency": "USD" },
33
33
  "deepseek": { "adapter": "deepseek" },
34
34
  "opencode-go-2": { "adapter": "opencode-go" },
35
- "yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." }
35
+ "yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." },
36
+ "my-script": { "adapter": "exec", "command": ["my-quota-cli", "--json"], "label": "My Script" }
36
37
  }
37
38
  }
38
39
  }
@@ -47,6 +48,7 @@ Use the **exact Pi provider ID**. Built-in providers and explicitly bound provid
47
48
  | `deepseek` | Official `/user/balance` | Balance only; no invented spend |
48
49
  | `opencode-go` | Official `https://opencode.ai/zen/go/v1/usage` | 5h/1w/30d windows, same as the built-in `opencode-go` adapter |
49
50
  | `yescode` | `co.yes.vg` login → session → `/api/v1/auth/profile` + `/api/v1/user/balance` | Wallet (subscription + pay-as-you-go) plus daily/weekly subscription windows |
51
+ | `exec` | Runs `command` as argv (no shell), parses stdout as a `QuotaSnapshot` JSON | Whatever the command reports; `fetchedAt` is stamped by the adapter, never by the command |
50
52
 
51
53
  Bind `opencode-go` to a second OpenCode Go account: register it as its own Pi provider in `models.json` with its own `apiKey` (e.g. `opencode-go-2`), then bind that ID here. The built-in `opencode-go` binding is implicit and needs no entry; the reserved IDs `openai-codex`, `kimi-coding` and `opencode-go` cannot be rebound.
52
54
 
@@ -56,6 +58,8 @@ For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionall
56
58
 
57
59
  For YesCode, `username` is the account email and `password` the account password; both stay in global `settings.json`, which is the trade-off of this adapter. YesCode rejects API keys on account endpoints ("requires a user session or JWT"), so the adapter logs in via `/api/v1/auth/login`, caches the issued cookie/JWT in memory for ~23 hours, and re-logs in once on an auth failure. The model `cr_` key is never sent to account endpoints; it only pins the provider to the official `co.yes.vg` origin. Balances are summed from subscription and pay-as-you-go; daily/weekly windows appear only when the account actually has those caps.
58
60
 
61
+ Exec runs the configured argv without a shell and parses stdout as a `QuotaSnapshot` JSON object: `windows`, optional `balance`, `allowance` and `spend` — the same schema every adapter produces, enforced by the controller. `fetchedAt` is stamped by the adapter; a value from the command is ignored. The controller bounds every query (10s default) and kills the child on timeout; non-zero exits, unreadable JSON and output over 256KB become errors, and command output is never logged. The child inherits Pi's environment, so keep secrets in env vars, not in `quotaUsage`.
62
+
59
63
  Authenticated quota URLs must use HTTPS, except HTTP loopback for local deployments. URL userinfo, query strings, fragments and HTTP redirects are rejected; credentials never follow redirects. Fixed official endpoints reject custom origins. No browser cookie scraping or credential discovery.
60
64
 
61
65
  ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/bar/quota.ts CHANGED
@@ -24,14 +24,18 @@ function windowSpans(window: QuotaWindow, now: number): Span[] {
24
24
  ];
25
25
  }
26
26
 
27
- export function quotaElement(state: QuotaState, now = Date.now()): BarElement {
27
+ // Footer window cap: a positive count, or 'all' for every shared window.
28
+ export type FooterWindows = number | 'all';
29
+
30
+ export function quotaElement(state: QuotaState, now = Date.now(), maxWindows: FooterWindows = 2): BarElement {
28
31
  const element: BarElement = { id: 'quota', spans: [] };
29
32
  if (state.kind === 'hidden') return element;
30
33
  if (state.kind === 'loading') return { ...element, spans: [{ text: 'Quota …', tone: 'dim' }] };
31
34
  if (state.kind === 'error') return { ...element, spans: [{ text: `Quota ! ${errorText[state.code]}`, tone: 'warning' }] };
32
35
  const windows = state.snapshot.windows;
33
36
  const shared = windows.filter(window => !window.scope);
34
- const shown = (shared.length ? shared : windows).slice(0, 2);
37
+ const eligible = shared.length ? shared : windows;
38
+ const shown = maxWindows === 'all' ? eligible : eligible.slice(0, maxWindows);
35
39
  const spans: Span[] = state.snapshot.balance ? balanceSpans(state.snapshot.balance)
36
40
  : state.snapshot.allowance ? allowanceSpans(state.snapshot.allowance) : [];
37
41
  for (const window of shown) {
@@ -42,8 +46,8 @@ export function quotaElement(state: QuotaState, now = Date.now()): BarElement {
42
46
  return { ...element, spans };
43
47
  }
44
48
 
45
- export function renderFooter(state: QuotaState, paint: Paint = plain, now = Date.now()): string {
46
- return renderBar([quotaElement(state, now)], paint);
49
+ export function renderFooter(state: QuotaState, paint: Paint = plain, now = Date.now(), maxWindows: FooterWindows = 2): string {
50
+ return renderBar([quotaElement(state, now, maxWindows)], paint);
47
51
  }
48
52
 
49
53
  export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.now(), barWidth = 16): string[] {
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createDeepSeekAdapter } from './query/deepseek.ts';
2
+ import { createExecAdapter, validateExecOptions } from './query/exec.ts';
2
3
  import { createNewApiAdapter, validateNewApiOptions } from './query/new-api.ts';
3
4
  import { createOpenCodeGoAdapter } from './query/opencode-go.ts';
4
5
  import { createSub2ApiAdapter } from './query/sub2api.ts';
@@ -6,12 +7,14 @@ import { createYesCodeAdapter, validateYesCodeOptions } from './query/yescode.ts
6
7
  import type { NewApiOptions } from './query/new-api.ts';
7
8
  import type { YesCodeOptions } from './query/yescode.ts';
8
9
  import type { QuotaAdapter } from './query/types.ts';
10
+ import type { FooterWindows } from './bar/quota.ts';
9
11
 
10
12
  export type ProviderQuotaConfig =
11
13
  | ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
12
14
  | ({ adapter: 'yescode' } & YesCodeOptions)
15
+ | { adapter: 'exec'; command: string[]; label: string }
13
16
  | { adapter: 'deepseek' | 'sub2api' | 'opencode-go' };
14
- export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig> }
17
+ export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig>; footerWindows?: FooterWindows }
15
18
 
16
19
  const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'] as const;
17
20
 
@@ -29,10 +32,16 @@ function parseDashboardOptions(item: Record<string, unknown>): Pick<NewApiOption
29
32
  return result;
30
33
  }
31
34
 
35
+ function parseFooterWindows(value: unknown): FooterWindows {
36
+ if (value === 'all') return value;
37
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1) return value;
38
+ throw new QuotaConfigError();
39
+ }
40
+
32
41
  export class QuotaConfigError extends Error {
33
42
  constructor() {
34
43
  // Settings may accidentally contain secrets. Never echo content/values.
35
- super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go" or "yescode".');
44
+ super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go", "yescode" or "exec", and optionally footerWindows as a positive integer or "all".');
36
45
  }
37
46
  }
38
47
 
@@ -41,7 +50,7 @@ function record(value: unknown): value is Record<string, unknown> {
41
50
  }
42
51
 
43
52
  export function parseQuotaConfig(value: unknown): QuotaConfig {
44
- if (!record(value) || Object.keys(value).some(key => key !== 'providers')
53
+ if (!record(value) || Object.keys(value).some(key => key !== 'providers' && key !== 'footerWindows')
45
54
  || !record(value.providers) || Object.keys(value.providers).length > 64) throw new QuotaConfigError();
46
55
  const providers: Record<string, ProviderQuotaConfig> = Object.create(null);
47
56
  for (const [provider, item] of Object.entries(value.providers)) {
@@ -67,6 +76,13 @@ export function parseQuotaConfig(value: unknown): QuotaConfig {
67
76
  } catch { throw new QuotaConfigError(); }
68
77
  continue;
69
78
  }
79
+ if (item.adapter === 'exec') {
80
+ if (Object.keys(item).some(key => !['adapter', 'command', 'label'].includes(key))) throw new QuotaConfigError();
81
+ try {
82
+ providers[provider] = { adapter: 'exec', ...validateExecOptions(item) };
83
+ } catch { throw new QuotaConfigError(); }
84
+ continue;
85
+ }
70
86
  if (item.adapter !== 'new-api'
71
87
  || Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
72
88
  || (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
@@ -76,7 +92,14 @@ export function parseQuotaConfig(value: unknown): QuotaConfig {
76
92
  providers[provider] = { adapter: 'new-api', ...settings, ...parseDashboardOptions(item) };
77
93
  } catch { throw new QuotaConfigError(); }
78
94
  }
79
- return { providers };
95
+ const config: QuotaConfig = { providers };
96
+ if (value.footerWindows !== undefined) config.footerWindows = parseFooterWindows(value.footerWindows);
97
+ return config;
98
+ }
99
+
100
+ export function loadQuotaFooterWindows(settings: unknown): FooterWindows | undefined {
101
+ if (!record(settings) || settings.quotaUsage === undefined) return undefined;
102
+ return parseQuotaConfig(settings.quotaUsage).footerWindows;
80
103
  }
81
104
 
82
105
  export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[] {
@@ -88,6 +111,7 @@ export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[]
88
111
  case 'sub2api': return createSub2ApiAdapter(provider);
89
112
  case 'opencode-go': return createOpenCodeGoAdapter(provider);
90
113
  case 'yescode': return createYesCodeAdapter(provider, options);
114
+ case 'exec': return createExecAdapter(provider, options);
91
115
  case 'new-api': return createNewApiAdapter(provider, options);
92
116
  }
93
117
  });
package/src/extension.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { renderFooter } from './bar/quota.ts';
3
+ import type { FooterWindows } from './bar/quota.ts';
3
4
  import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines, compactUsageLines } from './bar/card.ts';
4
5
  import type { UsageCard } from './bar/card.ts';
5
6
  import { codexAdapter } from './query/codex.ts';
@@ -23,6 +24,7 @@ export interface QuotaExtensionOptions {
23
24
  adapters?: readonly QuotaAdapter[];
24
25
  providers?: readonly string[]; // Extra configured quota providers to include in /usage
25
26
  footer?: boolean; // false: query + /usage + structured events only
27
+ footerWindows?: FooterWindows; // footer window cap; default 2, 'all' shows every window
26
28
  intervalMs?: number;
27
29
  timeoutMs?: number;
28
30
  }
@@ -57,7 +59,7 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
57
59
  const ctx = current;
58
60
  const paint = process.env.NO_COLOR !== undefined ? undefined
59
61
  : (tone: Parameters<typeof ctx.ui.theme.fg>[0], text: string) => ctx.ui.theme.fg(tone, text);
60
- const text = renderFooter(controller.state, paint) || undefined;
62
+ const text = renderFooter(controller.state, paint, Date.now(), options.footerWindows) || undefined;
61
63
  if (text !== lastStatus) {
62
64
  ctx.ui.setStatus(STATUS_KEY, text);
63
65
  lastStatus = text;
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { getAgentDir, SettingsManager } from '@earendil-works/pi-coding-agent';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
3
  import { createQuotaExtension } from './extension.ts';
4
- import { loadQuotaAdaptersFromSettings, QuotaConfigError } from './config.ts';
4
+ import { loadQuotaAdaptersFromSettings, loadQuotaFooterWindows, QuotaConfigError } from './config.ts';
5
+ import type { FooterWindows } from './bar/quota.ts';
5
6
  import type { QuotaAdapter } from './query/types.ts';
6
7
 
7
8
  export default function (pi: ExtensionAPI): void {
8
9
  let adapters: QuotaAdapter[] = [];
10
+ let footerWindows: FooterWindows | undefined;
9
11
  try {
10
12
  // Quota provider bindings belong to the user's global Pi settings. Project
11
13
  // settings are intentionally not read here: a project must not redirect a
@@ -14,11 +16,12 @@ export default function (pi: ExtensionAPI): void {
14
16
  projectTrusted: false,
15
17
  }).getGlobalSettings() as unknown;
16
18
  adapters = loadQuotaAdaptersFromSettings(settings);
19
+ footerWindows = loadQuotaFooterWindows(settings);
17
20
  } catch {
18
21
  pi.on('session_start', (_event, ctx) => {
19
22
  if (ctx.hasUI) ctx.ui.notify(new QuotaConfigError().message, 'warning');
20
23
  });
21
24
  }
22
25
  // Invalid optional settings never disable the built-in Codex/Kimi/OpenCode Go adapters.
23
- createQuotaExtension({ adapters })(pi);
26
+ createQuotaExtension({ adapters, footerWindows })(pi);
24
27
  }
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { QuotaError } from './types.ts';
3
+ import type { QuotaAdapter, QuotaSnapshot } from './types.ts';
4
+
5
+ export interface ExecOptions { command: string[]; label: string }
6
+
7
+ export function validateExecOptions(options: { command?: unknown; label?: unknown }): ExecOptions {
8
+ const { command, label } = options;
9
+ if (!Array.isArray(command) || command.length === 0 || command.length > 16
10
+ || command.some(part => typeof part !== 'string' || part.trim() === '' || part.length > 512)) {
11
+ throw new Error('Invalid exec options');
12
+ }
13
+ if (typeof label !== 'string' || label.trim() === '' || label.length > 64) throw new Error('Invalid exec options');
14
+ return { command: [...command] as string[], label };
15
+ }
16
+
17
+ // Same bound as the HTTP clients: quota payloads are small.
18
+ const MAX_OUTPUT_BYTES = 256 * 1024;
19
+
20
+ function runCommand(command: readonly string[], signal: AbortSignal): Promise<string> {
21
+ return new Promise((resolve, reject) => {
22
+ let settled = false;
23
+ const fail = (error: unknown) => { if (!settled) { settled = true; reject(error); } };
24
+ const done = (text: string) => { if (!settled) { settled = true; resolve(text); } };
25
+ let child;
26
+ try {
27
+ // No shell: argv passes verbatim; settings.json is the only trust boundary.
28
+ // The controller bounds the wait via the signal even if the child hangs.
29
+ child = spawn(command[0], command.slice(1), { signal, stdio: ['ignore', 'pipe', 'ignore'] });
30
+ } catch { fail(new QuotaError('network')); return; }
31
+ let output = '';
32
+ child.stdout?.setEncoding('utf8');
33
+ child.stdout?.on('data', (chunk: string) => {
34
+ output += chunk;
35
+ if (output.length > MAX_OUTPUT_BYTES) { child.kill(); fail(new QuotaError('schema')); }
36
+ });
37
+ child.on('error', () => fail(signal.aborted ? signal.reason : new QuotaError('network')));
38
+ child.on('close', code => {
39
+ if (signal.aborted) fail(signal.reason);
40
+ else if (code !== 0) fail(new QuotaError('network'));
41
+ else done(output);
42
+ });
43
+ });
44
+ }
45
+
46
+ export function createExecAdapter(provider: string, options: ExecOptions): QuotaAdapter {
47
+ return {
48
+ provider, label: options.label,
49
+ async query(context) {
50
+ const text = await runCommand(options.command, context.signal);
51
+ let payload: unknown;
52
+ try { payload = JSON.parse(text); } catch { throw new QuotaError('schema'); }
53
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new QuotaError('schema');
54
+ // fetchedAt is stamped here, never trusted from the command; the
55
+ // controller's validateSnapshot performs the real schema check.
56
+ return { ...(payload as Record<string, unknown>), fetchedAt: context.now() } as QuotaSnapshot;
57
+ },
58
+ };
59
+ }