@narumitw/pi-codex-usage 0.13.0 → 0.13.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.
package/README.md CHANGED
@@ -98,14 +98,15 @@ The extension does not read Pi or Codex auth files directly, and it does not exp
98
98
  ```txt
99
99
  extensions/pi-codex-usage/
100
100
  ├── src/
101
- └── codex-usage.ts
101
+ ├── codex-usage.ts # Pi entrypoint and cache/lifecycle orchestration
102
+ │ └── *.ts # Package-local query, RPC, normalization, and format modules
102
103
  ├── README.md
103
104
  ├── LICENSE
104
105
  ├── tsconfig.json
105
106
  └── package.json
106
107
  ```
107
108
 
108
- The package exposes its Pi extension through `package.json`:
109
+ Only `codex-usage.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
109
110
 
110
111
  ```json
111
112
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-codex-usage",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension that shows Codex ChatGPT subscription usage without requiring Codex CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,216 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { normalizeAppServerResponse } from "./normalize.js";
4
+ import type {
5
+ AppServerRateLimitResponse,
6
+ CodexUsageReport,
7
+ PendingRpc,
8
+ RpcResponse,
9
+ } from "./types.js";
10
+
11
+ const MAX_ERROR_BODY_CHARS = 600;
12
+
13
+ export async function queryViaCodexAppServer(timeoutMs: number): Promise<CodexUsageReport> {
14
+ const client = new CodexAppServerClient(timeoutMs);
15
+ try {
16
+ await client.start();
17
+ await client.request("initialize", {
18
+ clientInfo: {
19
+ name: "pi_codex_usage",
20
+ title: "Pi Codex Usage",
21
+ version: "0.1.0",
22
+ },
23
+ capabilities: {
24
+ experimentalApi: false,
25
+ requestAttestation: false,
26
+ optOutNotificationMethods: [],
27
+ },
28
+ });
29
+ client.notify("initialized");
30
+ const result = await client.request("account/rateLimits/read", undefined);
31
+ return normalizeAppServerResponse(
32
+ assertObject(result, "account/rateLimits/read result") as AppServerRateLimitResponse,
33
+ Date.now(),
34
+ );
35
+ } finally {
36
+ client.dispose();
37
+ }
38
+ }
39
+
40
+ class CodexAppServerClient {
41
+ private child?: ChildProcessWithoutNullStreams;
42
+ private nextId = 1;
43
+ private stderr = "";
44
+ private readonly pending = new Map<number, PendingRpc>();
45
+ private startPromise?: Promise<void>;
46
+ private exitError?: Error;
47
+ private readonly timeoutMs: number;
48
+
49
+ constructor(timeoutMs: number) {
50
+ this.timeoutMs = timeoutMs;
51
+ }
52
+
53
+ start(): Promise<void> {
54
+ if (this.startPromise) return this.startPromise;
55
+
56
+ this.startPromise = new Promise((resolve, reject) => {
57
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
58
+ stdio: ["pipe", "pipe", "pipe"],
59
+ });
60
+ this.child = child;
61
+
62
+ const startupTimeout = setTimeout(() => {
63
+ reject(
64
+ new Error(
65
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
66
+ ),
67
+ );
68
+ }, this.timeoutMs);
69
+
70
+ child.once("spawn", () => {
71
+ clearTimeout(startupTimeout);
72
+ resolve();
73
+ });
74
+
75
+ child.once("error", (error) => {
76
+ clearTimeout(startupTimeout);
77
+ reject(new Error(`Failed to start codex app-server: ${error.message}`));
78
+ this.rejectAll(error);
79
+ });
80
+
81
+ child.once("exit", (code, signal) => {
82
+ const suffix = this.stderr ? ` stderr: ${redactErrorBody(this.stderr)}` : "";
83
+ this.exitError = new Error(
84
+ `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
85
+ );
86
+ this.rejectAll(this.exitError);
87
+ });
88
+
89
+ child.stderr.setEncoding("utf8");
90
+ child.stderr.on("data", (chunk: string) => {
91
+ this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
92
+ });
93
+
94
+ const lines = createInterface({ input: child.stdout });
95
+ lines.on("line", (line) => this.handleLine(line));
96
+ });
97
+
98
+ return this.startPromise;
99
+ }
100
+
101
+ request(method: string, params: unknown): Promise<unknown> {
102
+ const child = this.child;
103
+ if (!child?.stdin.writable) {
104
+ throw new Error("codex app-server is not running.");
105
+ }
106
+ if (this.exitError) throw this.exitError;
107
+
108
+ const id = this.nextId++;
109
+ const payload = params === undefined ? { method, id } : { method, id, params };
110
+ const response = new Promise<unknown>((resolve, reject) => {
111
+ const timeout = setTimeout(() => {
112
+ this.pending.delete(id);
113
+ reject(
114
+ new Error(`Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`),
115
+ );
116
+ }, this.timeoutMs);
117
+
118
+ this.pending.set(id, {
119
+ resolve: (value) => {
120
+ clearTimeout(timeout);
121
+ resolve(value);
122
+ },
123
+ reject: (error) => {
124
+ clearTimeout(timeout);
125
+ reject(error);
126
+ },
127
+ });
128
+ });
129
+
130
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
131
+ return response;
132
+ }
133
+
134
+ notify(method: string): void {
135
+ const child = this.child;
136
+ if (!child?.stdin.writable) return;
137
+ child.stdin.write(`${JSON.stringify({ method })}\n`);
138
+ }
139
+
140
+ dispose(): void {
141
+ for (const [id, pending] of this.pending) {
142
+ pending.reject(new Error(`codex app-server request ${id} cancelled.`));
143
+ }
144
+ this.pending.clear();
145
+
146
+ const child = this.child;
147
+ if (!child) return;
148
+ child.stdin.end();
149
+ if (!child.killed) child.kill();
150
+ this.child = undefined;
151
+ }
152
+
153
+ private handleLine(line: string): void {
154
+ let parsed: RpcResponse;
155
+ try {
156
+ parsed = JSON.parse(line) as RpcResponse;
157
+ } catch {
158
+ return;
159
+ }
160
+
161
+ if (typeof parsed.id !== "number") return;
162
+ const pending = this.pending.get(parsed.id);
163
+ if (!pending) return;
164
+ this.pending.delete(parsed.id);
165
+
166
+ if (parsed.error) {
167
+ const message =
168
+ typeof parsed.error.message === "string" ? parsed.error.message : "unknown error";
169
+ pending.reject(new Error(`codex app-server request failed: ${message}`));
170
+ return;
171
+ }
172
+
173
+ pending.resolve(parsed.result);
174
+ }
175
+
176
+ private rejectAll(error: Error): void {
177
+ for (const pending of this.pending.values()) pending.reject(error);
178
+ this.pending.clear();
179
+ }
180
+ }
181
+
182
+ function parseJsonObject(text: string, description: string): Record<string, unknown> {
183
+ let parsed: unknown;
184
+ try {
185
+ parsed = JSON.parse(text) as unknown;
186
+ } catch (error) {
187
+ throw new Error(`${description} was not valid JSON: ${errorMessage(error)}`);
188
+ }
189
+ return assertObject(parsed, description);
190
+ }
191
+
192
+ function assertObject(value: unknown, description: string): Record<string, unknown> {
193
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
194
+ throw new Error(`${description} was not an object.`);
195
+ }
196
+ return value as Record<string, unknown>;
197
+ }
198
+
199
+ function redactErrorBody(body: string): string {
200
+ return truncateEnd(
201
+ body
202
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
203
+ .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
204
+ .trim(),
205
+ MAX_ERROR_BODY_CHARS,
206
+ );
207
+ }
208
+
209
+ function truncateEnd(value: string, maxChars: number): string {
210
+ if (value.length <= maxChars) return value;
211
+ return `${value.slice(0, maxChars - 1)}…`;
212
+ }
213
+
214
+ function errorMessage(error: unknown): string {
215
+ return error instanceof Error ? error.message : String(error);
216
+ }