pi-harness-runtime 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to `pi-harness-runtime` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-06-29
11
+
12
+ ### Changed
13
+
14
+ - **Project rename**: `pi-usage-status` → `pi-harness-runtime`
15
+ - npm package name: `pi-usage-status` → `pi-harness-runtime`
16
+ - Repository: `ManotLuijiu/pi-usage-status` → `ManotLuijiu/pi-harness-runtime`
17
+ - Bundled skill: `usage-status` → `harness-runtime` (file path `skills/usage-status/` → `skills/harness-runtime/`)
18
+ - Status bar key: `usage-status` → `harness-runtime`
19
+ - **Data directory unchanged**: still `~/.pi/usage-status/` (preserves existing user data)
20
+ - **Env var unchanged**: still `PI_USAGE_DIR` (preserves existing user configs)
21
+ - **Extension symlink unchanged**: still `~/.pi/agent/extensions/pi-usage-status` (repointed to renamed project directory)
22
+ - Added `"files"` whitelist to `package.json` — publish only the 7 runtime `.ts` files, `skills/`, `package.json`, `README.md`, `CHANGELOG.md`, `LICENSE` (excludes `test/`, `ADR/`, `docs/`, `examples/`, `packages/`, `PRD/`, `RFC/`)
23
+
24
+ ## [0.1.0] - 2026-06-26
25
+
26
+ ### Added
27
+
28
+ - Initial release
29
+ - `/usage` slash command — show Codex-style usage status (model, directory, local tracking, provider mirror)
30
+ - `/usage sync` slash command — open form to manually mirror provider-side quota from console.minimax.io
31
+ - `/usage today` / `/usage week` / `/usage reset` focused views
32
+ - Local SQLite tracking of every assistant message (input/output tokens + cost)
33
+ - Manual provider-mirror JSON at `~/.pi/usage-status/mirror.json` (synced_at, 5h_used_pct, 5h_resets_at, weekly_used_pct, weekly_resets_at)
34
+ - Rolling 5h window aggregation (auto-computed from local data)
35
+ - Rolling weekly window aggregation
36
+ - Reset-time computation (`oldest_request_in_window + window_duration`)
37
+ - Progress bar renderer matching Codex's `[████████░░░░░░░░░░░░]` style
38
+ - Local-vs-mirror divergence detection (warns if local tracking diverges from manual mirror by >5%)
39
+ - Burn-rate projection ("Weekly burn rate: 11.4% / day")
40
+ - Bundled skill `usage-status` (loaded automatically)
41
+ - MIT license
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MooCoding
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,207 @@
1
+ # Pi Harness Runtime
2
+
3
+ **Codex-style `/usage` status for pi coding agent: local token tracking + manual provider mirror.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/pi-harness-runtime?style=for-the-badge)](https://www.npmjs.com/package/pi-harness-runtime)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
7
+ [![Platform](https://img.shields.io/badge/Platform-macOS%20|20|Linux-blue?style=for-the-badge)]()
8
+
9
+ ## Why Pi Harness Runtime
10
+
11
+ **3-Source Honest Tracking** — Codex-style `/status` for pi: local-tracked tokens + manually-mirrored provider quota + derived projections. No fake precision, no scraping, no password storage.
12
+
13
+ **Zero Config** — Works immediately. Auto-tracks every assistant message. Run `/usage sync` once a day to mirror provider quota from `https://platform.minimax.io/console/usage`.
14
+
15
+ **Local-First Privacy** — All data stays in `~/.pi/usage-status/`. No telemetry, no remote calls, no credential storage. The provider mirror is a 5-second manual entry.
16
+
17
+ **Derived Insights** — Auto-computes reset times from your data (no provider API needed), burn rate projections, local-vs-mirror divergence warnings.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:pi-harness-runtime
23
+ ```
24
+
25
+ Or install locally for development:
26
+
27
+ ```bash
28
+ git clone https://github.com/ManotLuijiu/pi-harness-runtime.git
29
+ ln -s pi-harness-runtime ~/.pi/agent/extensions/pi-harness-runtime
30
+ pi reload
31
+ ```
32
+
33
+ **Important**: Symlink the **directory** into `~/.pi/agent/extensions/`. Pi's loader scans that directory and reads `package.json`'s `pi.extensions` field for multi-file extensions. Don't symlink a single file (relative imports break) or symlink into `node_modules/` (pi doesn't auto-scan there).
34
+
35
+ No API keys required. No build step (Bun runs `.ts` directly).
36
+
37
+ Requires Pi v0.37.3+.
38
+
39
+ ## Quick Start
40
+
41
+ ```bash
42
+ /usage # show full status (model, local tracking, provider mirror)
43
+ /usage sync # open form to mirror provider-side quota
44
+ /usage today # focused: today's usage + 5h window
45
+ /usage week # focused: this week's usage + lifetime totals
46
+ /usage reset # clear provider mirror (force re-sync)
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ ### `/usage`
52
+
53
+ Show Codex-style usage status. Renders three sections:
54
+
55
+ ```
56
+ Codex-style usage status for pi
57
+ ────────────────────────────────────────────────────────────────
58
+ Model: minimax/MiniMax-M3
59
+ Directory: ~/frappe-bench/apps/thai_business_suite
60
+
61
+ ① LOCAL TRACKED (ground truth — we count this)
62
+ This session: $0.17 · 142k tokens · 17 requests
63
+ This 5h: 384k tokens · 23 requests · $0.04
64
+ This week: 1.2M tokens · 67 requests · $0.13
65
+ Lifetime: 4592 requests · $81.61
66
+
67
+ ② PROVIDER MIRROR (you enter from console.minimax.io)
68
+ Last sync: 2 min ago [fresh]
69
+ Provider: minimax
70
+ 5h limit: [████████░░░░░░░░░░░░] 18% left (resets in 4h 54m)
71
+ Weekly limit: [████████████████░░░░] 81% left (resets in 2d 13h)
72
+
73
+ ③ LOCAL RESET TIMES (derived from your data)
74
+ Local 5h reset: in 3h 12m (oldest request falls out of window)
75
+ Local week reset: in 5d 7h (oldest request falls out of window)
76
+ Local-vs-mirror: -12.4% ⚠️ divergence > 5%
77
+ Burn rate: 11.4% / day → 100% in 2.5 d
78
+ ────────────────────────────────────────────────────────────────
79
+ ```
80
+
81
+ ### `/usage sync`
82
+
83
+ Open a 6-prompt form to mirror provider-side quota:
84
+
85
+ ```
86
+ 5h used % (0-100) for minimax: [18]
87
+ 5h resets in (hours): [4]
88
+ 5h resets in (minutes, 0-59): [56]
89
+ Weekly used % (0-100): [72]
90
+ Weekly resets in (days, 0-7): [2]
91
+ Weekly resets in (hours, 0-23): [13]
92
+ ```
93
+
94
+ Saves to `~/.pi/usage-status/mirror.json` and updates the next `/usage` output.
95
+
96
+ ### `/usage today`
97
+
98
+ Focused view: today's usage + 5h window. Quick check before starting a long session.
99
+
100
+ ### `/usage week`
101
+
102
+ Focused view: this week's usage + lifetime totals. Good for end-of-week review.
103
+
104
+ ### `/usage reset`
105
+
106
+ Clear the provider mirror. Local usage log is preserved. Asks for confirmation.
107
+
108
+ ## How It Works
109
+
110
+ ### Data Sources (3-source model)
111
+
112
+ 1. **Local tracked** — every assistant message is logged to `~/.pi/usage-status/usage.jsonl`
113
+ - Auto-tracked via `message_end` event
114
+ - Contains: timestamp, model, input/output/cache tokens, cost
115
+ - Real-time, exact, but only counts THIS pi session
116
+
117
+ 2. **Provider mirror** — manually entered from `https://platform.minimax.io/console/usage`
118
+ - Stored at `~/.pi/usage-status/mirror.json`
119
+ - Synced via `/usage sync` form
120
+ - Ground truth for TOTAL quota (across all clients)
121
+
122
+ 3. **Derived** — burn rate, reset times, divergence
123
+ - Local reset time = oldest request in window + window duration
124
+ - Burn rate = mirror weekly % / elapsed days
125
+ - Divergence warning if local tracking differs from mirror by >5%
126
+
127
+ ### Files Written
128
+
129
+ | Path | Contents |
130
+ |---|---|
131
+ | `~/.pi/usage-status/usage.jsonl` | Append-only usage log |
132
+ | `~/.pi/usage-status/mirror.json` | Manual provider mirror |
133
+ | (none — pure functions) | CLI helpers in `cli.ts` |
134
+
135
+ Override location with `PI_USAGE_DIR` env var (useful for testing).
136
+
137
+ ### What's NOT Stored
138
+
139
+ - Your password (we don't ask for it, ever)
140
+ - Provider session tokens (we don't read browser cookies)
141
+ - Telemetry or remote calls (all local)
142
+ - Auto-send quota (you always confirm any sync)
143
+
144
+ ## Why Manual Mirror?
145
+
146
+ Most AI providers (MiniMax, Anthropic, OpenAI) don't expose rate limit headers publicly. Two alternatives exist:
147
+
148
+ | Approach | Pros | Cons |
149
+ |---|---|---|
150
+ | **Manual mirror** (this) | Zero security risk, 5 sec/day | 1 manual entry |
151
+ | **Browser cookie extraction** (pi-web-access does for Gemini Web) | Fully automatic | ~400 LOC platform-specific code, fragile to auth changes |
152
+ | **HTML scraping** (assumes cookies work) | Once cookies extracted, parsing is easy | Breaks if page redesigns |
153
+
154
+ We chose the manual approach because:
155
+
156
+ 1. MiniMax's UI shows reset times like "Resets in 4 hr 56 min" — manual entry is genuinely 5 seconds
157
+ 2. Cookie extraction has ongoing maintenance burden
158
+ 3. Password storage is a security anti-pattern
159
+
160
+ If you find yourself needing to sync more than 3x/day, we may add browser cookie extraction as a future enhancement.
161
+
162
+ ## Safety Properties
163
+
164
+ - ✅ **No auto-tracking of other clients** — local data is just this pi session
165
+ - ✅ **No scraping** — provider mirror is manual
166
+ - ✅ **No fabrication** — divergence warning if local and mirror disagree
167
+ - ✅ **Idempotent** — running `/usage` repeatedly has no side effects
168
+ - ✅ **Privacy-respecting** — all data stays on local disk
169
+ - ✅ **No credentials stored** — passwords, tokens, cookies: none
170
+
171
+ ## Architecture
172
+
173
+ ```
174
+ pi-harness-runtime/
175
+ ├── index.ts # entry — exports default (pi: ExtensionAPI)
176
+ ├── tracker.ts # UsageTracker — JSONL writer/reader
177
+ ├── mirror.ts # MirrorStore — JSON read/write + freshness check
178
+ ├── windows.ts # WindowAggregator — pure aggregation functions
179
+ ├── renderer.ts # StatusRenderer — Codex-style progress bars
180
+ ├── sync-form.ts # /usage sync form handling
181
+ ├── cli.ts # shared pure helpers (paths, formatting, IO)
182
+ ├── skills/
183
+ │ └── harness-runtime/
184
+ │ └── SKILL.md # bundled skill (auto-loaded)
185
+ ├── test/ # node --test
186
+ │ ├── cli.test.mjs
187
+ │ ├── tracker.test.mjs
188
+ │ ├── mirror.test.mjs
189
+ │ ├── windows.test.mjs
190
+ │ ├── renderer.test.mjs
191
+ │ └── sync-form.test.mjs
192
+ ├── package.json # "pi" field declares extensions + skills
193
+ ├── README.md
194
+ ├── CHANGELOG.md
195
+ ├── LICENSE # MIT
196
+ └── .gitignore
197
+ ```
198
+
199
+ ## Related
200
+
201
+ - [pi-web-access](https://github.com/nicobailon/pi-web-access) — web search + URL fetching for pi (inspiration for this package's structure)
202
+ - [context-mode](https://github.com/MiniMax-AI/context-mode) — context-window tracking (complementary)
203
+ - [pi-coding-agent](https://github.com/earendil-works/pi-coding-agent) — the underlying pi agent
204
+
205
+ ## License
206
+
207
+ MIT © 2026 MooCoding
package/cli.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pure helper functions — testable without pi context.
3
+ * Imported by tracker.ts, mirror.ts, windows.ts, renderer.ts.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ /** Resolve the ~/.pi/usage-status/ directory. Override with PI_USAGE_DIR env var (testing). */
11
+ export function getUsageDir(): string {
12
+ const override = process.env.PI_USAGE_DIR;
13
+ if (override) return override;
14
+ return join(homedir(), ".pi", "usage-status");
15
+ }
16
+
17
+ /** Resolve the JSONL usage log path. */
18
+ export function getUsageLogPath(): string {
19
+ return join(getUsageDir(), "usage.jsonl");
20
+ }
21
+
22
+ /** Resolve the mirror JSON path. */
23
+ export function getMirrorPath(): string {
24
+ return join(getUsageDir(), "mirror.json");
25
+ }
26
+
27
+ /** Ensure the directory exists. Idempotent. */
28
+ export function ensureUsageDir(): void {
29
+ const dir = getUsageDir();
30
+ if (!existsSync(dir)) {
31
+ mkdirSync(dir, { recursive: true });
32
+ }
33
+ }
34
+
35
+ /** Read the JSONL usage log as an array of UsageRecord. Returns [] if missing. */
36
+ export function readJsonl<T>(path: string): T[] {
37
+ if (!existsSync(path)) return [];
38
+ const text = readFileSync(path, "utf-8");
39
+ const out: T[] = [];
40
+ for (const line of text.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) continue;
43
+ try {
44
+ out.push(JSON.parse(trimmed) as T);
45
+ } catch {
46
+ // skip corrupted line
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Append a single JSON line to a JSONL file. */
53
+ export function appendJsonl(path: string, record: unknown): void {
54
+ ensureUsageDir();
55
+ const line = JSON.stringify(record) + "\n";
56
+ appendFileSync(path, line, "utf-8");
57
+ }
58
+
59
+ /** Read JSON file safely. Returns null if missing or corrupted. */
60
+ export function readJson(path: string): unknown | null {
61
+ if (!existsSync(path)) return null;
62
+ try {
63
+ return JSON.parse(readFileSync(path, "utf-8"));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** Write JSON file (creates parent dir if needed). */
70
+ export function writeJson(path: string, data: unknown): void {
71
+ ensureUsageDir();
72
+ if (!existsSync(dirname(path))) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ }
75
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
76
+ }
77
+
78
+ /** Format milliseconds as "Xh Ym" or "Xd Yh" or "Xm" or "Xs". */
79
+ export function formatDuration(ms: number): string {
80
+ const abs = Math.max(0, Math.floor(ms / 1000));
81
+ const sec = abs % 60;
82
+ const min = Math.floor(abs / 60) % 60;
83
+ const hr = Math.floor(abs / 3600) % 24;
84
+ const day = Math.floor(abs / 86400);
85
+ if (day > 0) return `${day}d ${hr}h`;
86
+ if (hr > 0) return `${hr}h ${min}m`;
87
+ if (min > 0) return `${min}m`;
88
+ return `${sec}s`;
89
+ }
90
+
91
+ /** Format a timestamp as relative time "X min ago". */
92
+ export function formatRelative(fromIso: string, nowMs: number): string {
93
+ const fromMs = Date.parse(fromIso);
94
+ if (isNaN(fromMs)) return "unknown";
95
+ const deltaMs = nowMs - fromMs;
96
+ return formatDuration(deltaMs) + " ago";
97
+ }
98
+
99
+ /** Format a token count with k/M suffix. */
100
+ export function formatTokens(n: number): string {
101
+ if (n < 1000) return String(n);
102
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
103
+ return `${(n / 1_000_000).toFixed(2)}M`;
104
+ }
105
+
106
+ /** Format USD cost with 2-4 decimals depending on size. */
107
+ export function formatUsd(n: number): string {
108
+ if (n < 0.01) return `$${n.toFixed(4)}`;
109
+ if (n < 100) return `$${n.toFixed(2)}`;
110
+ return `$${n.toFixed(0)}`;
111
+ }
package/index.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * pi-harness-runtime — Codex-style /usage status for pi.
3
+ *
4
+ * Slash commands:
5
+ * /usage — show full status (model, local tracking, provider mirror)
6
+ * /usage sync — open form to manually mirror provider-side quota
7
+ * /usage today — focused: this 5h + today (UTC)
8
+ * /usage week — focused: this week + lifetime
9
+ * /usage reset — clear mirror (forces re-sync)
10
+ *
11
+ * Auto-tracks every assistant message via the message_end event.
12
+ * Stores data in ~/.pi/usage-status/ (override with PI_USAGE_DIR for testing).
13
+ *
14
+ * No build step — Bun runs this .ts file directly.
15
+ */
16
+
17
+ import type {
18
+ ExtensionAPI,
19
+ ExtensionCommandContext,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { UsageTracker } from "./tracker.ts";
22
+ import { MirrorStore, type MirrorRecord } from "./mirror.ts";
23
+ import { aggregateWindows } from "./windows.ts";
24
+ import { renderStatus } from "./renderer.ts";
25
+ import { buildMirrorRecord, parseSyncValues } from "./sync-form.ts";
26
+
27
+ const PROVIDER_DEFAULT = "minimax"; // can be changed via /usage sync form
28
+
29
+ export default function (pi: ExtensionAPI) {
30
+ const tracker = new UsageTracker();
31
+ const mirrorStore = new MirrorStore();
32
+
33
+ // ─── Auto-track every assistant message ──────────────────────────────
34
+ pi.on("message_end", async (event, ctx) => {
35
+ if (event.message.role !== "assistant") return;
36
+ const m = event.message as {
37
+ usage?: {
38
+ input?: number;
39
+ output?: number;
40
+ cacheRead?: number;
41
+ cacheWrite?: number;
42
+ cost?: { total?: number };
43
+ };
44
+ };
45
+ if (!m.usage) return;
46
+ tracker.append({
47
+ ts: Date.now(),
48
+ model: ctx.model?.id ?? "unknown",
49
+ input: m.usage.input ?? 0,
50
+ output: m.usage.output ?? 0,
51
+ cache_read: m.usage.cacheRead ?? 0,
52
+ cache_write: m.usage.cacheWrite ?? 0,
53
+ cost: m.usage.cost?.total ?? 0,
54
+ });
55
+ });
56
+
57
+ // ─── /usage — show full status ───────────────────────────────────────
58
+ pi.registerCommand("usage", {
59
+ description: "Show Codex-style usage status (local + provider mirror)",
60
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
61
+ const local = aggregateWindows(tracker.all());
62
+ const mirror = mirrorStore.read();
63
+ const output = renderStatus({
64
+ model: ctx.model?.id ?? null,
65
+ cwd: ctx.cwd ?? process.cwd(),
66
+ local,
67
+ mirror,
68
+ mirrorStore,
69
+ nowMs: Date.now(),
70
+ });
71
+ ctx.ui.notify(output, "info");
72
+ },
73
+ });
74
+
75
+ // ─── /usage sync — open form ─────────────────────────────────────────
76
+ pi.registerCommand("usage-sync", {
77
+ description:
78
+ "Sync provider quota: /usage sync [provider] [h5%,h,h,m,wk%,d,h]",
79
+ getArgumentCompletions: (prefix: string) => {
80
+ const opts = ["minimax", "anthropic", "openai", "openrouter"];
81
+ const filtered = opts.filter((o) => o.startsWith(prefix));
82
+ return filtered.length > 0
83
+ ? filtered.map((o) => ({ value: o, label: o }))
84
+ : null;
85
+ },
86
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
87
+ const parts = (args || "").trim().split(/\s+/).filter(Boolean);
88
+ let provider = PROVIDER_DEFAULT;
89
+ let inlineValues = "";
90
+ if (parts.length > 0 && !/^\d/.test(parts[0])) {
91
+ provider = parts[0];
92
+ inlineValues = parts.slice(1).join(" ");
93
+ } else if (parts.length > 0) {
94
+ inlineValues = parts.join(" ");
95
+ }
96
+ await openSyncForm(ctx, mirrorStore, provider, inlineValues);
97
+ },
98
+ });
99
+
100
+ // ─── /usage today — focused view ─────────────────────────────────────
101
+ pi.registerCommand("usage-today", {
102
+ description: "Show today's usage + 5h window",
103
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
104
+ const local = aggregateWindows(tracker.all());
105
+ const lines = [
106
+ " Today's usage",
107
+ "─────────────────────────────────────",
108
+ ` Model: ${ctx.model?.id ?? "unknown"}`,
109
+ ` Today: ${local.today.tokens} tokens · ${local.today.requests} requests · $${local.today.cost.toFixed(4)}`,
110
+ ` This 5h: ${local.five_h.tokens} tokens · ${local.five_h.requests} requests · $${local.five_h.cost.toFixed(4)}`,
111
+ "",
112
+ " Run /usage for full status or /usage sync to mirror provider quota.",
113
+ ];
114
+ ctx.ui.notify(lines.join("\n"), "info");
115
+ },
116
+ });
117
+
118
+ // ─── /usage week — focused view ──────────────────────────────────────
119
+ pi.registerCommand("usage-week", {
120
+ description: "Show this week's usage + lifetime totals",
121
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
122
+ const local = aggregateWindows(tracker.all());
123
+ const lines = [
124
+ " This week's usage",
125
+ "─────────────────────────────────────",
126
+ ` Model: ${ctx.model?.id ?? "unknown"}`,
127
+ ` This week: ${local.weekly.tokens} tokens · ${local.weekly.requests} requests · $${local.weekly.cost.toFixed(4)}`,
128
+ ` Lifetime: ${local.lifetime.tokens} tokens · ${local.lifetime.requests} requests · $${local.lifetime.cost.toFixed(4)}`,
129
+ "",
130
+ " Run /usage for full status with provider mirror.",
131
+ ];
132
+ ctx.ui.notify(lines.join("\n"), "info");
133
+ },
134
+ });
135
+
136
+ // ─── /usage reset — clear mirror ─────────────────────────────────────
137
+ pi.registerCommand("usage-reset", {
138
+ description: "Clear the provider mirror (force re-sync next time)",
139
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
140
+ const ok = await ctx.ui.confirm(
141
+ "Clear provider mirror?",
142
+ "This will delete ~/.pi/usage-status/mirror.json. Local usage log is preserved.",
143
+ );
144
+ if (!ok) {
145
+ ctx.ui.notify("Cancelled", "info");
146
+ return;
147
+ }
148
+ // Delete mirror file
149
+ try {
150
+ const { unlinkSync, existsSync } = await import("node:fs");
151
+ if (existsSync(mirrorStore["path"] ?? "")) {
152
+ // The path is private; use the JSON path getter via internal logic
153
+ // Cleaner: just unlink the known mirror path
154
+ }
155
+ // Simpler: import getMirrorPath and unlink
156
+ const { getMirrorPath } = await import("./cli.ts");
157
+ unlinkSync(getMirrorPath());
158
+ ctx.ui.notify(
159
+ "Mirror cleared. Run /usage sync to set a new one.",
160
+ "info",
161
+ );
162
+ } catch (e) {
163
+ ctx.ui.notify(`Failed to clear mirror: ${e}`, "error");
164
+ }
165
+ },
166
+ });
167
+
168
+ // ─── Footer status (persistent badge) ────────────────────────────────
169
+ pi.on("session_start", async (_event, ctx) => {
170
+ await refreshFooterStatus(ctx, mirrorStore, tracker);
171
+ });
172
+
173
+ pi.on("turn_end", async (_event, ctx) => {
174
+ await refreshFooterStatus(ctx, mirrorStore, tracker);
175
+ });
176
+ }
177
+
178
+ // ──────────────────────────────────────────────────────────────────────
179
+ // Helper: refresh persistent footer status with one-line summary
180
+ // ──────────────────────────────────────────────────────────────────────
181
+ async function refreshFooterStatus(
182
+ ctx: ExtensionCommandContext,
183
+ mirrorStore: MirrorStore,
184
+ tracker: UsageTracker,
185
+ ) {
186
+ const local = aggregateWindows(tracker.all());
187
+ const mirror = mirrorStore.read();
188
+ const now = Date.now();
189
+
190
+ const todayStr = `${(local.today.tokens / 1000).toFixed(1)}k tok · $${local.today.cost.toFixed(3)}`;
191
+ let summary = `today: ${todayStr}`;
192
+
193
+ if (mirror?.h5_used_pct !== undefined) {
194
+ const left = Math.max(0, 100 - mirror.h5_used_pct);
195
+ summary += ` · 5h: ${left}% left`;
196
+ }
197
+ if (mirror?.weekly_used_pct !== undefined) {
198
+ const left = Math.max(0, 100 - mirror.weekly_used_pct);
199
+ summary += ` · week: ${left}% left`;
200
+ }
201
+
202
+ ctx.ui.setStatus("harness-runtime", summary);
203
+ }
204
+
205
+ // ──────────────────────────────────────────────────────────────────────
206
+ // Helper: open sync form using ctx.ui
207
+ //
208
+ // Usage:
209
+ // /usage sync → prompts for values
210
+ // /usage sync minimax → prompts for values
211
+ // /usage sync minimax 6,4,8,73,2,13 → no prompts, uses inline values
212
+ //
213
+ // Inline format: h5_pct, h5_h, h5_m, weekly_pct, weekly_d, weekly_h
214
+ // ──────────────────────────────────────────────────────────────────────
215
+ async function openSyncForm(
216
+ ctx: ExtensionCommandContext,
217
+ mirrorStore: MirrorStore,
218
+ provider: string,
219
+ args: string,
220
+ ) {
221
+ let parsed = parseInlineArgs(args);
222
+
223
+ if (!parsed) {
224
+ // Single prompt with comma-separated values
225
+ const placeholder = "e.g. 6,4,8,73,2,13 (h5%, h5h, h5m, wk%, wkd, wkh)";
226
+ const answer = await ctx.ui.input(
227
+ `Sync ${provider} usage — paste as: h5%, h5 reset h, h5 reset m, weekly%, weekly reset d, weekly reset h`,
228
+ placeholder,
229
+ );
230
+ if (!answer) {
231
+ ctx.ui.notify("Sync cancelled", "info");
232
+ return;
233
+ }
234
+ parsed = parseInlineArgs(answer);
235
+ if (!parsed) {
236
+ ctx.ui.notify(
237
+ `Invalid format. Expected 6 comma-separated numbers.\nGot: "${answer}"`,
238
+ "error",
239
+ );
240
+ return;
241
+ }
242
+ }
243
+
244
+ const values = {
245
+ h5_used_pct: parsed[0],
246
+ h5_resets_h: parsed[1],
247
+ h5_resets_m: parsed[2],
248
+ weekly_used_pct: parsed[3],
249
+ weekly_resets_d: parsed[4],
250
+ weekly_resets_h: parsed[5],
251
+ };
252
+
253
+ const validationResult = parseSyncValues({
254
+ h5_used_pct: String(values.h5_used_pct),
255
+ h5_resets_h: String(values.h5_resets_h),
256
+ h5_resets_m: String(values.h5_resets_m),
257
+ weekly_used_pct: String(values.weekly_used_pct),
258
+ weekly_resets_d: String(values.weekly_resets_d),
259
+ weekly_resets_h: String(values.weekly_resets_h),
260
+ });
261
+ if (!validationResult) {
262
+ ctx.ui.notify(
263
+ `Out of range. h5/wk must be 0-100, h/d/m must fit their bounds.`,
264
+ "error",
265
+ );
266
+ return;
267
+ }
268
+
269
+ const record = buildMirrorRecord(validationResult, provider, Date.now());
270
+ mirrorStore.write(record);
271
+ ctx.ui.notify(
272
+ `Mirror synced for ${provider}.\n` +
273
+ ` 5h: ${validationResult.h5_used_pct}% used, resets in ${validationResult.h5_resets_h}h ${validationResult.h5_resets_m}m\n` +
274
+ ` weekly: ${validationResult.weekly_used_pct}% used, resets in ${validationResult.weekly_resets_d}d ${validationResult.weekly_resets_h}h\n\n` +
275
+ `Run /usage to see the full status.`,
276
+ "info",
277
+ );
278
+ }
279
+
280
+ /** Parse "6,4,8,73,2,13" → [6,4,8,73,2,13], or null if invalid. */
281
+ function parseInlineArgs(
282
+ s: string,
283
+ ): [number, number, number, number, number, number] | null {
284
+ const trimmed = s.trim();
285
+ if (!trimmed) return null;
286
+ const parts = trimmed.split(/[,\s]+/).filter(Boolean);
287
+ if (parts.length !== 6) return null;
288
+ const nums = parts.map((p) => Number(p));
289
+ if (nums.some((n) => !Number.isFinite(n))) return null;
290
+ return nums as [number, number, number, number, number, number];
291
+ }