@bacnh85/pi-sub 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +12 -18
  2. package/index.ts +193 -109
  3. package/package.json +1 -4
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pi extension that shows subscription usage for the currently selected supported model provider.
4
4
 
5
- V1 supports OpenAI Codex models via `@loongphy/codex-auth` and displays a compact footer status only while the active Pi model provider is `openai-codex`.
5
+ V1 supports OpenAI Codex models by reading Pi's auth state from `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`) and displays a separate subscription line below the editor only while the active Pi model provider is `openai-codex`.
6
6
 
7
7
  ## Install
8
8
 
@@ -34,13 +34,13 @@ The footer status includes:
34
34
  - weekly usage percentage and reset time/date;
35
35
  - active Codex model id.
36
36
 
37
- Example compact footer text:
37
+ Example subscription line:
38
38
 
39
39
  ```text
40
40
  Sub Plus user@example.com 5H 85%→18:12 W 80%→2 Jul 08:00 · gpt-5-codex
41
41
  ```
42
42
 
43
- When the current model provider is not supported, `pi-sub` clears its footer status and does not refresh subscription data.
43
+ When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
44
44
 
45
45
  ## Commands
46
46
 
@@ -50,12 +50,11 @@ When the current model provider is not supported, `pi-sub` clears its footer sta
50
50
  | `/sub status` | Same as `/sub`. |
51
51
  | `/sub refresh` | Force a usage refresh, then show details. |
52
52
 
53
- When Codex account data is available, `/sub` shows a table similar to `codex-auth list`:
53
+ When Pi OpenAI Codex auth is available, `/sub` shows the active account usage:
54
54
 
55
55
  ```text
56
56
  ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
57
57
  * hangdanchi@gmail.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
58
- mail@bacnh.com Plus 75% (14:21) 20% (14:23 on 29 Jun) Now
59
58
  ```
60
59
 
61
60
  ## Refresh behavior
@@ -68,23 +67,18 @@ ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
68
67
  - periodically while a supported provider remains active;
69
68
  - when `/sub refresh` is run.
70
69
 
71
- Refreshes are cached briefly to avoid excessive `codex-auth` calls.
70
+ `pi-sub` reads the `openai-codex` OAuth entry from Pi's auth file and refreshes live usage directly against ChatGPT's usage endpoint. It does not execute the `codex-auth` CLI and does not assume a separate Codex CLI installation exists.
72
71
 
73
- ## Requirements and troubleshooting
74
-
75
- - `@loongphy/codex-auth` is installed as a runtime dependency of this package.
76
- - Codex account data must already be configured for `codex-auth list` to return usage.
77
- - Node.js 22+ is required by `@loongphy/codex-auth`.
78
- - `pi-sub` redacts auth/token-related errors in the footer and never prints Codex tokens.
72
+ Refreshes are cached briefly to avoid excessive usage endpoint calls.
79
73
 
80
- If usage is unavailable, run:
74
+ ## Requirements and troubleshooting
81
75
 
82
- ```bash
83
- npx @loongphy/codex-auth list
84
- ```
76
+ - Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`.
77
+ - The entry must include `access` and `accountId` fields.
78
+ - `pi-sub` redacts auth/token-related errors and never prints Codex tokens.
85
79
 
86
- outside Pi to verify the underlying Codex account state.
80
+ If usage is unavailable, verify that Pi can use the `openai-codex` provider and that the auth file exists.
87
81
 
88
82
  ## Design notes
89
83
 
90
- The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. V1 intentionally supports only OpenAI Codex.
84
+ The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. V1 intentionally supports only OpenAI Codex and vendors only the small amount of behavior needed for this extension instead of invoking `codex-auth` directly.
package/index.ts CHANGED
@@ -1,17 +1,41 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Text } from "@earendil-works/pi-tui";
3
- import { spawn } from "node:child_process";
4
- import { createRequire } from "node:module";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
5
6
 
6
- const require = createRequire(import.meta.url);
7
7
  const STATUS_KEY = "pi-sub";
8
8
  const MESSAGE_TYPE = "pi-sub-status";
9
+ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
9
10
  const REFRESH_INTERVAL_MS = 60_000;
10
11
  const REFRESH_TTL_MS = 30_000;
11
12
  const REFRESH_DEBOUNCE_MS = 2_000;
13
+ const PI_SUB_PROVIDER = "openai-codex";
12
14
 
13
15
  type ModelLike = { provider?: string; id?: string } | undefined;
14
16
 
17
+ type UsageApiWindow = {
18
+ used_percent?: number;
19
+ limit_window_seconds?: number;
20
+ reset_at?: number;
21
+ };
22
+
23
+ type UsageApiSnapshot = {
24
+ primary?: UsageApiWindow;
25
+ secondary?: UsageApiWindow;
26
+ plan_type?: string;
27
+ };
28
+
29
+ type PiAuthFile = Record<string, PiAuthEntry | undefined>;
30
+
31
+ type PiAuthEntry = {
32
+ type?: string;
33
+ access?: string;
34
+ refresh?: string;
35
+ expires?: number;
36
+ accountId?: string;
37
+ };
38
+
15
39
  interface UsageWindow {
16
40
  used?: number;
17
41
  limit?: number;
@@ -59,117 +83,175 @@ interface State {
59
83
 
60
84
  function isCodexModel(model: ModelLike): boolean {
61
85
  const provider = model?.provider?.toLowerCase() ?? "";
62
- return provider === "openai-codex" || provider.includes("openai-codex");
86
+ return provider === PI_SUB_PROVIDER || provider.includes(PI_SUB_PROVIDER);
87
+ }
88
+
89
+ function piAuthPath(): string {
90
+ const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
91
+ return path.join(configDir, "auth.json");
92
+ }
93
+
94
+ async function readJsonFile<T>(file: string): Promise<T> {
95
+ return JSON.parse(await fs.readFile(file, "utf8")) as T;
63
96
  }
64
97
 
65
- function parsePercent(value: string): number | undefined {
66
- const match = value.match(/(\d+(?:\.\d+)?)\s*%/);
67
- return match ? Number(match[1]) : undefined;
98
+ function decodeJwtPayload(token: string | undefined): Record<string, any> | undefined {
99
+ if (!token) return undefined;
100
+ const parts = token.split(".");
101
+ if (parts.length < 2) return undefined;
102
+ try {
103
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, any>;
104
+ } catch {
105
+ return undefined;
106
+ }
68
107
  }
69
108
 
70
- function parseUsageWindow(value: string | undefined): UsageWindow | undefined {
71
- if (!value) return undefined;
72
- const trimmed = value.trim();
73
- if (!trimmed || trimmed === "-" || trimmed === "—") return undefined;
74
- const percent = parsePercent(trimmed);
75
- const resetMatch = trimmed.match(/\(([^)]+)\)/);
109
+ function accountFromPiAuth(entry: PiAuthEntry): SubscriptionAccountSnapshot {
110
+ const claims = decodeJwtPayload(entry.access);
111
+ const profile = claims?.["https://api.openai.com/profile"];
112
+ const auth = claims?.["https://api.openai.com/auth"];
113
+ const email = typeof profile?.email === "string" ? profile.email : undefined;
114
+ const plan = typeof auth?.chatgpt_plan_type === "string" ? planLabel(auth.chatgpt_plan_type) : undefined;
115
+ const accountId = typeof entry.accountId === "string" ? entry.accountId : typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
76
116
  return {
77
- percent,
78
- resetLabel: resetMatch?.[1]?.trim(),
79
- label: trimmed,
117
+ id: accountId,
118
+ isActive: true,
119
+ accountLabel: email ?? accountId ?? "openai-codex account",
120
+ plan,
121
+ lastActivity: "Now",
80
122
  };
81
123
  }
82
124
 
83
- function stripAnsi(value: string): string {
84
- return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
125
+ function planLabel(plan: string | undefined): string | undefined {
126
+ if (!plan) return undefined;
127
+ const normalized = plan.toLowerCase().replace(/[_-]+/g, " ");
128
+ const labels: Record<string, string> = {
129
+ free: "Free",
130
+ plus: "Plus",
131
+ prolite: "Pro Lite",
132
+ "pro lite": "Pro Lite",
133
+ pro: "Pro",
134
+ team: "Business",
135
+ business: "Business",
136
+ enterprise: "Enterprise",
137
+ edu: "Edu",
138
+ unknown: "Unknown",
139
+ };
140
+ return labels[normalized] ?? plan;
85
141
  }
86
142
 
87
- function parseCodexAuthList(output: string): SubscriptionUsageSnapshot {
88
- const accounts: SubscriptionAccountSnapshot[] = [];
89
- const lines = stripAnsi(output).split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean);
90
- const dataLines = lines.filter((line) => !/^ACCOUNT\s+PLAN\s+5H USAGE/i.test(line) && !/^-{5,}$/.test(line.trim()));
143
+ function formatReset(timestampSeconds: number | undefined): string | undefined {
144
+ if (!timestampSeconds) return undefined;
145
+ const date = new Date(timestampSeconds * 1000);
146
+ const now = new Date();
147
+ const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
148
+ if (date.toDateString() === now.toDateString()) return time;
149
+ return `${time} on ${date.toLocaleDateString(undefined, { day: "numeric", month: "short" })}`;
150
+ }
91
151
 
92
- for (const line of dataLines) {
93
- const match = line.match(/^(\*)?\s*(?:(\d+)\s+)?(\S+@\S+)\s+(\S+)\s+(\d+(?:\.\d+)?%\s*\([^)]*\)|[-—?])\s+(\d+(?:\.\d+)?%\s*\([^)]*\)|[-—?])\s*(.*)$/);
94
- if (!match) continue;
95
- accounts.push({
96
- isActive: Boolean(match[1]),
97
- id: match[2],
98
- accountLabel: match[3],
99
- plan: match[4],
100
- fiveHour: parseUsageWindow(match[5]),
101
- weekly: parseUsageWindow(match[6]),
102
- lastActivity: match[7]?.trim() || undefined,
103
- });
104
- }
152
+ function usageWindowFromApi(window: UsageApiWindow | undefined): UsageWindow | undefined {
153
+ if (!window || typeof window.used_percent !== "number") return undefined;
154
+ const percent = Math.round(window.used_percent);
155
+ const resetLabel = formatReset(window.reset_at);
156
+ return {
157
+ percent,
158
+ resetLabel,
159
+ resetsAt: window.reset_at,
160
+ label: resetLabel ? `${percent}% (${resetLabel})` : `${percent}%`,
161
+ };
162
+ }
163
+
164
+ function mergeUsageIntoAccount(account: SubscriptionAccountSnapshot, usage: UsageApiSnapshot | undefined): SubscriptionAccountSnapshot {
165
+ if (!usage) return account;
166
+ return {
167
+ ...account,
168
+ plan: planLabel(usage.plan_type) ?? account.plan,
169
+ fiveHour: usageWindowFromApi(usage.primary) ?? account.fiveHour,
170
+ weekly: usageWindowFromApi(usage.secondary) ?? account.weekly,
171
+ };
172
+ }
105
173
 
174
+ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
175
+ if (!body || typeof body !== "object") return undefined;
176
+ const root = body as any;
177
+ const rateLimit = root.rate_limit;
178
+ if (!rateLimit || typeof rateLimit !== "object") return undefined;
179
+ const parseWindow = (window: any): UsageApiWindow | undefined => {
180
+ if (!window || typeof window !== "object" || typeof window.used_percent !== "number") return undefined;
181
+ return {
182
+ used_percent: window.used_percent,
183
+ limit_window_seconds: typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : undefined,
184
+ reset_at: typeof window.reset_at === "number" ? window.reset_at : undefined,
185
+ };
186
+ };
106
187
  return {
107
- providerId: "openai-codex",
108
- providerDisplayName: "Codex",
109
- accounts,
110
- activeAccount: accounts.find((account) => account.isActive) ?? accounts[0],
111
- fetchedAt: Date.now(),
188
+ primary: parseWindow(rateLimit.primary_window),
189
+ secondary: parseWindow(rateLimit.secondary_window),
190
+ plan_type: typeof root.plan_type === "string" ? root.plan_type : undefined,
112
191
  };
113
192
  }
114
193
 
194
+ async function readPiCodexAuth(): Promise<PiAuthEntry> {
195
+ const auth = await readJsonFile<PiAuthFile>(piAuthPath());
196
+ const entry = auth[PI_SUB_PROVIDER];
197
+ if (!entry?.access || !entry.accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
198
+ return entry;
199
+ }
200
+
201
+ async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
202
+ const timeoutSignal = AbortSignal.timeout(7_000);
203
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
204
+ const response = await fetch(USAGE_ENDPOINT, {
205
+ headers: {
206
+ Accept: "application/json",
207
+ Authorization: `Bearer ${entry.access}`,
208
+ "ChatGPT-Account-Id": entry.accountId!,
209
+ "User-Agent": "pi-sub/0.1.0",
210
+ },
211
+ signal: combinedSignal,
212
+ });
213
+ if (!response.ok) throw new Error(`usage request failed with HTTP ${response.status}`);
214
+ return parseUsageResponse(await response.json());
215
+ }
216
+
115
217
  function redactedError(error: unknown): string {
116
218
  const message = error instanceof Error ? error.message : String(error || "Unknown error");
117
- if (/missing platform package|cannot find module|ENOENT/i.test(message)) return "codex-auth is not installed correctly";
118
- if (/timed out|timeout/i.test(message)) return "codex-auth timed out";
119
- if (/not logged in|auth|token|unauthorized|forbidden/i.test(message)) return "Codex auth unavailable";
219
+ if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
220
+ if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
221
+ if (/timed out|timeout|aborted/i.test(message)) return "Codex usage refresh timed out";
222
+ if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return "Codex auth unavailable";
120
223
  return "Codex usage unavailable";
121
224
  }
122
225
 
123
- function resolveCodexAuthBin(): string {
124
- return require.resolve("@loongphy/codex-auth/bin/codex-auth.js");
125
- }
126
-
127
- function runCodexAuthList(signal?: AbortSignal): Promise<string> {
128
- return new Promise((resolve, reject) => {
129
- const child = spawn(process.execPath, [resolveCodexAuthBin(), "list"], {
130
- stdio: ["ignore", "pipe", "pipe"],
131
- env: { ...process.env, NO_COLOR: "1" },
132
- });
133
- let stdout = "";
134
- let stderr = "";
135
- const timeout = setTimeout(() => {
136
- child.kill("SIGTERM");
137
- reject(new Error("codex-auth list timed out"));
138
- }, 20_000);
139
- const onAbort = () => {
140
- child.kill("SIGTERM");
141
- reject(signal?.reason ?? new Error("Operation aborted"));
226
+ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
227
+ try {
228
+ const entry = await readPiCodexAuth();
229
+ let activeAccount = accountFromPiAuth(entry);
230
+ const usage = await fetchUsageFromPiAuth(entry, signal);
231
+ activeAccount = mergeUsageIntoAccount(activeAccount, usage);
232
+ return {
233
+ providerId: PI_SUB_PROVIDER,
234
+ providerDisplayName: "Codex",
235
+ accounts: [activeAccount],
236
+ activeAccount,
237
+ fetchedAt: Date.now(),
142
238
  };
143
- signal?.addEventListener("abort", onAbort, { once: true });
144
- child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
145
- child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
146
- child.on("error", reject);
147
- child.on("close", (code) => {
148
- clearTimeout(timeout);
149
- signal?.removeEventListener("abort", onAbort);
150
- if (code === 0) resolve(stdout);
151
- else reject(new Error(stderr.trim() || `codex-auth list exited with code ${code}`));
152
- });
153
- });
239
+ } catch (error) {
240
+ return {
241
+ providerId: PI_SUB_PROVIDER,
242
+ providerDisplayName: "Codex",
243
+ accounts: [],
244
+ fetchedAt: Date.now(),
245
+ error: redactedError(error),
246
+ };
247
+ }
154
248
  }
155
249
 
156
250
  const codexAdapter: SubscriptionProviderAdapter = {
157
- id: "openai-codex",
251
+ id: PI_SUB_PROVIDER,
158
252
  displayName: "Codex",
159
253
  isModelSupported: isCodexModel,
160
- async fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
161
- try {
162
- return parseCodexAuthList(await runCodexAuthList(signal));
163
- } catch (error) {
164
- return {
165
- providerId: "openai-codex",
166
- providerDisplayName: "Codex",
167
- accounts: [],
168
- fetchedAt: Date.now(),
169
- error: redactedError(error),
170
- };
171
- }
172
- },
254
+ fetchUsage: fetchCodexUsage,
173
255
  };
174
256
 
175
257
  const adapters = [codexAdapter];
@@ -195,31 +277,33 @@ function maxPercent(account: SubscriptionAccountSnapshot | undefined): number {
195
277
  return Math.max(account?.fiveHour?.percent ?? 0, account?.weekly?.percent ?? 0);
196
278
  }
197
279
 
198
- function renderStatus(ctx: ExtensionContext, state: State): void {
280
+ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
199
281
  if (!state.adapter) {
200
- ctx.ui.setStatus(STATUS_KEY, undefined);
282
+ ctx.ui.setWidget(STATUS_KEY, undefined);
201
283
  return;
202
284
  }
203
285
  const theme = ctx.ui.theme;
204
286
  const snapshot = state.snapshot;
205
287
  const modelId = state.model?.id ?? "unknown-model";
288
+ let line: string;
289
+ let color: "dim" | "warning" | "error" = "dim";
206
290
  if (!snapshot) {
207
- ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `Sub ${state.adapter.displayName} loading · ${modelId}`));
208
- return;
209
- }
210
- if (snapshot.error) {
211
- ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `Sub ${snapshot.error} · ${modelId}`));
212
- return;
291
+ line = `Sub ${state.adapter.displayName} loading · ${modelId}`;
292
+ } else if (snapshot.error) {
293
+ line = `Sub ${snapshot.error} · ${modelId}`;
294
+ color = "warning";
295
+ } else {
296
+ const account = snapshot.activeAccount;
297
+ const pieces = ["Sub"];
298
+ if (account?.plan) pieces.push(account.plan);
299
+ pieces.push(account?.accountLabel ?? "unknown account");
300
+ pieces.push(`5H ${formatWindow(account?.fiveHour)}`);
301
+ pieces.push(`W ${formatWindow(account?.weekly)}`);
302
+ pieces.push("·", modelId);
303
+ line = pieces.join(" ");
304
+ color = maxPercent(account) >= 90 ? "error" : maxPercent(account) >= 80 ? "warning" : "dim";
213
305
  }
214
- const account = snapshot.activeAccount;
215
- const pieces = ["Sub"];
216
- if (account?.plan) pieces.push(account.plan);
217
- pieces.push(account?.accountLabel ?? "unknown account");
218
- pieces.push(`5H ${formatWindow(account?.fiveHour)}`);
219
- pieces.push(`W ${formatWindow(account?.weekly)}`);
220
- pieces.push("·", modelId);
221
- const color = maxPercent(account) >= 90 ? "error" : maxPercent(account) >= 80 ? "warning" : "dim";
222
- ctx.ui.setStatus(STATUS_KEY, theme.fg(color, pieces.join(" ")));
306
+ ctx.ui.setWidget(STATUS_KEY, [theme.fg(color, line)], { placement: "belowEditor" });
223
307
  }
224
308
 
225
309
  function startTimer(ctx: ExtensionContext, state: State): void {
@@ -244,23 +328,23 @@ function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLi
244
328
  state.lastRefreshAt = 0;
245
329
  stopTimer(state);
246
330
  }
247
- renderStatus(ctx, state);
331
+ renderSubscriptionLine(ctx, state);
248
332
  if (state.adapter) startTimer(ctx, state);
249
333
  }
250
334
 
251
335
  async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
252
336
  const adapter = state.adapter;
253
337
  if (!adapter) {
254
- renderStatus(ctx, state);
338
+ renderSubscriptionLine(ctx, state);
255
339
  return undefined;
256
340
  }
257
341
  if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
258
342
  if (state.inFlight) return state.inFlight;
259
- renderStatus(ctx, state);
343
+ renderSubscriptionLine(ctx, state);
260
344
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
261
345
  state.snapshot = snapshot;
262
346
  state.lastRefreshAt = Date.now();
263
- renderStatus(ctx, state);
347
+ renderSubscriptionLine(ctx, state);
264
348
  return snapshot;
265
349
  }).finally(() => {
266
350
  state.inFlight = undefined;
@@ -331,7 +415,7 @@ export default function (pi: ExtensionAPI) {
331
415
 
332
416
  pi.on("session_shutdown", async (_event, ctx) => {
333
417
  stopTimer(state);
334
- ctx.ui.setStatus(STATUS_KEY, undefined);
418
+ ctx.ui.setWidget(STATUS_KEY, undefined);
335
419
  });
336
420
 
337
421
  pi.registerCommand("sub", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,9 +33,6 @@
33
33
  "./index.ts"
34
34
  ]
35
35
  },
36
- "dependencies": {
37
- "@loongphy/codex-auth": "^0.2.10"
38
- },
39
36
  "peerDependencies": {
40
37
  "@earendil-works/pi-coding-agent": "*",
41
38
  "@earendil-works/pi-tui": "*"