diff --git a/README.md b/README.md
index 19543d2..6ffddd3 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Render order (top → bottom): **info · prayers · money · zai**.
## The money line is a ledger, not a tracker
-
+
Costs are disk-scanned from omp's own session tree (`~/.omp/agent/sessions/`), not tracked live:
@@ -53,8 +53,7 @@ Or from a checkout: `omp plugin link /path/to/omp-statusline`.
```json
{
"zai": {
- "pollIntervalMs": 180000,
- "authJsonPath": "~/.pi/agent/auth.json"
+ "pollIntervalMs": 180000
},
"deen": {
"city": "Jakarta",
@@ -68,7 +67,7 @@ Or from a checkout: `omp plugin link /path/to/omp-statusline`.
| Key | Default | Notes |
|---|---|---|
| `zai.pollIntervalMs` | `180000` | Poll cycle for quota fetch + money rescan (clamped to ≥ `30000`). |
-| `zai.authJsonPath` | `~/.pi/agent/auth.json` | pi-style auth JSON `{"zai": {"key": "…"}}`, read-only. omp has no auth store of its own, so the pi auth file is read by default. |
+| `zai.authJsonPath` | *unset* | Explicit pi-style auth JSON `{"zai": {"key": "…"}}`, read-only — pins that file as the sole key source. Unset (default): the key resolves from **omp's own credential store** (`omp /login` → `models.yml` → env → auth broker) via the host, falling back to `~/.pi/agent/auth.json` — omp-only setups need no pi files. |
| `deen.city` / `deen.country` | `Jakarta` / `Indonesia` | aladhan lookup. |
| `deen.method` | `auto` | Calculation method (`auto` → aladhan default). |
| `deen.escalateMinutes` | `30` | Minutes-until-next-prayer threshold for the `soon` escalation band. |
@@ -80,7 +79,7 @@ State (deen cache) lives beside the config in `~/.omp/agent/omp-statusline/`.
Forces a zai + deen + money refresh and notifies full source state: quota freshness, today's plan credits, 7-day per-model split, usage streaks, cache-hit rate, prayer city/hijri, and the spend breakdown including subagent share and entry count:
```
-zai 5h 16% · weekly 24% · fetched 0m ago · today 7.7K · models 7d glm-5.3 28K · gpt-5.4 9.2K · streak 46d (best 61d) · cache 71% | deen Jakarta · 25 Rabīʿ al-awwal 1448 · fresh | money REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88 · sub $11.02 · 1234 entries
+zai key omp credentials · 5h 16% · weekly 24% · fetched 0m ago · today 7.7K · models 7d glm-5.3 28K · gpt-5.4 9.2K · streak 46d (best 61d) · cache 71% | deen Jakarta · 25 Rabīʿ al-awwal 1448 · fresh | money REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88 · sub $11.02 · 1234 entries
```
The z.ai dashboard endpoints (`credit-usage/usage-detail`, `credit-usage/activity`) accept the same API key as the quota API — no browser or cookie session needed.
diff --git a/assets/dataflow.svg b/assets/dataflow.svg
index 9b5695c..9a0d7fe 100644
--- a/assets/dataflow.svg
+++ b/assets/dataflow.svg
@@ -27,8 +27,8 @@
z.ai quota API
zai · provider-gated
- key from ~/.pi/agent/auth.json
- read-only — omp has no auth store of its own
+ key: omp credential store (/login)
+ fallback: ~/.pi/agent/auth.json (read-only)
aladhan API
diff --git a/package.json b/package.json
index 3042e62..aaae0c9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpipher/omp-statusline",
- "version": "0.3.0",
+ "version": "0.4.0",
"description": "Four-line omp statusline widget: zai coding-plan quota (provider-gated), prayer times, hijri clock, and API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive).",
"keywords": [
"pi-package",
diff --git a/src/index.ts b/src/index.ts
index 3275bf6..9f4540d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,8 +5,9 @@
// REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88
// Data layer vendored from @getpipher/pi-statusline (quota/zai, format, deen, adapters);
// money comes from the omp sessions disk-scan (money.ts — subagent-inclusive). State
-// lives under ~/.omp/agent/omp-statusline/; the only ~/.pi read is the zai key
-// (configurable authJsonPath — omp has no auth.json of its own).
+// lives under ~/.omp/agent/omp-statusline/. The zai key resolves from omp's own
+// credential store first (ctx.modelRegistry — /login, models.yml, env, broker);
+// the pi-style auth file is only a fallback (authJsonPath pins it explicitly).
import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync, mkdirSync } from "node:fs";
@@ -47,12 +48,22 @@ interface SlUi {
interface SlModel {
provider?: string;
}
-interface SlCtx {
+export interface SlCtx {
ui: SlUi;
// omp-native model facade; absent in contexts that don't expose it → provider reads
// as undefined and the zai gate falls back to "show" (inert-adapter philosophy:
// plan data stays visible rather than vanishing when we can't read the provider).
models?: { current(): SlModel | null };
+ // omp-native credential ladder (models.yml apiKey → OAuth → /login-stored key →
+ // env → auth broker) — the same resolution the host uses for provider "zai".
+ // Absent in contexts that don't expose it → key resolution falls back to file.
+ modelRegistry?: {
+ getApiKeyForProvider(
+ provider: string,
+ sessionId?: unknown,
+ opts?: { forceRefresh?: boolean },
+ ): Promise;
+ };
}
interface SlApi {
on(event: "session_start" | "model_select", handler: (event: unknown, ctx: SlCtx) => void): void;
@@ -66,10 +77,16 @@ interface SlApi {
const STATE_DIR = join(homedir(), ".omp", "agent", "omp-statusline");
const CONFIG_PATH = join(STATE_DIR, "config.json");
const DEEN_CACHE = join(STATE_DIR, "deen-cache.json");
+// Fallback file only — omp keeps its own credentials in ~/.omp/agent/agent.db
+// (auth_credentials), so omp-only setups never need this pi-era path.
+const PI_AUTH_JSON = join(homedir(), ".pi", "agent", "auth.json");
export interface LiveConfig {
zaiPollMs: number;
- authJsonPath: string;
+ // null = not configured → key resolves via ctx.modelRegistry first, then the
+ // pi-style auth file (PI_AUTH_JSON). A configured path pins the file as the
+ // sole source (explicit intent beats the credential store).
+ authJsonPath: string | null;
deen: DeenSourceConfig;
}
@@ -78,7 +95,7 @@ export interface LiveConfig {
export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig {
const defaults: LiveConfig = {
zaiPollMs: 180_000,
- authJsonPath: join(homedir(), ".pi", "agent", "auth.json"),
+ authJsonPath: null,
deen: { city: "Jakarta", country: "Indonesia", method: "auto", escalateMinutes: 30 },
};
let parsed: { zai?: { pollIntervalMs?: unknown; authJsonPath?: unknown }; deen?: Record };
@@ -92,7 +109,7 @@ export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig {
const esc = parsed.deen?.escalateMinutes;
return {
zaiPollMs: typeof pollMs === "number" && Number.isFinite(pollMs) && pollMs >= 30_000 ? pollMs : defaults.zaiPollMs,
- authJsonPath: typeof authPath === "string" && authPath !== "" ? authPath : defaults.authJsonPath,
+ authJsonPath: typeof authPath === "string" && authPath !== "" ? authPath : null,
deen: {
city: typeof parsed.deen?.city === "string" ? parsed.deen.city : defaults.deen.city,
country: typeof parsed.deen?.country === "string" ? parsed.deen.country : defaults.deen.country,
@@ -102,6 +119,30 @@ export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig {
};
}
+// Resolved key + its origin (diagnostics: an omp /login key and a pi auth.json key
+// can be different z.ai accounts — /sl reports which one fed the quota).
+export interface ZaiKey {
+ key: string;
+ source: string;
+}
+export async function resolveZaiKey(
+ ctx: SlCtx | null,
+ cfg: LiveConfig,
+ defaultFile: string = PI_AUTH_JSON,
+): Promise {
+ if (cfg.authJsonPath === null && ctx?.modelRegistry) {
+ try {
+ const key = await ctx.modelRegistry.getApiKeyForProvider("zai");
+ if (typeof key === "string" && key !== "") return { key, source: "omp credentials" };
+ } catch {
+ /* registry unavailable → file fallback */
+ }
+ }
+ const file = cfg.authJsonPath ?? defaultFile;
+ const fileKey = readZaiKey(file);
+ return fileKey ? { key: fileKey, source: file } : null;
+}
+
// --- line renderers (approved mockup, tmux window slmock, 2026-09-07) ---------
// Heat bands: quota windows tint accent → warning (≥70) → error (≥90); next prayer
// success-green; past prayers dim ✓; upcoming text; labels/glyphs/separators dim.
@@ -251,15 +292,18 @@ export default function ompStatusline(pi: SlApi): void {
async function pollZai(): Promise {
try {
- const key = readZaiKey(cfg.authJsonPath);
- if (!key) {
+ const resolved = await resolveZaiKey(ctx, cfg);
+ if (!resolved) {
if (!warnedNoKey) {
warnedNoKey = true;
- ctx?.ui.notify(`omp-statusline: no zai key at ${cfg.authJsonPath} — quota line inert`, "warning");
+ ctx?.ui.notify(
+ `omp-statusline: no zai key (omp credential store / ${cfg.authJsonPath ?? PI_AUTH_JSON}) — quota line inert`,
+ "warning",
+ );
}
return;
}
- const result = await fetchQuota(key);
+ const result = await fetchQuota(resolved.key);
if (result) zaiData = result;
} catch {
/* keep last-good; next poll retries */
@@ -308,17 +352,18 @@ export default function ompStatusline(pi: SlApi): void {
await pollDeen();
pollMoney();
const s = deen.current();
- const key = readZaiKey(cfg.authJsonPath);
- const report = key ? await fetchZaiReport(key) : null;
+ const resolved = await resolveZaiKey(cmdCtx, cfg);
+ const report = resolved ? await fetchZaiReport(resolved.key) : null;
const zaiPart = zaiData
? [
+ `key ${resolved?.source ?? "?"}`,
zaiStatusDetail(zaiData, Date.now()),
report && report.todayCredits !== null ? `today ${compactK(report.todayCredits)}` : "",
report && report.models.length ? `models 7d ${report.models.map((m) => `${m.name} ${compactK(m.credits)}`).join(" · ")}` : "",
report && report.streakDays !== null ? `streak ${report.streakDays}d (best ${report.longestStreakDays ?? "?"}d)` : "",
report && report.cacheHitRate !== null ? `cache ${Math.round(report.cacheHitRate * 100)}%` : "",
].filter(Boolean).join(" · ")
- : "no data";
+ : resolved ? "no quota data" : "no key";
const deenPart = s ? `${s.city} · ${s.hijri}${s.staleMinutes !== null ? ` · stale ${s.staleMinutes}m` : " · fresh"}` : "no data";
const moneyPart = `REPO $${money.repo.toFixed(2)} · DAY $${money.day.toFixed(2)} · 7DAY $${money.week.toFixed(2)} · 30DAY $${money.month.toFixed(2)} · sub $${money.sub.toFixed(2)} · ${money.entries} entries`;
cmdCtx.ui.notify(`zai ${zaiPart} | deen ${deenPart} | money ${moneyPart}`, "info");
diff --git a/test/zai-key.test.ts b/test/zai-key.test.ts
new file mode 100644
index 0000000..cae8798
--- /dev/null
+++ b/test/zai-key.test.ts
@@ -0,0 +1,90 @@
+// test/zai-key.test.ts — zai key resolution order (v0.4.0): omp credential store
+// first, pi-style auth file fallback, explicit authJsonPath pins the file. This is
+// the omp-only-user contract — no ~/.pi required for the quota row to live.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { loadLiveConfig, resolveZaiKey, type LiveConfig, type SlCtx } from "../src/index.ts";
+
+const ui = {
+ setStatus: () => {},
+ setWidget: () => {},
+ notify: () => {},
+};
+function ctxWithRegistry(key: string | null | undefined, calls: string[] = []): SlCtx {
+ return {
+ ui,
+ modelRegistry: {
+ getApiKeyForProvider: (provider: string) => {
+ calls.push(provider);
+ if (key === null) return Promise.reject(new Error("registry unavailable"));
+ return Promise.resolve(key);
+ },
+ },
+ };
+}
+
+function tmpAuthFile(body: string): string {
+ const dir = mkdtempSync(join(tmpdir(), "osl-key-"));
+ const file = join(dir, "auth.json");
+ writeFileSync(file, body);
+ return file;
+}
+
+const unconfigured: LiveConfig = {
+ zaiPollMs: 180_000,
+ authJsonPath: null,
+ deen: { city: "Jakarta", country: "Indonesia", method: "auto", escalateMinutes: 30 },
+};
+
+test("loadLiveConfig: authJsonPath null unless explicitly a non-empty string", () => {
+ const dir = mkdtempSync(join(tmpdir(), "osl-cfg-"));
+ const absent = join(dir, "absent.json");
+ assert.equal(loadLiveConfig(absent).authJsonPath, null); // unreadable → default
+
+ const configured = join(dir, "configured.json");
+ writeFileSync(configured, JSON.stringify({ zai: { authJsonPath: "/custom/auth.json" } }));
+ assert.equal(loadLiveConfig(configured).authJsonPath, "/custom/auth.json");
+
+ const badType = join(dir, "bad.json");
+ writeFileSync(badType, JSON.stringify({ zai: { authJsonPath: 42 } }));
+ assert.equal(loadLiveConfig(badType).authJsonPath, null);
+
+ const empty = join(dir, "empty.json");
+ writeFileSync(empty, JSON.stringify({ zai: { authJsonPath: "" } }));
+ assert.equal(loadLiveConfig(empty).authJsonPath, null);
+});
+
+test("resolveZaiKey: credential store wins when unconfigured (omp-only user)", async () => {
+ const calls: string[] = [];
+ const fallback = tmpAuthFile(JSON.stringify({ zai: { key: "file-key" } }));
+ const resolved = await resolveZaiKey(ctxWithRegistry("store-key", calls), unconfigured, fallback);
+ assert.deepEqual(resolved, { key: "store-key", source: "omp credentials" });
+ assert.deepEqual(calls, ["zai"]); // asked the host, never touched the file
+});
+
+test("resolveZaiKey: file fallback when registry misses, rejects, or ctx lacks it", async () => {
+ const fallback = tmpAuthFile(JSON.stringify({ zai: { key: "file-key" } }));
+ for (const ctx of [ctxWithRegistry(null), ctxWithRegistry(undefined), ctxWithRegistry(""), { ui }]) {
+ const resolved = await resolveZaiKey(ctx, unconfigured, fallback);
+ assert.deepEqual(resolved, { key: "file-key", source: fallback });
+ }
+ // and no file either → inert, not an error
+ assert.equal(await resolveZaiKey({ ui }, unconfigured, join(tmpdir(), "osl-no-such-auth.json")), null);
+});
+
+test("resolveZaiKey: explicit authJsonPath pins the file — registry never consulted", async () => {
+ const calls: string[] = [];
+ const pinned = tmpAuthFile(JSON.stringify({ zai: { key: "pinned-key" } }));
+ const cfg: LiveConfig = { ...unconfigured, authJsonPath: pinned };
+ const resolved = await resolveZaiKey(ctxWithRegistry("store-key", calls), cfg, "/never/read.json");
+ assert.deepEqual(resolved, { key: "pinned-key", source: pinned });
+ assert.deepEqual(calls, []); // explicit intent beats the credential store
+ // pinned path with no key there → null (no silent fallback to the store)
+ const deadPin = join(pinned, "..", "nope.json");
+ assert.equal(await resolveZaiKey(ctxWithRegistry("store-key", calls), { ...unconfigured, authJsonPath: deadPin }), null);
+ assert.deepEqual(calls, []);
+});