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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Render order (top → bottom): **info · prayers · money · zai**.

| Line | Contents |
|---|---|
| **info** | Local weekday + clock — `Wed 21:12` · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city · version stamp — `omp 18.3.4 · sl 0.6.2` (host from omp's launch marker, `omp --version` spawn fallback; plugin from its own package.json) — all dim. The Hijri date advances at Maghrib, not civil midnight (evening value via Aladhan `gToH`, cached per city-day; on failure it stays on today's date). |
| **info** | Local weekday + clock — `Wed 21:12` · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city · version stamp — `omp 18.3.4 · sl 0.6.2` (host from one `--version` spawn of the **running omp binary** — `process.execPath`, so it is install-agnostic (brew/npm/curl/source) and correct even for sessions resumed across an upgrade; plugin from its own package.json) — all dim. The Hijri date advances at Maghrib, not civil midnight (evening value via Aladhan `gToH`, cached per city-day; on failure it stays on today's date). |
| **prayers** | All five prayers with wall times, from [aladhan](https://api.aladhan.com) (cached per local day, stale-marker on degradation). Past prayers get a dim `✓`; the next prayer is green with a countdown on its segment only — `Dhuhr 11:51 (3h 36m)`. |
| **money** | API spend + token volume per window: `REPO $68.36 (1.3B)` (all-time for the current project) · `DAY` (since 00:00 local) · `7DAY` / `30DAY` (rolling, hour-aligned). Token volumes are dim, from the same sessions scan. |
| **zai** | Quota pace per window: `LABEL usage%/elapsed% (pace · reset · absolute)` — e.g. `5hrs 16%/26% (30m under · 3h 43m · 11:58)`. **Provider-gated**: renders only while the active model's provider is `zai` (or the provider is unreadable); vanishes entirely on other providers. |
Expand Down
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.6.2",
"version": "0.6.3",
"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
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ export default function ompStatusline(pi: SlApi): void {
let started = false;
let warnedNoKey = false;
// Clock-line stamp: sl resolves synchronously (package.json next to the source);
// omp resolves async (marker file, else one `omp --version` spawn) and re-renders.
// omp resolves async (one `--version` spawn of the RUNNING binary,
// process.execPath — install-agnostic) and re-renders.
let stamp: VersionStamp = { omp: null, sl: slVersion() };

// Managed timers when the host exposes them (omp ≥18.1: a throw inside the tick
Expand Down
49 changes: 26 additions & 23 deletions src/versions.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
// src/versions.ts — clock-line version stamp sources (v0.6.2). Both resolved once
// per process and cached: the stamp describes the RUNNING omp + the INSTALLED sl,
// src/versions.ts — clock-line version stamp sources. Both resolved once per
// process and cached: the stamp describes the RUNNING omp + the INSTALLED sl,
// neither of which changes while the session lives.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileP = promisify(execFile);

// omp rewrites this marker on every launch — it tracks the running version without
// spawning anything. NOT createRequire("@oh-my-pi/..."): omp ships as a single
// bundled binary, so no host package.json is resolvable from inside the plugin.
const LAST_CHANGELOG = join(homedir(), ".omp", "agent", "last-changelog-version");

const SEMVER_RE = /\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?/;

// Pure extraction: "18.3.4\n" → "18.3.4"; "omp version 18.3.4 (bun)" → "18.3.4";
Expand All @@ -22,31 +15,41 @@ export function extractSemver(text: string): string | null {
return SEMVER_RE.exec(text.trim())?.[0] ?? null;
}

// Primary source: the launch marker file. Missing/garbled → null (caller falls
// back to spawning `omp --version`).
export function readOmpVersionFile(path: string = LAST_CHANGELOG): string | null {
try {
return extractSemver(readFileSync(path, "utf8"));
} catch {
return null;
}
// Guard over `--version` output: omp prints "omp/18.4.8" (legacy: "omp version …").
// A bare "22.11.0" — what `node --version`/`bun --version` print when execPath
// points at a plain runtime instead of omp (source/dev runs) — must NOT become
// the omp stamp, so anything without the omp prefix is rejected.
export function parseOmpVersionOutput(text: string): string | null {
const t = text.trim();
return /^omp[/ ]/.test(t) ? extractSemver(t) : null;
}

async function spawnOmpVersion(): Promise<string | null> {
// Primary (and only) source: spawn THE RUNNING BINARY. Inside the plugin,
// process.execPath is whatever binary actually launched omp — brew Cellar path,
// npm-global bin, curl-installed ~/.omp/bin, asdf shim target, source build — so
// the answer is install-agnostic and stays correct for sessions resumed across
// an upgrade, where spawning PATH `omp` would report the NEW binary. Spawn
// failure (e.g. brew cleanup deleted the old Cellar mid-session) → null: the
// stamp segment is omitted upstream, never rendered as a placeholder lie.
// (v0.6.2 read ~/.omp/agent/last-changelog-version first instead — proven wrong
// in the field: omp only writes that marker when its config-gated startup
// changelog actually displays, so it goes stale across silent upgrades and is
// global state shared by every session, old and new.)
export async function spawnOmpVersion(bin: string): Promise<string | null> {
try {
const { stdout } = await execFileP("omp", ["--version"], { timeout: 5_000 });
return extractSemver(stdout);
const { stdout } = await execFileP(bin, ["--version"], { timeout: 5_000 });
return parseOmpVersionOutput(stdout);
} catch {
return null;
}
}

let ompCache: Promise<string | null> | undefined;

// File first (no process spawn); `omp --version` fallback, cached process-long so
// the fallback fires at most once even across sessions in the same process.
// Running-binary spawn, cached process-long so it fires at most once per
// process even across sessions in the same process.
export function ompVersion(): Promise<string | null> {
ompCache ??= (async () => readOmpVersionFile() ?? await spawnOmpVersion())();
ompCache ??= spawnOmpVersion(process.execPath);
return ompCache;
}

Expand Down
42 changes: 30 additions & 12 deletions test/versions.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// test/versions.test.ts — clock-line stamp sources: pure semver extraction, the
// omp launch-marker reader (fixture files, no live ~/.omp state), and the plugin's
// own package.json resolver.
// omp `--version` output guard, the running-binary spawn (fixture scripts plus
// the test runner's own runtime as the bare-version false positive), and the
// plugin's own package.json resolver.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { chmodSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractSemver, readOmpVersionFile, slVersion } from "../src/versions.ts";
import { extractSemver, parseOmpVersionOutput, spawnOmpVersion, slVersion } from "../src/versions.ts";

test("extractSemver: bare marker, prefixed banner, prerelease+build, garbage", () => {
assert.equal(extractSemver("18.3.4\n"), "18.3.4");
Expand All @@ -16,21 +17,38 @@ test("extractSemver: bare marker, prefixed banner, prerelease+build, garbage", (
assert.equal(extractSemver(""), null);
});

test("readOmpVersionFile: valid marker parses; garbage and missing → null", () => {
test("parseOmpVersionOutput: omp banners parse; bare runtime versions rejected", () => {
assert.equal(parseOmpVersionOutput("omp/18.4.8\n"), "18.4.8");
assert.equal(parseOmpVersionOutput("omp version 18.3.4 (bun)"), "18.3.4");
assert.equal(parseOmpVersionOutput("omp/1.2.3-beta.1+abc"), "1.2.3-beta.1+abc");
assert.equal(parseOmpVersionOutput("v22.11.0\n"), null); // node --version shape
assert.equal(parseOmpVersionOutput("1.2.23\n"), null); // bun --version shape
assert.equal(parseOmpVersionOutput("no version here"), null);
assert.equal(parseOmpVersionOutput(""), null);
});

function writeExec(dir: string, name: string, body: string): string {
const path = join(dir, name);
writeFileSync(path, body);
chmodSync(path, 0o755);
return path;
}

test("spawnOmpVersion: omp fixture parses; bare-version fixture rejected; missing binary → null", async () => {
const dir = mkdtempSync(join(tmpdir(), "sl-versions-"));
try {
const good = join(dir, "good");
writeFileSync(good, "18.3.4\n");
assert.equal(readOmpVersionFile(good), "18.3.4");
const bad = join(dir, "bad");
writeFileSync(bad, "not-a-version");
assert.equal(readOmpVersionFile(bad), null);
assert.equal(readOmpVersionFile(join(dir, "absent")), null);
assert.equal(await spawnOmpVersion(writeExec(dir, "ompish", "#!/bin/sh\necho 'omp/9.9.9'\n")), "9.9.9");
assert.equal(await spawnOmpVersion(writeExec(dir, "runtime", "#!/bin/sh\necho '22.11.0'\n")), null);
assert.equal(await spawnOmpVersion(join(dir, "absent")), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("spawnOmpVersion(process.execPath) under the test runner → null: node/bun print bare versions, not omp stamps", async () => {
assert.equal(await spawnOmpVersion(process.execPath), null);
});

test("slVersion: resolves this package's package.json, matching the manifest version", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string };
assert.equal(slVersion(), pkg.version);
Expand Down
Loading