@openlines/opl-pi-sht 0.2.8 → 0.2.10

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
@@ -14,15 +14,18 @@
14
14
 
15
15
  A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
16
16
 
17
- <div align="center">
18
-
19
17
  >
20
- > ### v0.2.8 (29 Sept 2026) - **SSRF and tool-safety hardening**: `opl-webaccess` and `opl-browser` block private/link-local hosts by default and cloud metadata always (localhost stays available for dev), fetch redirects are re-checked per hop, screenshots refuse to overwrite existing files, and `evaluate` no longer crashes on `undefined`. <br/>See [CHANGELOG.md](CHANGELOG.md) for details.
18
+ > ### v0.2.10 - Codex subscription usage in opl-footer
19
+ > - `opl-footer` added ChatGPT Codex subs 5-hour/weekly quota segment with reset countdowns and stale-snapshot fallback.
20
+ > ### v0.2.9 - pi dependencies patch, updated test scripts
21
+ > - `devDependencies` updated to `>=0.87.0`, conforming to latest pi release `v0.99.x`; TUI and tests updates
22
+ > ### v0.2.8 - SSRF and tool-safety hardening
23
+ > - `opl-webaccess` and `opl-browser` block private/link-local hosts by default and cloud metadata always (localhost stays available for dev), fetch redirects are re-checked per hop, screenshots refuse to overwrite existing files, and `evaluate` no longer crashes on `undefined`.
21
24
  >
22
- > `Current project state also marked 'SAFE' for pi v0.99.0 release`<br/>*confirmed this because I was literally typing to push the patches when I noticed the release notif*
25
+ > See [CHANGELOG](CHANGELOG.md) for more details.
26
+ >
27
+ > `Current project state also marked 'SAFE' for pi v0.99.x release`
23
28
  >
24
-
25
- </div>
26
29
 
27
30
  ## Installation
28
31
 
@@ -91,7 +94,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
91
94
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
92
95
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
93
96
  | [`opl-modes`](extensions/opl-modes/README.md) | Mode, plan, tool-safety, lazy-tool-loading, and active-appearance manager - highly-modified, configrable and enhanced mode-switcher. | `/mode`, `/chat`, `/plan`, `/execute`, `plan_complete`, `load_tools`; `opl-modes.json`. |
94
- | [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | `/configure-opl`; `opl-footer.json`. |
97
+ | [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | `/configure-opl`; `opl-footer.json`. |
95
98
 
96
99
  ## What you'll gain
97
100
 
@@ -136,7 +139,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
136
139
 
137
140
  ### See what the agent is doing
138
141
 
139
- - **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, and per-turn timing in a configurable multi-row footer; `/configure-opl` edits and reorders its layout interactively.
142
+ - **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, per-turn timing, and an optional exact remaining ChatGPT Codex 5-hour/weekly subscription-quota segment with reset countdowns and stale-snapshot fallback; `/configure-opl` edits and reorders its layout interactively.
140
143
  - **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
141
144
  ![input-footer](images/ss-input-footer.png)
142
145
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, text:<literal>",
2
+ "_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, codex_usage, text:<literal>. codex_usage is hidden by default; add it to any row to show ChatGPT Codex subscription windows.",
3
3
  "row1LeftSegments": [
4
4
  "pi",
5
5
  "separator",
@@ -31,6 +31,7 @@ The third row is populated after the first completed turn. Its session and perfo
31
31
  - **Context bar**: configurable gradient bar with percentage and context-window size
32
32
  - **Git integration**: branch plus staged, unstaged, and untracked counts, with invalidation after relevant file and Git commands
33
33
  - **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
34
+ - **Codex subscription quota**: optional exact 5-hour and weekly ChatGPT Codex usage percentages with reset countdowns and stale-snapshot fallback
34
35
  - **Session statistics**: prompt, API-call, and model tool-call counts
35
36
  - **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
36
37
  - **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
@@ -91,9 +92,16 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
91
92
  | `session_stats` | Prompt, API-call, and tool-call counts | Hidden until the first prompt |
92
93
  | `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
93
94
  | `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
95
+ | `codex_usage` | `5h 76% ↻2h18m · W 37% ↻3d7h` | Exact remaining ChatGPT Codex subscription quota; OAuth-only, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
94
96
  | `separator` | `\|` divider | Coloured via `separator` in `colors` |
95
97
  | `text:...` | Literal text, e.g. `text:⚡` | — |
96
98
 
99
+ ## Codex Subscription Usage
100
+
101
+ Add `codex_usage` to any row through `/configure-opl` or `opl-footer.json`. While the active model is the OAuth-authenticated `openai-codex` provider, the footer asks Pi's model registry to resolve the same short-lived OAuth access token that Pi uses for the selected model, then sends `GET https://chatgpt.com/backend-api/wham/usage` directly from the local Pi process. The request carries `Authorization: Bearer <Pi OAuth token>`, `Accept: application/json`, a fixed `User-Agent: opl-footer-codex-usage`, and the token's `chatgpt_account_id` JWT claim as `chatgpt-account-id` when present. It runs after session start, model selection, and settled agent turns, with a 30-second refresh floor and a 15-second request timeout. It classifies the returned windows by duration rather than response order. This is ChatGPT subscription quota, not OpenAI Platform API-key usage or billing.
102
+
103
+ No OAuth token, account ID, or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `chatgpt.com` for that request. **The footer retains only the parsed 5-hour/weekly percentage and reset time in process memory for the active session.** The endpoint is an internal ChatGPT backend API and can change without notice. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
104
+
97
105
  ## Context Bar
98
106
 
99
107
  The `context_pct` segment's bar is fully configurable via `segmentOptions.contextBar`:
@@ -0,0 +1,122 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
4
+ const FIVE_HOURS_SECONDS = 5 * 60 * 60;
5
+ const WEEK_SECONDS = 7 * 24 * 60 * 60;
6
+ const WINDOW_TOLERANCE_SECONDS = 120;
7
+ const REQUEST_TIMEOUT_MS = 15_000;
8
+ const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
9
+
10
+ export interface CodexUsageWindow {
11
+ usedPercent: number;
12
+ windowSeconds: number;
13
+ resetAt?: number;
14
+ }
15
+
16
+ export interface CodexUsageSnapshot {
17
+ fiveHour?: CodexUsageWindow;
18
+ weekly?: CodexUsageWindow;
19
+ fetchedAt: number;
20
+ stale: boolean;
21
+ }
22
+
23
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
24
+
25
+ type UsageRecord = Record<string, unknown>;
26
+
27
+ function asRecord(value: unknown): UsageRecord | null {
28
+ return value !== null && typeof value === "object" && !Array.isArray(value)
29
+ ? value as UsageRecord
30
+ : null;
31
+ }
32
+
33
+ function normalizeWindow(value: unknown): CodexUsageWindow | null {
34
+ const record = asRecord(value);
35
+ if (!record) return null;
36
+
37
+ const usedPercent = record.used_percent;
38
+ const windowSeconds = record.limit_window_seconds;
39
+ const resetAt = record.reset_at;
40
+ if (
41
+ typeof usedPercent !== "number" || !Number.isFinite(usedPercent) ||
42
+ typeof windowSeconds !== "number" || !Number.isFinite(windowSeconds) || windowSeconds <= 0
43
+ ) return null;
44
+
45
+ return {
46
+ usedPercent,
47
+ windowSeconds,
48
+ ...(typeof resetAt === "number" && Number.isFinite(resetAt) ? { resetAt } : {}),
49
+ };
50
+ }
51
+
52
+ function matchesWindow(window: CodexUsageWindow, seconds: number): boolean {
53
+ return Math.abs(window.windowSeconds - seconds) <= WINDOW_TOLERANCE_SECONDS;
54
+ }
55
+
56
+ export function parseCodexUsage(data: unknown, fetchedAt = Date.now()): CodexUsageSnapshot | null {
57
+ const root = asRecord(data);
58
+ const rateLimit = asRecord(root?.rate_limit);
59
+ const windows = [
60
+ normalizeWindow(rateLimit?.primary_window),
61
+ normalizeWindow(rateLimit?.secondary_window),
62
+ ].filter((window): window is CodexUsageWindow => window !== null);
63
+
64
+ const fiveHour = windows.find((window) => matchesWindow(window, FIVE_HOURS_SECONDS));
65
+ const weekly = windows.find((window) => matchesWindow(window, WEEK_SECONDS));
66
+ if (!fiveHour && !weekly) return null;
67
+
68
+ return { fiveHour, weekly, fetchedAt, stale: false };
69
+ }
70
+
71
+ function accountIdFromToken(token: string): string | undefined {
72
+ try {
73
+ const payload = token.split(".")[1];
74
+ if (!payload) return undefined;
75
+ const claims = asRecord(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
76
+ const auth = asRecord(claims?.[OPENAI_AUTH_CLAIM]);
77
+ return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ export async function fetchCodexUsage(
84
+ ctx: ExtensionContext,
85
+ fetchFn: FetchLike = globalThis.fetch,
86
+ fetchedAt = Date.now(),
87
+ ): Promise<CodexUsageSnapshot> {
88
+ const model = ctx.model;
89
+ if (!model || model.provider !== "openai-codex" || !ctx.modelRegistry.isUsingOAuth(model)) {
90
+ throw new Error("Codex usage requires an openai-codex OAuth subscription model");
91
+ }
92
+
93
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
94
+ if (!auth.ok || !auth.apiKey) throw new Error("OpenAI Codex OAuth authentication is unavailable");
95
+
96
+ const accountId = accountIdFromToken(auth.apiKey);
97
+ const response = await fetchFn(USAGE_URL, {
98
+ headers: {
99
+ Authorization: `Bearer ${auth.apiKey}`,
100
+ Accept: "application/json",
101
+ "User-Agent": "opl-footer-codex-usage",
102
+ ...(accountId ? { "chatgpt-account-id": accountId } : {}),
103
+ },
104
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
105
+ });
106
+ if (!response.ok) throw new Error(`Codex usage request failed (${response.status})`);
107
+
108
+ const snapshot = parseCodexUsage(await response.json(), fetchedAt);
109
+ if (!snapshot) throw new Error("Codex usage response contained no supported windows");
110
+ return snapshot;
111
+ }
112
+
113
+ export async function refreshCodexUsageSnapshot(
114
+ previous: CodexUsageSnapshot | null,
115
+ load: () => Promise<CodexUsageSnapshot>,
116
+ ): Promise<CodexUsageSnapshot | null> {
117
+ try {
118
+ return await load();
119
+ } catch {
120
+ return previous ? { ...previous, stale: true } : null;
121
+ }
122
+ }
@@ -27,7 +27,7 @@ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
27
27
  "pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
28
28
  "chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
29
29
  "cache_read", "cache_write", "cost", "context_pct", "context_total",
30
- "session_stats", "perf_stats", "status",
30
+ "session_stats", "perf_stats", "status", "codex_usage",
31
31
  ];
32
32
 
33
33
  const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
@@ -12,6 +12,7 @@ import { getEffectiveConfig } from "./config.js";
12
12
  import { getIcons } from "./icons.js";
13
13
  import { getDefaultColors, fg } from "./theme.js";
14
14
  import { showFooterConfigurator } from "./configure.js";
15
+ import { fetchCodexUsage, refreshCodexUsageSnapshot, type CodexUsageSnapshot } from "./codex-usage.js";
15
16
 
16
17
  const GIT_BRANCH_PATTERNS: RegExp[] = [
17
18
  // init/clone included: creating a repo mid-session must clear the not-a-repo back-off.
@@ -19,8 +20,10 @@ const GIT_BRANCH_PATTERNS: RegExp[] = [
19
20
  /\bgit\s+stash\s+(pop|apply)/,
20
21
  ];
21
22
 
22
- /** Row keys whose configured segments decide whether git must be probed at all. */
23
- const GIT_LAYOUT_ROWS = [
23
+ /** Row keys used to detect whether an optional segment is enabled. */
24
+ const CODEX_USAGE_REFRESH_MS = 30_000;
25
+
26
+ const LAYOUT_ROWS = [
24
27
  "row1LeftSegments",
25
28
  "row1RightSegments",
26
29
  "row2LeftSegments",
@@ -116,6 +119,52 @@ export default function footer(pi: ExtensionAPI) {
116
119
  let lastBranchLength = 0;
117
120
  let cachedUsageStats: UsageStats | null = null;
118
121
  let tuiRef: TUI | null = null;
122
+ let codexUsage: CodexUsageSnapshot | null = null;
123
+ let codexUsageLastAttempt = 0;
124
+ let codexUsageGeneration = 0;
125
+ let codexUsageInFlight: Promise<void> | null = null;
126
+
127
+ const refreshCodexUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
128
+ const model = ctx.model;
129
+ const isCodexSubscription = model?.provider === "openai-codex" && ctx.modelRegistry.isUsingOAuth(model);
130
+ if (!isCodexSubscription) {
131
+ codexUsageGeneration++;
132
+ codexUsageInFlight = null;
133
+ codexUsage = null;
134
+ tuiRef?.requestRender();
135
+ return Promise.resolve();
136
+ }
137
+
138
+ const config = getEffectiveConfig();
139
+ const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("codex_usage"));
140
+ if (!enabled) {
141
+ codexUsageGeneration++;
142
+ codexUsageInFlight = null;
143
+ codexUsage = null;
144
+ codexUsageLastAttempt = 0;
145
+ tuiRef?.requestRender();
146
+ return Promise.resolve();
147
+ }
148
+ if (codexUsageInFlight) return codexUsageInFlight;
149
+
150
+ const now = Date.now();
151
+ if (!force && now - codexUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
152
+ codexUsageLastAttempt = now;
153
+ const generation = ++codexUsageGeneration;
154
+
155
+ let request: Promise<void>;
156
+ request = refreshCodexUsageSnapshot(codexUsage, () => fetchCodexUsage(ctx))
157
+ .then((snapshot) => {
158
+ if (generation !== codexUsageGeneration) return;
159
+ codexUsage = snapshot;
160
+ tuiRef?.requestRender();
161
+ })
162
+ .finally(() => {
163
+ if (codexUsageInFlight === request) codexUsageInFlight = null;
164
+ });
165
+ codexUsageInFlight = request;
166
+ return request;
167
+ };
119
168
 
120
169
  // Session stats accumulators (timing only; counts are reconstructed from the branch)
121
170
  let llmMs = 0;
@@ -132,7 +181,10 @@ export default function footer(pi: ExtensionAPI) {
132
181
  pi.registerCommand("configure-opl", {
133
182
  description: "Interactively configure the OPL footer layout",
134
183
  handler: async (_args, ctx) => {
135
- await showFooterConfigurator(ctx, () => tuiRef?.requestRender());
184
+ await showFooterConfigurator(ctx, () => {
185
+ tuiRef?.requestRender();
186
+ void refreshCodexUsage(ctx, true);
187
+ });
136
188
  },
137
189
  });
138
190
 
@@ -142,6 +194,10 @@ export default function footer(pi: ExtensionAPI) {
142
194
  currentCtx = ctx;
143
195
  lastBranchLength = 0;
144
196
  cachedUsageStats = null;
197
+ codexUsage = null;
198
+ codexUsageLastAttempt = 0;
199
+ codexUsageGeneration++;
200
+ codexUsageInFlight = null;
145
201
  llmMs = 0;
146
202
  toolMs = 0;
147
203
  ttftSamples = [];
@@ -155,9 +211,14 @@ export default function footer(pi: ExtensionAPI) {
155
211
 
156
212
  if (ctx.hasUI) {
157
213
  setupFooter(ctx);
214
+ void refreshCodexUsage(ctx, true);
158
215
  }
159
216
  });
160
217
 
218
+ pi.on("model_select", async (_event: unknown, ctx: ExtensionContext) => {
219
+ void refreshCodexUsage(ctx, true);
220
+ });
221
+
161
222
  // Track user-prompt-to-completion turnaround. agent_start may fire multiple
162
223
  // times per user turn (retries/compaction); only the first since the last
163
224
  // settle marks when the user's prompt began processing. agent_settled is the
@@ -175,6 +236,7 @@ export default function footer(pi: ExtensionAPI) {
175
236
  }
176
237
  statusTracker.agentSettled();
177
238
  tuiRef?.requestRender();
239
+ void refreshCodexUsage(_ctx);
178
240
  });
179
241
 
180
242
  pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -292,7 +354,7 @@ export default function footer(pi: ExtensionAPI) {
292
354
 
293
355
  // Get git status (cached). Skip the probes entirely when no visible row renders the
294
356
  // git segment — otherwise an unused cell keeps spawning git once per second.
295
- const usesGit = GIT_LAYOUT_ROWS.some((row) => effectiveConfig[row]?.includes("git"));
357
+ const usesGit = LAYOUT_ROWS.some((row) => effectiveConfig[row]?.includes("git"));
296
358
  const gitBranch = usesGit ? footerDataRef?.getGitBranch() ?? null : null;
297
359
  const gitStatus = usesGit
298
360
  ? getGitStatus(gitBranch)
@@ -339,6 +401,7 @@ export default function footer(pi: ExtensionAPI) {
339
401
  icons: getIcons(effectiveConfig.icons),
340
402
  sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
341
403
  agentStatus: statusTracker.status(),
404
+ codexUsage,
342
405
  };
343
406
  }
344
407
 
@@ -0,0 +1,45 @@
1
+ import type { RenderedSegment, SegmentContext } from "../types.js";
2
+ import { applyColor } from "../theme.js";
3
+
4
+ function formatPercent(value: number): string {
5
+ const clamped = Math.max(0, Math.min(100, value));
6
+ return Number.isInteger(clamped) ? String(clamped) : clamped.toFixed(1);
7
+ }
8
+
9
+ function formatReset(resetAt: number | undefined, nowMs: number): string {
10
+ if (resetAt === undefined) return "";
11
+ const resetMs = resetAt > 10_000_000_000 ? resetAt : resetAt * 1000;
12
+ const remainingMinutes = Math.max(0, Math.floor((resetMs - nowMs) / 60_000));
13
+ if (remainingMinutes === 0) return "now";
14
+
15
+ const days = Math.floor(remainingMinutes / (24 * 60));
16
+ const hours = Math.floor((remainingMinutes % (24 * 60)) / 60);
17
+ if (days > 0) return `${days}d${hours}h`;
18
+
19
+ const minutes = remainingMinutes % 60;
20
+ if (hours > 0) return `${hours}h${minutes}m`;
21
+ return `${minutes}m`;
22
+ }
23
+
24
+ export const codexUsageSegment = {
25
+ id: "codex_usage" as const,
26
+ render(ctx: SegmentContext): RenderedSegment {
27
+ const snapshot = ctx.codexUsage;
28
+ if (!snapshot) return { content: "", visible: false };
29
+
30
+ const nowMs = ctx.now ?? Date.now();
31
+ const parts: string[] = [];
32
+ if (snapshot.fiveHour) {
33
+ const reset = formatReset(snapshot.fiveHour.resetAt, nowMs);
34
+ parts.push(`5h ${formatPercent(100 - snapshot.fiveHour.usedPercent)}% ${reset ? `↻${reset}` : ""}`);
35
+ }
36
+ if (snapshot.weekly) {
37
+ const reset = formatReset(snapshot.weekly.resetAt, nowMs);
38
+ parts.push(`W ${formatPercent(100 - snapshot.weekly.usedPercent)}% ${reset ? `↻${reset}` : ""}`);
39
+ }
40
+ if (parts.length === 0) return { content: "", visible: false };
41
+
42
+ const stale = snapshot.stale ? applyColor(ctx.theme, "dim", " (stale)") : "";
43
+ return { content: parts.join(applyColor(ctx.theme, "dim", " · ")) + stale, visible: true };
44
+ },
45
+ };
@@ -14,6 +14,7 @@ import { chatModeSegment } from "./chat-mode.js";
14
14
  import { modeSwitcherSegment } from "./mode-switcher.js";
15
15
  import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
16
16
  import { statusSegment } from "./status.js";
17
+ import { codexUsageSegment } from "./codex-usage.js";
17
18
 
18
19
  const SEGMENTS = {
19
20
  pi: piSegment,
@@ -37,6 +38,7 @@ const SEGMENTS = {
37
38
  session_stats: sessionStatsSegment,
38
39
  perf_stats: perfStatsSegment,
39
40
  status: statusSegment,
41
+ codex_usage: codexUsageSegment,
40
42
  };
41
43
 
42
44
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -1,5 +1,6 @@
1
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import type { IconSet } from "./icons.js";
3
+ import type { CodexUsageSnapshot } from "./codex-usage.js";
3
4
 
4
5
  // Theme color - either a pi theme color name or a custom hex color
5
6
  export type ColorValue = ThemeColor | `#${string}`;
@@ -54,6 +55,7 @@ export type StatusLineSegmentId =
54
55
  | "session_stats"
55
56
  | "perf_stats"
56
57
  | "status"
58
+ | "codex_usage"
57
59
  | "separator"
58
60
  | `text:${string}`;
59
61
 
@@ -131,6 +133,8 @@ export interface SegmentContext {
131
133
  icons: IconSet;
132
134
  sessionStats: SessionStats;
133
135
  agentStatus: AgentStatus;
136
+ codexUsage: CodexUsageSnapshot | null;
137
+ now?: number;
134
138
  }
135
139
 
136
140
  // Minimal event shapes used by the opl-footer handlers
@@ -1,6 +1,6 @@
1
1
  # opl-guardian
2
2
 
3
- A configurable session and tool safety extension. `opl-guardian` filters malformed assistant tool calls, prompts before dangerous Bash commands, blocks operations on protected paths, and confirms destructive session transitions. It replaces the standalone `permission-gate`, `protected-paths`, and `confirm-destructive` extensions while retaining malformed-call diagnostics. It adds no tools, commands, prompts, or model-facing schema overhead.
3
+ A configurable session and tool safety extension. `opl-guardian` filters malformed assistant tool calls, prompts before dangerous Bash commands, blocks operations on protected paths, and confirms destructive session transitions. It replaces the standalone `permission-gate`, `protected-paths`, and `confirm-destructive` extensions while retaining malformed-call diagnostics. It adds no tools, commands, prompts, or model-facing schema overhead. All Guardian notification, selection-prompt, and confirmation message text is rendered in fixed red, regardless of Pi's theme; choice labels retain Pi's normal styling.
4
4
 
5
5
  ## Configuration
6
6
 
@@ -20,6 +20,12 @@ import {
20
20
  import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
21
21
 
22
22
  const LOG_PATH = join("err", "guardian.jsonl");
23
+ const RED = "\u001b[31m";
24
+ const RESET_FOREGROUND = "\u001b[39m";
25
+
26
+ function red(text: string): string {
27
+ return `${RED}${text}${RESET_FOREGROUND}`;
28
+ }
23
29
 
24
30
  export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
25
31
  const directory = join(cwd, "err");
@@ -103,7 +109,7 @@ export function createToolCallHandler(config: GuardianConfig) {
103
109
  const reason = blocked.unresolved
104
110
  ? `[opl-guardian] Cannot safely resolve path "${blocked.path}"; ${blocked.operation} denied by protected-path policy.`
105
111
  : `[opl-guardian] Path "${blocked.path}" is protected (${blocked.operation} denied).`;
106
- if (ctx.hasUI) ctx.ui.notify(reason, "warning");
112
+ if (ctx.hasUI) ctx.ui.notify(red(reason), "warning");
107
113
  return { block: true, reason };
108
114
  }
109
115
  }
@@ -124,7 +130,7 @@ export function createToolCallHandler(config: GuardianConfig) {
124
130
  const options = ["No", "Yes"];
125
131
  if (ctx.mode === "rpc") {
126
132
  try {
127
- const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, options);
133
+ const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
128
134
  if (choice === "Yes") return undefined;
129
135
  } catch {
130
136
  // A failed RPC dialog is not approval.
@@ -132,10 +138,10 @@ export function createToolCallHandler(config: GuardianConfig) {
132
138
  return { block: true, reason: "[opl-guardian] Command blocked; confirmation was declined or unavailable." };
133
139
  }
134
140
 
135
- const choice = await ctx.ui.select(`Dangerous command: ${preview.slice(0, 256)}\nAllow?`, options);
141
+ const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
136
142
  if (choice === "Yes") return undefined;
137
143
  const reason = "[opl-guardian] Command blocked by user because it matches a dangerous pattern.";
138
- ctx.ui.notify(reason, "warning");
144
+ ctx.ui.notify(red(reason), "warning");
139
145
  return { block: true, reason };
140
146
  };
141
147
  }
@@ -151,7 +157,7 @@ export function createMessageEndHandler(config: GuardianConfig) {
151
157
  sessionId: ctx.sessionManager.getSessionId(),
152
158
  });
153
159
  if (!guarded) return undefined;
154
- ctx.ui?.notify(guarded.diagnostic, "warning");
160
+ ctx.ui?.notify(red(guarded.diagnostic), "warning");
155
161
  return { message: guarded.message };
156
162
  };
157
163
  }
@@ -172,7 +178,7 @@ export function createSessionBeforeSwitchHandler(config: GuardianConfig) {
172
178
  }
173
179
 
174
180
  if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
175
- return await ctx.ui.confirm(title, message) ? undefined : { cancel: true };
181
+ return await ctx.ui.confirm(red(title), red(message)) ? undefined : { cancel: true };
176
182
  };
177
183
  }
178
184
 
@@ -181,8 +187,8 @@ export function createSessionBeforeForkHandler(config: GuardianConfig) {
181
187
  if (!config.confirmDestructive.forkSession) return undefined;
182
188
  if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
183
189
  const confirmed = await ctx.ui.confirm(
184
- `Fork from entry ${event.entryId.slice(0, 8)}?`,
185
- "Create a new session branch from this point?",
190
+ red(`Fork from entry ${event.entryId.slice(0, 8)}?`),
191
+ red("Create a new session branch from this point?"),
186
192
  );
187
193
  return confirmed ? undefined : { cancel: true };
188
194
  };
@@ -192,7 +198,7 @@ export default function (pi: ExtensionAPI) {
192
198
  const { config, warnings } = loadGuardianConfig();
193
199
  pi.on("session_start", async (_event, ctx) => {
194
200
  if (warnings.length && ctx.hasUI) {
195
- ctx.ui.notify(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`, "warning");
201
+ ctx.ui.notify(red(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`), "warning");
196
202
  }
197
203
  });
198
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, tool and session safety, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,9 +40,9 @@
40
40
  },
41
41
  "type": "module",
42
42
  "scripts": {
43
- "test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-guardian && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim && npm run test:pi-host",
43
+ "test": "node tests/test-summary.mjs",
44
44
  "test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
45
- "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
45
+ "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs tests/opl-footer-lifecycle.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
46
46
  "test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
47
47
  "test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
48
48
  "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
@@ -55,6 +55,6 @@
55
55
  "test:pi-host": "bun test tests/pi-host-loader.test.mjs"
56
56
  },
57
57
  "devDependencies": {
58
- "@earendil-works/pi-coding-agent": "^0.87.0"
58
+ "@earendil-works/pi-coding-agent": ">=0.87.0"
59
59
  }
60
60
  }