Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Render order (top → bottom): **info · prayers · money · zai**.

## The money line is a ledger, not a tracker

<img src="assets/dataflow.svg" alt="Data-flow diagram: the omp sessions tree on disk feeds the money line; the z.ai quota API (key from the pi auth file, read-only) feeds the provider-gated zai line; the aladhan API, cached per local day, feeds the prayers and info lines." width="832">
<img src="assets/dataflow.svg" alt="Data-flow diagram: the omp sessions tree on disk feeds the money line; the z.ai quota API (key from omp's credential store, with the pi auth file as fallback) feeds the provider-gated zai line; the aladhan API, cached per local day, feeds the prayers and info lines." width="832">

Costs are disk-scanned from omp's own session tree (`~/.omp/agent/sessions/`), not tracked live:

Expand All @@ -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",
Expand All @@ -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. |
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions assets/dataflow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
71 changes: 58 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null | undefined>;
};
}
interface SlApi {
on(event: "session_start" | "model_select", handler: (event: unknown, ctx: SlCtx) => void): void;
Expand All @@ -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;
}

Expand All @@ -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<string, unknown> };
Expand All @@ -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,
Expand All @@ -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<ZaiKey | null> {
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.
Expand Down Expand Up @@ -251,15 +292,18 @@ export default function ompStatusline(pi: SlApi): void {

async function pollZai(): Promise<void> {
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 */
Expand Down Expand Up @@ -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");
Expand Down
90 changes: 90 additions & 0 deletions test/zai-key.test.ts
Original file line number Diff line number Diff line change
@@ -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, []);
});
Loading