@bitkyc08/opencodex 2.6.17 → 2.6.18
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 +9 -0
- package/bin/ocx.mjs +70 -5
- package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
- package/gui/dist/assets/index-DbTEyo46.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +9 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +88 -0
- package/src/adapters/cursor/cursor-errors.ts +85 -0
- package/src/adapters/cursor/discovery.ts +144 -0
- package/src/adapters/cursor/effort-map.ts +74 -0
- package/src/adapters/cursor/exec-policy.ts +44 -0
- package/src/adapters/cursor/framing.ts +136 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +25 -0
- package/src/adapters/cursor/live-models.ts +93 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +758 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +236 -0
- package/src/adapters/cursor/message-mapper.ts +46 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +177 -0
- package/src/adapters/cursor/native-exec-fs.ts +284 -0
- package/src/adapters/cursor/native-exec-mcp.ts +151 -0
- package/src/adapters/cursor/native-exec-network.ts +32 -0
- package/src/adapters/cursor/native-exec-shell.ts +191 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +177 -0
- package/src/adapters/cursor/protobuf-events.ts +309 -0
- package/src/adapters/cursor/protobuf-request.ts +347 -0
- package/src/adapters/cursor/request-builder.ts +98 -0
- package/src/adapters/cursor/tool-definitions.ts +301 -0
- package/src/adapters/cursor/transport-retry.ts +116 -0
- package/src/adapters/cursor/transport.ts +47 -0
- package/src/adapters/cursor/types.ts +36 -0
- package/src/adapters/cursor.ts +99 -0
- package/src/adapters/google.ts +7 -1
- package/src/adapters/kiro.ts +15 -0
- package/src/adapters/openai-chat.ts +7 -2
- package/src/adapters/run-turn-queue.ts +58 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/bridge.ts +7 -1
- package/src/cli-help.ts +9 -2
- package/src/cli-status.ts +7 -5
- package/src/cli.ts +122 -79
- package/src/codex-catalog.ts +213 -71
- package/src/codex-history-provider.ts +31 -14
- package/src/codex-inject.ts +17 -9
- package/src/codex-paths.ts +2 -1
- package/src/codex-shim.ts +30 -7
- package/src/codex-sync.ts +70 -0
- package/src/config.ts +58 -2
- package/src/doctor.ts +4 -2
- package/src/index.ts +1 -0
- package/src/model-cache.ts +22 -2
- package/src/oauth/callback-server.ts +44 -16
- package/src/oauth/cursor.ts +188 -0
- package/src/oauth/index.ts +29 -3
- package/src/oauth/key-providers.ts +20 -33
- package/src/oauth/login-cli.ts +7 -4
- package/src/open-url.ts +5 -1
- package/src/ports.ts +13 -0
- package/src/process-control.ts +76 -0
- package/src/provider-label.ts +10 -5
- package/src/providers/derive.ts +30 -3
- package/src/providers/registry.ts +39 -1
- package/src/proxy-liveness.ts +122 -0
- package/src/responses/parser.ts +1 -0
- package/src/responses/state.ts +83 -0
- package/src/router.ts +38 -23
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server.ts +130 -18
- package/src/service.ts +94 -32
- package/src/types.ts +24 -1
- package/src/update-job.ts +360 -0
- package/src/update.ts +73 -11
- package/src/usage-log.ts +3 -3
- package/src/usage-summary.ts +3 -2
- package/src/win-paths.ts +68 -0
- package/gui/dist/assets/index-DIBiVVC0.css +0 -1
- package/gui/dist/assets/index-DcnD944i.js +0 -9
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { atomicWriteFile, getConfigDir, readPid } from "./config";
|
|
6
|
+
import { killProxy } from "./process-control";
|
|
7
|
+
import { isServiceInstalled } from "./service";
|
|
8
|
+
import {
|
|
9
|
+
type Channel,
|
|
10
|
+
type Installer,
|
|
11
|
+
currentVersion,
|
|
12
|
+
defaultUpdateTag,
|
|
13
|
+
detectInstall,
|
|
14
|
+
latestVersion,
|
|
15
|
+
updateCommand,
|
|
16
|
+
updateCommandStr,
|
|
17
|
+
} from "./update";
|
|
18
|
+
import { isNewer } from "./update-notify";
|
|
19
|
+
|
|
20
|
+
const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
|
|
21
|
+
const UPDATE_JOB_FILENAME = "update-job.json";
|
|
22
|
+
const UPDATE_TIMEOUT_MS = 180_000;
|
|
23
|
+
const RESTART_TIMEOUT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
|
|
26
|
+
|
|
27
|
+
export interface UpdateCheckResult {
|
|
28
|
+
currentVersion: string;
|
|
29
|
+
latestVersion: string | null;
|
|
30
|
+
channel: Channel;
|
|
31
|
+
installer: Installer;
|
|
32
|
+
updateAvailable: boolean;
|
|
33
|
+
canUpdate: boolean;
|
|
34
|
+
command: string;
|
|
35
|
+
releaseNotesUrl: string;
|
|
36
|
+
reason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UpdateJobState {
|
|
40
|
+
id: string;
|
|
41
|
+
status: UpdateJobStatus;
|
|
42
|
+
startedAt: string;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
currentVersion: string;
|
|
45
|
+
latestVersion: string | null;
|
|
46
|
+
channel: Channel;
|
|
47
|
+
installer: Installer;
|
|
48
|
+
restart: boolean;
|
|
49
|
+
command: string;
|
|
50
|
+
releaseNotesUrl: string;
|
|
51
|
+
log: string[];
|
|
52
|
+
pid?: number;
|
|
53
|
+
error?: string;
|
|
54
|
+
exitCode?: number | null;
|
|
55
|
+
signal?: string | null;
|
|
56
|
+
restarted?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class UpdateJobError extends Error {
|
|
60
|
+
constructor(message: string, readonly status = 400, readonly code = "update_error") {
|
|
61
|
+
super(message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UpdateCheckDeps {
|
|
66
|
+
currentVersion: () => string;
|
|
67
|
+
detectInstall: () => Installer;
|
|
68
|
+
latestVersion: (tag: Channel) => string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const defaultCheckDeps: UpdateCheckDeps = {
|
|
72
|
+
currentVersion,
|
|
73
|
+
detectInstall,
|
|
74
|
+
latestVersion,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function nodeBin(): string {
|
|
78
|
+
return process.platform === "win32" ? "node.exe" : "node";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ocxBin(): string {
|
|
82
|
+
return process.platform === "win32" ? "ocx.cmd" : "ocx";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function packageLauncherPath(): string {
|
|
86
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "ocx.mjs");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatCommand(bin: string, args: string[]): string {
|
|
90
|
+
return `${bin} ${args.join(" ")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function manualSourceCommand(): string {
|
|
94
|
+
return "git pull && bun install && bun run build:gui";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeUpdateChannel(raw: string | null | undefined, current = currentVersion()): Channel {
|
|
98
|
+
return raw === "latest" || raw === "preview" ? raw : defaultUpdateTag(current);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function updateJobPath(): string {
|
|
102
|
+
return join(getConfigDir(), UPDATE_JOB_FILENAME);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ensureJobDir(): void {
|
|
106
|
+
const dir = getConfigDir();
|
|
107
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeJob(job: UpdateJobState): void {
|
|
111
|
+
ensureJobDir();
|
|
112
|
+
atomicWriteFile(updateJobPath(), `${JSON.stringify(job, null, 2)}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
|
|
118
|
+
if (jobId && parsed.id !== jobId) return null;
|
|
119
|
+
if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
|
|
120
|
+
return parsed;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function updateJob(job: UpdateJobState, patch: Partial<UpdateJobState>, logLine?: string): UpdateJobState {
|
|
127
|
+
const current = readUpdateJob(job.id) ?? job;
|
|
128
|
+
const next = {
|
|
129
|
+
...current,
|
|
130
|
+
...patch,
|
|
131
|
+
updatedAt: new Date().toISOString(),
|
|
132
|
+
log: logLine ? [...current.log, logLine] : current.log,
|
|
133
|
+
};
|
|
134
|
+
writeJob(next);
|
|
135
|
+
return next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function updateExecutionCommand(
|
|
139
|
+
installer: Installer,
|
|
140
|
+
channel: Channel,
|
|
141
|
+
launcher = packageLauncherPath(),
|
|
142
|
+
): { bin: string; args: string[]; display: string } {
|
|
143
|
+
if (installer === "npm") {
|
|
144
|
+
const bin = nodeBin();
|
|
145
|
+
const args = [launcher, "update", "--tag", channel];
|
|
146
|
+
return { bin, args, display: formatCommand(bin, args) };
|
|
147
|
+
}
|
|
148
|
+
if (installer === "bun") {
|
|
149
|
+
const { bin, args } = updateCommand(installer, channel);
|
|
150
|
+
return { bin, args, display: updateCommandStr(installer, channel) };
|
|
151
|
+
}
|
|
152
|
+
return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function restartCommand(
|
|
156
|
+
serviceInstalled: boolean,
|
|
157
|
+
installer: Installer,
|
|
158
|
+
launcher = packageLauncherPath(),
|
|
159
|
+
): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } {
|
|
160
|
+
const mode = serviceInstalled ? "service" : "proxy";
|
|
161
|
+
if (installer === "npm") {
|
|
162
|
+
const bin = nodeBin();
|
|
163
|
+
const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"];
|
|
164
|
+
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
165
|
+
}
|
|
166
|
+
const bin = ocxBin();
|
|
167
|
+
const args = serviceInstalled ? ["service", "install"] : ["start"];
|
|
168
|
+
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function checkForUpdate(
|
|
172
|
+
requestedChannel?: Channel,
|
|
173
|
+
deps: UpdateCheckDeps = defaultCheckDeps,
|
|
174
|
+
): UpdateCheckResult {
|
|
175
|
+
const current = deps.currentVersion();
|
|
176
|
+
const installer = deps.detectInstall();
|
|
177
|
+
const channel = requestedChannel ?? normalizeUpdateChannel(null, current);
|
|
178
|
+
const latest = installer === "source" ? null : deps.latestVersion(channel);
|
|
179
|
+
const updateAvailable = !!latest && isNewer(latest, current, channel);
|
|
180
|
+
let reason: string | undefined;
|
|
181
|
+
let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display;
|
|
182
|
+
|
|
183
|
+
if (installer === "source") {
|
|
184
|
+
reason = "source_checkout";
|
|
185
|
+
command = manualSourceCommand();
|
|
186
|
+
} else if (!latest) {
|
|
187
|
+
reason = "latest_unavailable";
|
|
188
|
+
} else if (!updateAvailable) {
|
|
189
|
+
reason = "already_latest";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
currentVersion: current,
|
|
194
|
+
latestVersion: latest,
|
|
195
|
+
channel,
|
|
196
|
+
installer,
|
|
197
|
+
updateAvailable,
|
|
198
|
+
canUpdate: installer !== "source" && updateAvailable,
|
|
199
|
+
command,
|
|
200
|
+
releaseNotesUrl: RELEASE_NOTES_URL,
|
|
201
|
+
...(reason ? { reason } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function newJobId(): string {
|
|
206
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function startUpdateJob(channel: Channel, restart: boolean): UpdateJobState {
|
|
210
|
+
const running = readUpdateJob();
|
|
211
|
+
if (running?.status === "running" || running?.status === "restarting") {
|
|
212
|
+
throw new UpdateJobError("An update job is already running", 409, "update_already_running");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const check = checkForUpdate(channel);
|
|
216
|
+
if (!check.canUpdate) {
|
|
217
|
+
throw new UpdateJobError(check.reason ?? "No update is available", 409, check.reason ?? "update_unavailable");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const id = newJobId();
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
const job: UpdateJobState = {
|
|
223
|
+
id,
|
|
224
|
+
status: "running",
|
|
225
|
+
startedAt: now,
|
|
226
|
+
updatedAt: now,
|
|
227
|
+
currentVersion: check.currentVersion,
|
|
228
|
+
latestVersion: check.latestVersion,
|
|
229
|
+
channel: check.channel,
|
|
230
|
+
installer: check.installer,
|
|
231
|
+
restart,
|
|
232
|
+
command: check.command,
|
|
233
|
+
releaseNotesUrl: check.releaseNotesUrl,
|
|
234
|
+
log: [`Update job queued for ${check.currentVersion} -> ${check.latestVersion}.`],
|
|
235
|
+
};
|
|
236
|
+
writeJob(job);
|
|
237
|
+
|
|
238
|
+
const child = spawn(process.execPath, [process.argv[1], "__gui-update-worker", id, channel, restart ? "restart" : "no-restart"], {
|
|
239
|
+
detached: true,
|
|
240
|
+
stdio: "ignore",
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
env: { ...process.env, OCX_SERVICE: "1" },
|
|
243
|
+
});
|
|
244
|
+
child.unref();
|
|
245
|
+
return { ...job, pid: child.pid };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
|
|
249
|
+
job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`);
|
|
250
|
+
const result = spawnSync(bin, args, {
|
|
251
|
+
encoding: "utf8",
|
|
252
|
+
timeout,
|
|
253
|
+
windowsHide: true,
|
|
254
|
+
});
|
|
255
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
256
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
257
|
+
if (stdout) job = updateJob(job, {}, stdout.slice(-4000));
|
|
258
|
+
if (stderr) updateJob(job, {}, stderr.slice(-4000));
|
|
259
|
+
return { status: result.status, signal: result.signal };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function spawnDetachedStart(job: UpdateJobState, installer: Installer): void {
|
|
263
|
+
const cmd = restartCommand(false, installer);
|
|
264
|
+
const env = { ...process.env };
|
|
265
|
+
delete env.OCX_SERVICE;
|
|
266
|
+
updateJob(job, {}, `$ ${cmd.display}`);
|
|
267
|
+
const child = spawn(cmd.bin, cmd.args, {
|
|
268
|
+
detached: true,
|
|
269
|
+
stdio: "ignore",
|
|
270
|
+
windowsHide: true,
|
|
271
|
+
env,
|
|
272
|
+
});
|
|
273
|
+
child.unref();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function restartAfterUpdate(job: UpdateJobState): void {
|
|
277
|
+
const serviceInstalled = isServiceInstalled();
|
|
278
|
+
const cmd = restartCommand(serviceInstalled, job.installer);
|
|
279
|
+
if (serviceInstalled) {
|
|
280
|
+
const result = runLoggedCommand(job, cmd.bin, cmd.args, RESTART_TIMEOUT_MS);
|
|
281
|
+
if (result.status !== 0) {
|
|
282
|
+
throw new Error(`service restart failed (${cmd.display}, exit ${result.status ?? "?"})`);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pid = readPid();
|
|
288
|
+
if (pid) {
|
|
289
|
+
updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
|
|
290
|
+
killProxy(pid);
|
|
291
|
+
}
|
|
292
|
+
spawnDetachedStart(job, job.installer);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): void {
|
|
296
|
+
let job = readUpdateJob(jobId);
|
|
297
|
+
const check = checkForUpdate(channel);
|
|
298
|
+
const now = new Date().toISOString();
|
|
299
|
+
if (!job) {
|
|
300
|
+
job = {
|
|
301
|
+
id: jobId,
|
|
302
|
+
status: "running",
|
|
303
|
+
startedAt: now,
|
|
304
|
+
updatedAt: now,
|
|
305
|
+
currentVersion: check.currentVersion,
|
|
306
|
+
latestVersion: check.latestVersion,
|
|
307
|
+
channel: check.channel,
|
|
308
|
+
installer: check.installer,
|
|
309
|
+
restart,
|
|
310
|
+
command: check.command,
|
|
311
|
+
releaseNotesUrl: check.releaseNotesUrl,
|
|
312
|
+
log: [],
|
|
313
|
+
};
|
|
314
|
+
writeJob(job);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
if (!check.canUpdate) {
|
|
319
|
+
throw new Error(check.reason ?? "No update is available");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const cmd = updateExecutionCommand(check.installer, channel);
|
|
323
|
+
job = updateJob(job, {
|
|
324
|
+
currentVersion: check.currentVersion,
|
|
325
|
+
latestVersion: check.latestVersion,
|
|
326
|
+
installer: check.installer,
|
|
327
|
+
command: cmd.display,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/* [Decision Log]
|
|
331
|
+
- 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다.
|
|
332
|
+
- 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능.
|
|
333
|
+
- 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
|
|
334
|
+
*/
|
|
335
|
+
const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
|
|
336
|
+
if (result.status !== 0) {
|
|
337
|
+
updateJob(job, {
|
|
338
|
+
status: "failed",
|
|
339
|
+
exitCode: result.status,
|
|
340
|
+
signal: result.signal,
|
|
341
|
+
error: `update command failed (${result.status ?? "?"})`,
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (restart) {
|
|
347
|
+
job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
|
|
348
|
+
restartAfterUpdate(job);
|
|
349
|
+
updateJob(job, { status: "succeeded", restarted: true }, "Restart requested.");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version.");
|
|
354
|
+
} catch (err) {
|
|
355
|
+
updateJob(job, {
|
|
356
|
+
status: "failed",
|
|
357
|
+
error: err instanceof Error ? err.message : String(err),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
package/src/update.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { getConfigDir, readPid, readRuntimePort } from "./config";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A `codex-history-backup-*.json` surviving a stop means the native-history restore was
|
|
9
|
+
* skipped (locked state DB) — routed threads stay hidden in the Codex app until a retry.
|
|
10
|
+
*/
|
|
11
|
+
export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
|
|
12
|
+
try {
|
|
13
|
+
return readdirSync(configDir).some(
|
|
14
|
+
name => name.startsWith("codex-history-backup-") && name.endsWith(".json"),
|
|
15
|
+
);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
export const PKG = "@bitkyc08/opencodex";
|
|
7
22
|
const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src
|
|
@@ -23,16 +38,30 @@ export function currentVersion(): string {
|
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
|
|
41
|
+
export function defaultUpdateTag(current: string): Channel {
|
|
42
|
+
return current.includes("-preview.") ? "preview" : "latest";
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
export function updateTag(current: string): Channel {
|
|
27
46
|
const tagIndex = process.argv.indexOf("--tag");
|
|
28
47
|
const explicit = tagIndex !== -1 ? process.argv[tagIndex + 1] : undefined;
|
|
29
48
|
if (explicit === "preview" || explicit === "latest") return explicit;
|
|
30
|
-
return current
|
|
49
|
+
return defaultUpdateTag(current);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* npm is `npm.cmd` on Windows, and Node/Bun refuse shell-less .cmd spawns
|
|
54
|
+
* (CVE-2024-27980 hardening) — route Windows npm invocations through the shell.
|
|
55
|
+
*/
|
|
56
|
+
function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
|
|
57
|
+
if (process.platform !== "win32" || bin !== "npm") return { bin, shell: false };
|
|
58
|
+
return { bin: "npm.cmd", shell: true };
|
|
31
59
|
}
|
|
32
60
|
|
|
33
61
|
/** Latest published version from the registry (best-effort; null if npm isn't available). */
|
|
34
62
|
export function latestVersion(tag: string): string | null {
|
|
35
|
-
const
|
|
63
|
+
const npm = npmSpawnTarget("npm");
|
|
64
|
+
const r = spawnSync(npm.bin, ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell });
|
|
36
65
|
return r.status === 0 ? r.stdout.trim() : null;
|
|
37
66
|
}
|
|
38
67
|
|
|
@@ -72,10 +101,40 @@ export async function runUpdate(): Promise<void> {
|
|
|
72
101
|
return;
|
|
73
102
|
}
|
|
74
103
|
|
|
104
|
+
// Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
|
|
105
|
+
// unloads it permanently, so a successful update must reinstall/restart it afterwards.
|
|
106
|
+
let serviceWasInstalled = false;
|
|
107
|
+
try {
|
|
108
|
+
const { isServiceInstalled } = await import("./service");
|
|
109
|
+
serviceWasInstalled = isServiceInstalled();
|
|
110
|
+
} catch { /* best-effort */ }
|
|
111
|
+
|
|
112
|
+
// Never replace package files under a live proxy: the running server dynamic-imports
|
|
113
|
+
// modules after startup, so an in-place update leaves it executing mixed old/new code.
|
|
114
|
+
// Gate on the service and the runtime-port record too, not just the pid file — a
|
|
115
|
+
// service-managed or orphaned proxy can be live while ocx.pid is stale/missing.
|
|
116
|
+
// Full `ocx stop` semantics (drain, service stop, restore).
|
|
117
|
+
if (serviceWasInstalled || readPid() || readRuntimePort()) {
|
|
118
|
+
console.log("⏹ Stopping the running proxy before updating...");
|
|
119
|
+
const stop = spawnSync(process.execPath, [process.argv[1], "stop"], { stdio: "inherit", windowsHide: true });
|
|
120
|
+
if (stop.status !== 0 || readPid() || readRuntimePort()) {
|
|
121
|
+
console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
if (historyRestoreIncomplete()) {
|
|
125
|
+
console.warn(
|
|
126
|
+
"⚠️ Codex resume history was NOT restored (history DB locked — Codex app/IDE open?).\n" +
|
|
127
|
+
" Your routed threads stay hidden in the native Codex app until restored.\n" +
|
|
128
|
+
" After the update: close the Codex app, then run 'ocx stop' once to restore.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
75
133
|
const { bin, args: cmdArgs } = updateCommand(installer, tag);
|
|
76
134
|
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
|
|
77
135
|
|
|
78
|
-
const
|
|
136
|
+
const target = npmSpawnTarget(bin);
|
|
137
|
+
const r = spawnSync(target.bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true, shell: target.shell });
|
|
79
138
|
if (r.status === 0) {
|
|
80
139
|
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
|
|
81
140
|
// Re-bake the bundled Bun path into the Codex autostart shim on every
|
|
@@ -89,13 +148,16 @@ export async function runUpdate(): Promise<void> {
|
|
|
89
148
|
} catch (e) {
|
|
90
149
|
console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
|
|
91
150
|
}
|
|
92
|
-
// The
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
151
|
+
// The stop above unloaded any managed service; reinstall it with the NEW files
|
|
152
|
+
// (spawn the fresh cli.ts so updated code writes the baked paths) so a
|
|
153
|
+
// launchd/schtasks/systemd user isn't left with the background proxy down.
|
|
154
|
+
if (serviceWasInstalled) {
|
|
155
|
+
console.log("🔁 Reinstalling the background service with the updated files...");
|
|
156
|
+
const svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], { stdio: "inherit", windowsHide: true });
|
|
157
|
+
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
|
|
158
|
+
} else {
|
|
159
|
+
console.log("Restart the proxy: ocx start");
|
|
160
|
+
}
|
|
99
161
|
} else {
|
|
100
162
|
console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
|
|
101
163
|
process.exit(1);
|
package/src/usage-log.ts
CHANGED
|
@@ -27,13 +27,13 @@ export function usageTotalTokens(usage: OcxUsage | undefined): number | undefine
|
|
|
27
27
|
return usageDisplayTotalTokens(usage);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function
|
|
31
|
-
return provider === "kiro" || provider.startsWith("kiro-");
|
|
30
|
+
function isEstimatedUsageProvider(provider: string): boolean {
|
|
31
|
+
return provider === "kiro" || provider.startsWith("kiro-") || provider === "cursor";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function usageForFinalLog(provider: string, usage: OcxUsage | undefined): OcxUsage | undefined {
|
|
35
35
|
if (!usage) return undefined;
|
|
36
|
-
if (usage.estimated ||
|
|
36
|
+
if (usage.estimated || isEstimatedUsageProvider(provider)) return { ...usage, estimated: true };
|
|
37
37
|
return usage;
|
|
38
38
|
}
|
|
39
39
|
|
package/src/usage-summary.ts
CHANGED
|
@@ -150,9 +150,10 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
|
|
|
150
150
|
const bumpDayModel = (dayKey: string, entry: PersistedUsageEntry): void => {
|
|
151
151
|
let models = dayModels.get(dayKey);
|
|
152
152
|
if (!models) { models = new Map(); dayModels.set(dayKey, models); }
|
|
153
|
-
const
|
|
153
|
+
const providerKey = baseProviderLabel(entry.provider);
|
|
154
|
+
const mKey = `${providerKey}/${entry.model}`;
|
|
154
155
|
let m = models.get(mKey);
|
|
155
|
-
if (!m) { m = { model: entry.model, provider:
|
|
156
|
+
if (!m) { m = { model: entry.model, provider: providerKey, requests: 0, totalTokens: 0 }; models.set(mKey, m); }
|
|
156
157
|
m.requests += 1;
|
|
157
158
|
m.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0;
|
|
158
159
|
};
|
package/src/win-paths.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows path → batch-safe env indirection.
|
|
3
|
+
*
|
|
4
|
+
* cmd.exe parses .cmd files in the console's OEM codepage (CP949/GBK/CP437…), NOT UTF-8.
|
|
5
|
+
* Any absolute path we bake into a generated batch file breaks for users whose profile
|
|
6
|
+
* directory contains non-ASCII characters (e.g. Korean/Chinese usernames), because the
|
|
7
|
+
* UTF-8 bytes we wrote are decoded as mojibake at parse time. Instead of baking the
|
|
8
|
+
* profile prefix literally, rewrite it as a `%USERPROFILE%`-style token: cmd expands the
|
|
9
|
+
* variable natively at parse time in the correct codepage, so the non-ASCII prefix never
|
|
10
|
+
* has to survive a file-encoding round trip.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type WindowsEnvMap = Record<string, string | undefined>;
|
|
14
|
+
|
|
15
|
+
/** Ordered by specificity; longest resolved prefix wins regardless of this order. */
|
|
16
|
+
const INDIRECTION_VARS = ["LOCALAPPDATA", "APPDATA", "USERPROFILE"] as const;
|
|
17
|
+
|
|
18
|
+
/** True when `prefix` matches a leading whole-component span of `path` (case-insensitive). */
|
|
19
|
+
function isComponentPrefix(path: string, prefix: string): boolean {
|
|
20
|
+
if (path.length < prefix.length) return false;
|
|
21
|
+
if (path.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return false;
|
|
22
|
+
if (path.length === prefix.length) return true;
|
|
23
|
+
const next = path[prefix.length];
|
|
24
|
+
return next === "\\" || next === "/";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split a Windows path into an env-token prefix and the remaining literal suffix.
|
|
29
|
+
* Returns `token: ""` (and the full path as `rest`) when no known prefix applies.
|
|
30
|
+
*/
|
|
31
|
+
export function splitWindowsEnvPrefix(path: string, env: WindowsEnvMap = process.env): { token: string; rest: string } {
|
|
32
|
+
let best: { name: string; value: string } | null = null;
|
|
33
|
+
for (const name of INDIRECTION_VARS) {
|
|
34
|
+
const raw = env[name]?.trim();
|
|
35
|
+
if (!raw) continue;
|
|
36
|
+
const value = raw.replace(/[\\/]+$/, "");
|
|
37
|
+
if (!value || !isComponentPrefix(path, value)) continue;
|
|
38
|
+
if (!best || value.length > best.value.length) best = { name, value };
|
|
39
|
+
}
|
|
40
|
+
if (!best) return { token: "", rest: path };
|
|
41
|
+
return { token: `%${best.name}%`, rest: path.slice(best.value.length) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render a single path for embedding in a batch `set "X=…"` line: env-token prefix kept
|
|
46
|
+
* verbatim (cmd must expand it), remaining literal suffix run through the caller's batch
|
|
47
|
+
* escaping (which doubles `%` and would otherwise destroy the token itself).
|
|
48
|
+
*/
|
|
49
|
+
export function windowsEnvIndirectBatchValue(
|
|
50
|
+
path: string,
|
|
51
|
+
escape: (value: string) => string,
|
|
52
|
+
env: WindowsEnvMap = process.env,
|
|
53
|
+
): string {
|
|
54
|
+
const { token, rest } = splitWindowsEnvPrefix(path, env);
|
|
55
|
+
return token + escape(rest);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Same as {@link windowsEnvIndirectBatchValue} for `;`-separated path lists (PATH). */
|
|
59
|
+
export function windowsEnvIndirectBatchPathList(
|
|
60
|
+
value: string,
|
|
61
|
+
escape: (value: string) => string,
|
|
62
|
+
env: WindowsEnvMap = process.env,
|
|
63
|
+
): string {
|
|
64
|
+
return value
|
|
65
|
+
.split(";")
|
|
66
|
+
.map(part => (part ? windowsEnvIndirectBatchValue(part, escape, env) : part))
|
|
67
|
+
.join(";");
|
|
68
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--bg:var(--lightningcss-light,#f6f7f9)var(--lightningcss-dark,#0c0d11);--rail:var(--lightningcss-light,#fff)var(--lightningcss-dark,#101218);--surface:var(--lightningcss-light,#fff)var(--lightningcss-dark,#15171d);--raised:var(--lightningcss-light,#f1f2f5)var(--lightningcss-dark,#1c1f27);--raised-hover:var(--lightningcss-light,#e8eaee)var(--lightningcss-dark,#242833);--border:var(--lightningcss-light,#e2e4e9)var(--lightningcss-dark,#2a2e39);--border-soft:var(--lightningcss-light,#ededf1)var(--lightningcss-dark,#20242d);--hover:var(--lightningcss-light,#11131c09)var(--lightningcss-dark,#ffffff06);--text:var(--lightningcss-light,#16181d)var(--lightningcss-dark,#edeef2);--muted:var(--lightningcss-light,#5b6270)var(--lightningcss-dark,#a3a9b5);--faint:var(--lightningcss-light,#868d9b)var(--lightningcss-dark,#6b7280);--accent:var(--lightningcss-light,#4f46e5)var(--lightningcss-dark,#6366f1);--accent-hover:var(--lightningcss-light,#4338ca)var(--lightningcss-dark,#818cf8);--accent-ink:#fff;--accent-soft:var(--lightningcss-light,#4f46e51a)var(--lightningcss-dark,#6366f129);--accent-ring:var(--lightningcss-light,#4f46e566)var(--lightningcss-dark,#6366f180);--green:var(--lightningcss-light,#047857)var(--lightningcss-dark,#34d399);--green-soft:var(--lightningcss-light,#0596691a)var(--lightningcss-dark,#34d39921);--red:var(--lightningcss-light,#b91c1c)var(--lightningcss-dark,#f87171);--red-soft:var(--lightningcss-light,#b91c1c17)var(--lightningcss-dark,#f8717121);--amber:var(--lightningcss-light,#b45309)var(--lightningcss-dark,#fbbf24);--amber-soft:var(--lightningcss-light,#b453091a)var(--lightningcss-dark,#fbbf2421);--radius:8px;--radius-sm:5px;--radius-xs:4px;--font:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Helvetica Neue", sans-serif;--mono:ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;--shadow:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#00000080), 0 10px 28px var(--lightningcss-light,#10182812)var(--lightningcss-dark,#0000004d);--shadow-sm:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#0006)}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--bg);color:var(--text);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font-size:14px;line-height:1.5}a{color:var(--accent-hover);text-decoration:none}a:hover{text-decoration:underline}code,.mono{font-family:var(--mono);font-size:.92em}h1,h2,h3,h4{letter-spacing:-.01em;margin:0;font-weight:650}::selection{background:var(--accent-soft)}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border:2px solid var(--bg);border-radius:99px}::-webkit-scrollbar-thumb:hover{background:var(--faint)}:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px;border-radius:4px}.app{grid-template-columns:232px 1fr;min-height:100dvh;display:grid}.sidebar{border-right:1px solid var(--border);background:var(--rail);flex-direction:column;align-self:start;gap:4px;height:100dvh;padding:18px 14px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:10px;padding:6px 8px 14px;display:flex}.brand-logo{background:var(--text);flex-shrink:0;width:26px;height:26px;-webkit-mask:url(/logo.png) 50%/contain no-repeat;mask:url(/logo.png) 50%/contain no-repeat}.brand .name{letter-spacing:-.02em;font-size:15px;font-weight:700;line-height:26px}.brand .ver{font-family:var(--mono);color:var(--muted);background:var(--raised);border:1px solid var(--border);border-radius:99px;align-self:center;padding:2px 6px;font-size:10px;line-height:1}.nav-item{border-radius:var(--radius-sm);text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;background:0 0;border:none;align-items:center;gap:10px;padding:8px 10px;font-size:13.5px;font-weight:500;transition:background .12s,color .12s;display:flex}.nav-item:hover{background:var(--raised);color:var(--text)}.nav-item.active{background:var(--accent-soft);color:var(--text)}.nav-item svg{width:17px;height:17px;color:var(--faint);flex-shrink:0}.nav-item.active svg{color:var(--accent)}.sidebar-foot{flex-direction:column;gap:2px;margin-top:auto;padding-top:12px;display:flex}.sidebar-link{color:var(--muted);border-radius:var(--radius-sm);align-items:center;gap:9px;padding:8px 10px;font-size:13px;display:flex}.sidebar-link:hover{background:var(--raised);color:var(--text);text-decoration:none}.sidebar-link svg{width:16px;height:16px}.theme-toggle{text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;border-radius:var(--radius-sm);background:0 0;border:none;align-items:center;gap:9px;padding:8px 10px;font-size:13px;transition:background .12s,color .12s;display:flex}.theme-toggle:hover{background:var(--raised);color:var(--text)}.theme-toggle svg{flex-shrink:0;width:16px;height:16px}.theme-toggle .mode{text-transform:capitalize}.stop-toggle{color:var(--red)}.stop-toggle:hover{background:var(--red-soft);color:var(--red)}.stop-toggle:disabled{opacity:.5;cursor:default}.main{min-width:0}.main-inner{max-width:980px;margin:0 auto;padding:32px 36px 64px}.page-head{justify-content:space-between;align-items:center;gap:16px;margin-bottom:6px;display:flex}.page-head h2{font-size:19px}.page-sub{color:var(--muted);max-width:70ch;margin:4px 0 22px;font-size:13.5px}.page-sub b{color:var(--text);font-weight:600}.btn{border-radius:var(--radius-sm);font:inherit;cursor:pointer;white-space:nowrap;border:1px solid #0000;justify-content:center;align-items:center;gap:7px;padding:7px 14px;font-size:13px;font-weight:550;transition:background .12s,border-color .12s,opacity .12s;display:inline-flex}.btn svg{width:15px;height:15px}.btn:disabled{opacity:.55;cursor:default}.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--raised);color:var(--text);border-color:var(--border)}.btn-ghost:hover:not(:disabled){background:var(--raised-hover)}.btn-danger{color:var(--red);background:0 0;border-color:#f871714d}.btn-danger:hover:not(:disabled){background:var(--red-soft)}.btn-sm{border-radius:var(--radius-xs);padding:4px 9px;font-size:12px}.btn-icon{color:var(--muted);border-radius:var(--radius-xs);padding:5px;transition:background .12s,color .12s}.btn-icon:hover{background:var(--raised-hover);color:var(--text)}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);min-width:0}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px}.panel-accent{background:linear-gradient(180deg, var(--accent-soft), transparent 120%), var(--surface);border-color:#7c5cff47}.stat-row{grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px;display:grid}.stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;transition:border-color .12s}.stat:hover{border-color:var(--accent-ring)}.stat .label{color:var(--muted);text-transform:uppercase;letter-spacing:.05em;align-items:center;gap:6px;margin-bottom:9px;font-size:11px;font-weight:600;display:flex}.stat .label svg{width:14px;height:14px}.stat .value{letter-spacing:-.02em;font-size:24px;font-weight:700;line-height:1.1}.stat .value.mono{font-family:var(--mono);font-size:19px}.model-group-head{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;align-items:baseline;gap:8px;margin:0 0 8px;font-size:12px;font-weight:600;display:flex}.model-group-head .count{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--faint);font-weight:500}.model-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;display:grid}.model-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;transition:border-color .12s,background .12s}.model-card:hover{border-color:var(--accent-ring);background:var(--hover)}.model-card .id{font-family:var(--mono);letter-spacing:-.01em;color:var(--text);font-size:13px;font-weight:600}.badge{font-size:11px;font-weight:600;font-family:var(--mono);letter-spacing:.01em;border:1px solid #0000;border-radius:99px;align-items:center;gap:5px;padding:2px 8px;display:inline-flex}.badge-accent{background:var(--accent-soft);color:var(--accent-hover)}.badge-green{background:var(--green-soft);color:var(--green)}.badge-amber{background:var(--amber-soft);color:var(--amber)}.badge-muted{background:var(--raised);color:var(--muted);border:1px solid var(--border)}.badge-clickable{cursor:pointer;appearance:none;transition:filter .12s}.badge-clickable:hover{filter:brightness(1.1)}.badge-disabled{opacity:.5;cursor:default}.card-badges{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;display:inline-flex}.card-badges .badge{flex-shrink:0}.confirm-icon{background:var(--amber-soft);width:44px;height:44px;color:var(--amber);border-radius:50%;justify-content:center;align-items:center;margin:0 auto 12px;display:flex}.credit-list{flex-direction:column;gap:6px;display:flex}.credit-item{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--raised);padding:8px 10px;transition:border-color .12s}.credit-next{border-color:var(--amber);background:var(--amber-soft)}.credit-item-head{color:var(--text);align-items:center;gap:6px;margin-bottom:3px;font-size:12px;font-weight:600;display:flex}.credit-item-head svg{color:var(--amber);flex-shrink:0}.credit-item-dates{font-size:11px;font-family:var(--mono);color:var(--muted);justify-content:space-between;display:flex}.credit-urgent{color:var(--red);font-weight:600}.dot{border-radius:50%;flex-shrink:0;width:7px;height:7px}.dot-green{background:var(--green);box-shadow:0 0 0 3px var(--green-soft)}.dot-red{background:var(--red);box-shadow:0 0 0 3px var(--red-soft)}.tbl{border-collapse:collapse;width:100%;font-size:13px}.tbl thead th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:9px 12px;font-size:11.5px;font-weight:600}.tbl tbody td{border-bottom:1px solid var(--border-soft);padding:10px 12px}.tbl tbody tr:last-child td{border-bottom:none}.tbl tbody tr:hover td{background:var(--hover)}.tbl .num{text-align:right;font-family:var(--mono)}.tbl-wrap{border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}.input,textarea.input{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);width:100%;color:var(--text);font:inherit;padding:8px 11px;font-size:13px;transition:border-color .12s}.input::placeholder{color:var(--faint)}.input:focus{border-color:var(--accent);outline:none}textarea.input{resize:vertical;font-family:var(--mono);line-height:1.55}.field-label{color:var(--muted);margin-bottom:5px;font-size:12px;font-weight:500;display:block}select.input{appearance:none}.select-sm{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);color:var(--text);font:inherit;cursor:pointer;padding:5px 8px;font-size:13px;transition:border-color .12s}.select-sm:focus{border-color:var(--accent);outline:none}.select-sm:disabled{opacity:.5;cursor:default}.switch{cursor:pointer;background:var(--lightningcss-light,#c5c9d2)var(--lightningcss-dark,#3a3f4b);border:none;border-radius:99px;flex-shrink:0;width:34px;height:19px;padding:0;transition:background .15s;position:relative}.switch.on{background:var(--accent)}.switch:disabled{opacity:.6;cursor:default}.switch .knob{background:#fff;border-radius:50%;width:15px;height:15px;transition:left .15s;position:absolute;top:2px;left:2px;box-shadow:0 1px 2px #1018284d}.switch.on .knob{left:17px}.muted{color:var(--muted)}.faint{color:var(--faint)}.row{align-items:center;gap:10px;display:flex}.spread{justify-content:space-between;align-items:center;gap:12px;display:flex}.stack{flex-direction:column;display:flex}.chip{font-family:var(--mono);background:var(--raised);border:1px solid var(--border);border-radius:var(--radius-xs);color:var(--text);padding:1px 7px;font-size:12px}.empty{text-align:center;border:1px dashed var(--border);border-radius:var(--radius);color:var(--muted);padding:56px 20px}.empty svg{width:30px;height:30px;color:var(--faint);margin-bottom:12px}.empty .title{color:var(--text);margin-bottom:6px;font-weight:600}.notice{border-radius:var(--radius-sm);align-items:center;gap:8px;margin-bottom:14px;padding:9px 12px;font-size:13px;display:flex}.notice svg{flex-shrink:0;width:15px;height:15px}.notice-ok{background:var(--green-soft);color:var(--green)}.notice-err{background:var(--red-soft);color:var(--red)}.h-section{color:var(--text);align-items:center;gap:8px;margin:30px 0 12px;font-size:13px;font-weight:600;display:flex}.h-section .count{color:var(--muted);font-weight:500;font-family:var(--mono);font-size:12px}.spin{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:14px;height:14px;animation:.7s linear infinite spin;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}.modal-overlay{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:50;background:var(--lightningcss-light,#11131c73)var(--lightningcss-dark,#0009);justify-content:center;align-items:flex-start;padding:8vh 16px;display:flex;position:fixed;inset:0}.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:100%;max-width:520px;box-shadow:var(--shadow);max-height:84vh;padding:20px;overflow-y:auto}.modal-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.modal-head h3{font-size:16px}.card-head{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;padding:14px 16px 6px;display:flex}.card-head strong{overflow-wrap:anywhere;min-width:0}.card-sub{color:var(--muted);overflow-wrap:anywhere;min-width:0;padding:0 16px 10px;font-size:12px}.card-active{border-color:var(--accent-ring)}.card-right{color:var(--faint);align-items:center;gap:4px;margin-left:auto;font-size:11px;display:flex}.btn-icon-danger.card-right{appearance:none;color:var(--red);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center}.btn-icon-danger.card-right:hover{background:var(--red-soft);border-color:var(--red-soft);color:var(--red)}.card-row{justify-content:space-between;align-items:center;padding:14px 16px;display:flex}.badge-primary{background:var(--accent-soft);color:var(--accent-hover)}.dot-blue{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.dot-muted{background:var(--muted)}.dot-amber{background:var(--amber);box-shadow:0 0 0 3px var(--amber-soft)}.section-sep{align-items:center;gap:10px;margin:20px 0 12px;display:flex}.section-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;font-size:12px;font-weight:600}.sep-line{background:var(--border);flex:1;height:1px}.quota-compact{gap:4px;padding:2px 16px 12px;display:grid}.quota-row{grid-template-columns:minmax(34px,max-content) 34px minmax(34px,44px) minmax(38px,42px) minmax(58px,1fr) minmax(30px,max-content);align-items:center;gap:8px;min-width:0;display:grid}.quota-label{color:var(--muted);font-size:11px;font-weight:600}.quota-val{font-size:11px;font-family:var(--mono);color:var(--text);text-align:right}.quota-reset-label{text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:11px;overflow:hidden}.quota-reset-day,.quota-reset-time{color:var(--muted);white-space:nowrap;font-size:11px}.quota-reset-time{font-family:var(--mono)}.bar{background:var(--raised);border-radius:3px;min-width:0;height:5px;overflow:hidden}.bar-fill{border-radius:3px;height:100%;transition:width .3s}.bar-green{background:var(--green)}.bar-amber{background:linear-gradient(90deg, var(--green), var(--amber))}.toggle{background:var(--raised);border:1px solid var(--border);cursor:pointer;border-radius:10px;flex-shrink:0;width:36px;height:20px;transition:background .15s;position:relative}.toggle.on{background:var(--accent);border-color:var(--accent)}.toggle-knob{background:#fff;border-radius:50%;width:16px;height:16px;transition:left .15s;position:absolute;top:1px;left:1px}.toggle.on .toggle-knob{left:17px}.notice-warn{border-radius:var(--radius-sm);background:var(--amber-soft);color:var(--amber);align-items:center;gap:6px;margin-bottom:12px;padding:8px 10px;font-size:12px;display:flex}.notice-warn svg{flex-shrink:0;width:14px;height:14px}.modal-desc{color:var(--muted);margin-bottom:14px;font-size:13px}.modal-actions{gap:8px;margin-top:16px;display:flex}.modal-actions .btn{flex:1}.setup-guide{border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:4px;padding:8px 12px;font-size:13px}.setup-guide summary{cursor:pointer;color:var(--accent-hover);font-weight:500}.setup-guide summary:hover{text-decoration:underline}.setup-guide a{color:var(--accent-hover)}.list-row{text-align:left;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--raised);cursor:pointer;width:100%;color:var(--text);font:inherit;justify-content:space-between;align-items:center;gap:10px;padding:11px 13px;transition:background .12s,border-color .12s;display:flex}.list-row:hover{background:var(--raised-hover);border-color:var(--accent-ring)}.list-row .title{font-size:14px;font-weight:600}.list-row .sub{color:var(--muted);margin-top:2px;font-size:12px}.prov-card{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:12px;padding:15px 16px;display:flex}.prov-card-disabled{opacity:.62}.provider-actions{flex-shrink:0;align-items:center;gap:8px;margin-left:auto;display:flex}.link-btn{color:var(--accent-hover);font:inherit;cursor:pointer;background:0 0;border:none;padding:6px 2px;font-size:13px;text-decoration:underline}@media (width<=760px){.app{grid-template-columns:1fr}.sidebar{z-index:20;border-right:none;border-bottom:1px solid var(--border);background:var(--rail);flex-flow:wrap;align-items:center;gap:0;min-width:0;height:auto;padding:0 10px;position:sticky;top:0}.brand{flex:auto;order:1;width:auto;padding:10px 4px}.sidebar-foot{flex-direction:row;flex:none;order:2;gap:4px;margin:0;padding:0}.sidebar-foot .sidebar-link{display:none}.theme-toggle{justify-content:center;min-width:44px;min-height:44px;padding:8px}.theme-toggle .mode{display:none}.sidebar nav{overscroll-behavior-x:contain;border-top:1px solid var(--border-soft);scrollbar-width:none;flex-direction:row;flex:100%;order:3;gap:2px;min-width:0;margin:0;padding:4px 0 8px;display:flex;overflow-x:auto}.sidebar nav::-webkit-scrollbar{display:none}.nav-item{white-space:nowrap;width:auto;min-height:44px;padding:9px 14px;font-size:14px}.main-inner{padding:22px 18px 48px}.stat-row{grid-template-columns:repeat(2,minmax(0,1fr))}.tbl{min-width:460px}.usage-cards{grid-template-columns:repeat(2,minmax(0,1fr))}}.usage-cards{grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-top:8px;display:grid}.usage-cards .stat-value{margin-top:4px;font-size:22px;font-weight:600}.usage-range{border:1px solid var(--border);background:var(--surface);border-radius:999px;gap:2px;padding:2px;display:inline-flex}.usage-range-btn{color:var(--muted);cursor:pointer;font:inherit;background:0 0;border:none;border-radius:999px;padding:4px 12px}.usage-range-btn.active{background:var(--raised);color:var(--text);font-weight:600}.panel-title{color:var(--text);margin:0 0 12px;font-size:14px;font-weight:600}.panel-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;display:flex}.panel-head .panel-title{margin:0}.panel-head .input{max-width:220px}.heatmap{flex-direction:column;gap:6px;padding-bottom:4px;display:flex;overflow-x:auto}.heatmap-months{color:var(--muted);margin-bottom:-2px;font-size:11px;display:grid}.heatmap-day-spacer{grid-column:1}.heatmap-month{white-space:nowrap}.heatmap-body{gap:3px;display:flex}.heatmap-days{color:var(--muted);flex-shrink:0;grid-template-rows:repeat(7,1fr);align-items:center;width:25px;font-size:10px;display:grid}.heatmap-grid{flex:1;gap:3px;min-width:0;display:grid}.heatmap-week{grid-template-rows:repeat(7,1fr);gap:3px;display:grid}.heatmap-cell{aspect-ratio:1;background:var(--border);border-radius:2px;min-width:0}.heatmap-cell-0{background:var(--border)}.heatmap-cell-1{background:color-mix(in oklch, var(--green) 25%, var(--surface))}.heatmap-cell-2{background:color-mix(in oklch, var(--green) 50%, var(--surface))}.heatmap-cell-3{background:color-mix(in oklch, var(--green) 75%, var(--surface))}.heatmap-cell-4{background:var(--green)}.heatmap-legend{align-self:flex-end;align-items:center;gap:4px;font-size:12px;display:inline-flex}.heatmap-legend .heatmap-cell{aspect-ratio:auto;width:10px;height:10px}.usage-bar{background:var(--border);border-radius:999px;width:100%;min-width:60px;height:6px;overflow:hidden}.usage-bar-fill{background:var(--green);border-radius:999px;height:100%}.daybars{grid-template-columns:repeat(7,1fr);align-items:end;gap:10px;height:180px;padding-top:8px;display:grid}.daybar{flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;height:100%;display:flex;position:relative}.daybar-track{background:var(--border);border-radius:6px;flex:1;align-items:flex-end;width:100%;max-width:48px;display:flex;overflow:hidden}.daybar-stack{border-radius:6px 6px 0 0;flex-direction:column-reverse;width:100%;min-height:2px;transition:height .2s;display:flex;overflow:hidden}.daybar-seg{width:100%;min-height:1px}.daybar-count{color:var(--text);font-size:12px;font-weight:600}.daybar-label{white-space:nowrap;font-size:11px}.daybar:hover .daybar-track{outline:1px solid var(--border)}.daybar-tip{z-index:5;background:var(--surface);border:1px solid var(--border);pointer-events:none;border-radius:8px;min-width:160px;padding:8px 10px;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%);box-shadow:0 6px 20px #00000059}.daybar-tip-date{color:var(--text);white-space:nowrap;margin-bottom:6px;font-size:12px;font-weight:600}.daybar-tip-row{align-items:center;gap:8px;font-size:12px;line-height:1.7;display:flex}.daybar-tip-swatch{border-radius:2px;flex-shrink:0;width:10px;height:10px}.daybar-tip-name{color:var(--text);white-space:nowrap;text-overflow:ellipsis;flex:1;max-width:160px;overflow:hidden}.daybar-tip-val{color:var(--muted);font-variant-numeric:tabular-nums}.usage-scroll{max-height:360px;overflow-y:auto}.usage-scroll thead th{z-index:1;background:var(--surface);position:sticky;top:0}
|