@coseung2/opencodex 2.8.0-cs.1 → 2.8.0-cs.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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CAnnes06.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-OY43ubAq.css">
19
+ <script type="module" crossorigin src="/assets/index-BvAys666.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CxisOo-q.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coseung2/opencodex",
3
- "version": "2.8.0-cs.1",
3
+ "version": "2.8.0-cs.10",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -31,10 +31,13 @@ For OCX 2.8+, the notch automatically reads the existing `%USERPROFILE%\.opencod
31
31
  - The same native sample collects `GetPerformanceInfo` physical total/available and commit total/limit values. The header labels the smaller available headroom as `안정`, `주의`, or `위험`; caution and danger use 10%/2 GiB and 5%/1 GiB minimum-headroom thresholds respectively. No memory history is persisted.
32
32
  - `/api/usage?range=7d` refreshes around every 30 seconds; only the newest calendar day's model rows are aggregated into the displayed per-provider usage.
33
33
  - `/api/logs?tail=10` refreshes every ~2 seconds only while the Logs tab is visible. The response replaces the in-memory list, newest first, and OCX Notch never persists logs itself.
34
- - OpenAI account state and per-account quotas refresh around every 5 seconds. Other provider configuration, account pools, and cached `/api/provider-quotas` refresh around every 5 minutes.
34
+ - OpenAI account state and per-account quotas refresh around every 5 seconds. Other provider configuration, account pools, and cached `/api/provider-quotas` refresh around every 5 minutes. For xAI OAuth, OCX reports the authoritative weekly Grok credit-pool usage and reset; the separate monthly spending cap is not presented as quota.
35
+ - Provider setup reads OCX's `/api/provider-presets` catalog on demand. OAuth status is polled only while a browser/device sign-in or account reauthentication is active; device authorization URLs, instructions, and user codes remain visible in the modal.
35
36
  - `/api/system/memory` is requested only while expanded, at most every ~45 seconds, for optional heap detail.
36
37
 
37
- All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, database, log file, runtime download, or automatic startup behavior. The last window position and width are stored in `%LOCALAPPDATA%\OCX Notch\window.json`.
38
+ All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, database, persistent usage history, runtime download, or automatic startup behavior. The last window position and width are stored in `%LOCALAPPDATA%\OCX Notch\window.json`.
39
+
40
+ Startup failures and Rust panics are written to the bounded diagnostic log `%LOCALAPPDATA%\OCX Notch\ocx-notch.log`. Running `ocx-notch` while the widget is already open restores its opacity, repaints it, and moves it to the top of the monitor containing the cursor. Display-topology changes also clamp the window back into a visible work area.
38
41
 
39
42
  ## Interaction
40
43
 
@@ -48,8 +51,9 @@ All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, datab
48
51
  - Drag anywhere on the notch to move it; its chosen position is preserved while it expands, collapses, or refreshes.
49
52
  - Click a provider with multiple accounts to expand or collapse its account rows.
50
53
  - Click the pause icon beside an OpenAI account to exclude it from the rotation pool. A paused account shows a play icon that includes it again. The icon updates immediately and rolls back if OCX rejects the request.
54
+ - Accounts that require renewed credentials expose an inline **재인증** action on the same row. OpenAI's main account is intentionally excluded; eligible OpenAI pool and generic OAuth accounts can start, cancel, and retry browser authentication.
51
55
  - Providers with quota data are shown first. Providers with usage but no quota stay behind the inline usage-only toggle.
52
56
  - Press **Esc** to collapse.
53
- - Right-click to set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. `Off` writes threshold `0`.
57
+ - Right-click to add a provider, set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. The opaque provider modal groups supported presets into Account, Free, and Paid tabs. Canonical OpenAI adds another Codex account, OAuth presets use browser/device authorization, and required-key presets use masked API-key entry. Local, key-optional, endpoint-choice, and placeholder-URL presets are omitted because this compact modal cannot preserve those setup contracts safely. `Off` writes threshold `0`.
54
58
 
55
59
  Quota percentages are shown as used percentages with the same 5-hour/weekly/monthly/custom-window rows, reset countdowns, 5px progress bars, green fill, and green-to-amber threshold warning used by the OCX dashboard. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
@@ -8,7 +8,7 @@ export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is at
8
8
  export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
9
9
 
10
10
  export const KIRO_COMPLETION_INSTRUCTIONS =
11
- `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
11
+ `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
12
12
 
13
13
  export type KiroCompletionMode = "disabled" | "required" | "text_fallback";
14
14
 
@@ -468,17 +468,38 @@ export function buildKiroPayload(
468
468
  }
469
469
  };
470
470
 
471
- for (const msg of kiroPayloadMessages(parsed)) {
471
+ const payloadMessages = kiroPayloadMessages(parsed);
472
+ const replayMessagePrefixLength = Math.min(
473
+ Math.max(0, parsed._replayMessagePrefixLen ?? 0),
474
+ payloadMessages.length,
475
+ );
476
+ for (let messageIndex = 0; messageIndex < payloadMessages.length; messageIndex++) {
477
+ const msg = payloadMessages[messageIndex];
478
+ const isReplayedMessage = messageIndex < replayMessagePrefixLength;
472
479
  if (msg.role === "user" || msg.role === "developer") {
473
480
  const text = userContentText((msg as { content: string | OcxContentPart[] }).content);
474
- const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
481
+ // Historical text/tool structure remains replayable, but image bytes are scoped to the turn
482
+ // that introduced them. Re-sending completed-turn images makes Kiro inspect the same visual on
483
+ // every unrelated follow-up and repeatedly pays the multimodal context cost. The parser-owned
484
+ // prefix boundary keeps current user/tool-result images intact, including the bounded internal
485
+ // completion retry built from this same parsed request.
486
+ const images = isReplayedMessage
487
+ ? []
488
+ : extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
475
489
  pushUser(text, images);
476
490
  } else if (msg.role === "assistant") {
477
491
  const aMsg = msg as OcxAssistantMessage;
478
- const text = (aMsg.content || [])
492
+ const rawText = (aMsg.content || [])
479
493
  .filter((b): b is OcxTextContent => b.type === "text")
480
494
  .map(b => b.text)
481
495
  .join("");
496
+ // Responses commentary is transient UI progress, not durable assistant state. Replaying it
497
+ // into every Kiro tool-result continuation teaches the model to repeat the same update and
498
+ // grows the upstream context on each client-driven tool round. Keep the structural tool use,
499
+ // but omit commentary prose from normal history. The adapter-owned completion retry appends
500
+ // its one replayable assistant turn without a commentary phase below, so that bounded path is
501
+ // preserved.
502
+ const text = aMsg.phase === "commentary" ? "" : rawText;
482
503
  const toolCalls = (aMsg.content || [])
483
504
  .filter((b): b is OcxToolCall => b.type === "toolCall");
484
505
  const toolUses: KiroToolUse[] = toolCalls.map(tc => {
@@ -492,7 +513,7 @@ export function buildKiroPayload(
492
513
  });
493
514
  if (!text && toolUses.length === 0) {
494
515
  const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
495
- if (hasReasoning) continue;
516
+ if (hasReasoning || aMsg.phase === "commentary") continue;
496
517
  }
497
518
  pushAssistant(text, toolUses);
498
519
  } else if (msg.role === "toolResult") {
@@ -502,7 +523,7 @@ export function buildKiroPayload(
502
523
  }
503
524
  const text = userContentText(tr.content);
504
525
  const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
505
- const images = extractKiroImages(tr.content);
526
+ const images = isReplayedMessage ? [] : extractKiroImages(tr.content);
506
527
  const toolUseId = normalizeToolId(tr.toolCallId);
507
528
  if (!priorCalls.has(toolUseId)) {
508
529
  throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
@@ -1779,7 +1800,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1779
1800
  retryParsed.context.messages.push({
1780
1801
  role: "assistant",
1781
1802
  content: [{ type: "text" as const, text: assistantText }],
1782
- phase: "commentary",
1803
+ // Intentionally omit `phase:"commentary"`: normal client commentary is filtered from
1804
+ // Kiro history, while this adapter-owned one-shot replay is the evidence the bounded
1805
+ // completion retry must validate.
1783
1806
  model: retryParsed.modelId,
1784
1807
  timestamp: Date.now(),
1785
1808
  });
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import { stripGrokConfig } from "../grok/inject";
5
5
  import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
6
6
  import { reconcileJournal } from "../codex/journal";
7
7
  import {
8
+ applyProxyEnv,
8
9
  codexAutoStartEnabled,
9
10
  getConfigDir,
10
11
  loadConfig,
@@ -25,7 +26,7 @@ import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy";
25
26
  import { installCrashGuards } from "../lib/crash-guard";
26
27
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
27
28
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
28
- import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
29
+ import { findLiveProxy, probeHostname, proxyIdentityAt, type LiveProxy } from "../server/proxy-liveness";
29
30
  import { stopProxy } from "../lib/process-control";
30
31
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
31
32
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
@@ -64,7 +65,7 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
64
65
  process.exit(0);
65
66
  }
66
67
 
67
- maybeAutoRestoreCodexShim(command, args);
68
+ if (command !== "start") maybeAutoRestoreCodexShim(command, args);
68
69
 
69
70
  function parsePortOption(): number | undefined {
70
71
  if (args.length === 1) return undefined;
@@ -119,16 +120,22 @@ function startArgv(port?: number): string[] {
119
120
  return args;
120
121
  }
121
122
 
123
+ function rejectAlreadyRunning(live: LiveProxy, fallbackPid: number | null = null): never {
124
+ console.error(`⚠️ Proxy already running (PID ${live.pid ?? fallbackPid ?? "unknown"}, port ${live.port}). Use 'ocx stop' first.`);
125
+ process.exit(1);
126
+ }
127
+
122
128
  async function chooseListenPort(requestedPort?: number): Promise<number> {
123
129
  const config = loadConfig();
124
130
  const preferred = requestedPort ?? config.port ?? 10100;
125
131
  const hardPin = requestedPort !== undefined && requestedPort > 0;
132
+ const hostname = config.hostname ?? "127.0.0.1";
126
133
  // Soft start: brief prefer-retry then ephemeral hop.
127
134
  // Explicit `--port` (service wrappers / update restart): wait for the pinned port
128
135
  // to free without killing any listener (healthy ocx / foreign). Never hop.
129
136
  if (hardPin && preferred > 0) {
130
137
  const { reclaimListenPort } = await import("../server/port-reclaim");
131
- await reclaimListenPort(preferred, config.hostname ?? "127.0.0.1", {
138
+ await reclaimListenPort(preferred, hostname, {
132
139
  // Ghost LISTEN rows with a dead PID can outlive the process for a while.
133
140
  // SetTcpEntry(DELETE_TCB) needs elevation (often returns 317), so the only
134
141
  // reliable non-admin recovery is to wait for the OS to release the TCB.
@@ -139,49 +146,67 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
139
146
  dropTcpRows: true,
140
147
  });
141
148
  }
149
+ let selected: number;
142
150
  try {
143
- const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
151
+ selected = await findAvailablePort(preferred, hostname, {
144
152
  // After reclaim, keep probing briefly — ghost rows sometimes clear between
145
153
  // the reclaim deadline and the final listen. Still never hop off `--port`.
146
154
  preferRetryMs: hardPin ? 5_000 : 750,
147
155
  preferRetryIntervalMs: 50,
148
- allowEphemeralFallback: !hardPin,
156
+ allowEphemeralFallback: false,
149
157
  });
150
- if (preferred > 0 && selected !== preferred) {
151
- console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
152
- }
153
- if (shouldPersistSelectedPort(config.port, selected, preferred)) {
154
- config.port = selected;
155
- saveConfig(config);
156
- }
157
- return selected;
158
158
  } catch (err) {
159
- if (err instanceof PortUnavailableError) {
160
- console.error(`❌ ${err.message}`);
161
- console.error(" Stop whatever holds that port, or change config.port, then retry.");
162
- process.exit(1);
159
+ if (err instanceof PortUnavailableError && !hardPin) {
160
+ // Runtime files are advisory: a live proxy can still own the preferred listener when
161
+ // both records are absent or stale. Check that listener before allocating an ephemeral
162
+ // port, so a missed discovery pass cannot shadow it and overwrite fresh runtime state.
163
+ if (preferred > 0) {
164
+ const identity = await proxyIdentityAt(preferred, { hostname });
165
+ if (identity) {
166
+ rejectAlreadyRunning({ pid: null, port: preferred, hostname, source: "config" });
167
+ }
168
+ }
169
+ const live = await findLiveProxy();
170
+ if (live) rejectAlreadyRunning(live);
171
+ selected = await findAvailablePort(0, hostname);
172
+ if (preferred > 0) {
173
+ console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
174
+ }
175
+ } else {
176
+ if (err instanceof PortUnavailableError) {
177
+ console.error(`❌ ${err.message}`);
178
+ console.error(" Stop whatever holds that port, or change config.port, then retry.");
179
+ process.exit(1);
180
+ }
181
+ throw err;
163
182
  }
164
- throw err;
165
183
  }
184
+ if (shouldPersistSelectedPort(config.port, selected, preferred)) {
185
+ config.port = selected;
186
+ saveConfig(config);
187
+ }
188
+ return selected;
166
189
  }
167
190
 
168
191
  async function handleStart(options: { block?: boolean } = {}) {
192
+ const requestedPort = parsePortOption();
193
+ // Identity probes must stay direct even when the shell supplied HTTP_PROXY
194
+ // without NO_PROXY. startServer applies the same idempotent normalization.
195
+ applyProxyEnv(loadConfig());
196
+ const existingPid = readPid();
197
+ const live = await findLiveProxy();
198
+ if (live) rejectAlreadyRunning(live, existingPid);
199
+ maybeAutoRestoreCodexShim(command, args);
200
+ if (existingPid) {
201
+ removePid(existingPid);
202
+ }
203
+
169
204
  // Native (WinSW) service mode has no batch wrapper to read the service token file
170
205
  // into the environment, so the app loads it here before the server binds. The server
171
206
  // auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
172
207
  const serviceToken = loadServiceTokenFromFile(process.env);
173
208
  if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
174
- const requestedPort = parsePortOption();
175
209
  if (!currentExternalCodexModelProvider()) reconcileJournal();
176
- const existingPid = readPid();
177
- if (existingPid) {
178
- const live = await findLiveProxy();
179
- if (live) {
180
- console.error(`⚠️ Proxy already running (PID ${live.pid ?? existingPid}, port ${live.port}). Use 'ocx stop' first.`);
181
- process.exit(1);
182
- }
183
- removePid(existingPid);
184
- }
185
210
 
186
211
  // Interactive-only update prompt. Must run BEFORE we bind a port / write a
187
212
  // PID: choosing "Update now" installs globally and exits, so we never want a
@@ -213,16 +238,29 @@ async function handleStart(options: { block?: boolean } = {}) {
213
238
  }
214
239
  continue;
215
240
  }
241
+ const live = await findLiveProxy();
242
+ if (live) rejectAlreadyRunning(live);
216
243
  console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
217
244
  port = await chooseListenPort(requestedPort);
218
245
  }
219
246
  }
247
+ const config = loadConfig();
248
+ const preferred = requestedPort ?? config.port ?? 10100;
249
+ if (port !== preferred && preferred > 0) {
250
+ // Recheck immediately before any runtime-state write: another proxy may have won the
251
+ // preferred listener after soft fallback selection completed.
252
+ const identity = await proxyIdentityAt(preferred, { hostname: config.hostname });
253
+ if (identity) {
254
+ server.stop(true);
255
+ rejectAlreadyRunning({ pid: null, port: preferred, hostname: config.hostname, source: "config" });
256
+ }
257
+ }
258
+
220
259
  // A single request's streaming error must never crash the daemon serving every
221
260
  // other Codex session — capture the full stack to crash.log and stay up.
222
261
  installCrashGuards();
223
262
  writePid(process.pid);
224
263
 
225
- const config = loadConfig();
226
264
  writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
227
265
  // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
228
266
  // is about to transform; snapshotting earlier only captured a baseline that could
package/src/cli/status.ts CHANGED
@@ -3,7 +3,7 @@ import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics
3
3
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
4
4
  import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
5
5
  import type { OcxConfig } from "../types";
6
- import { diagnoseService, serviceLogPath } from "../service";
6
+ import { diagnoseService, serviceStatusSummary } from "../service";
7
7
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
8
8
  import { getCodexRoutingKind } from "../codex/inject";
9
9
  import { diagnoseCodexShim } from "../codex/shim";
@@ -172,9 +172,10 @@ export async function collectStatus(): Promise<CliStatusView> {
172
172
  // A service can be registered and still not serve: the manager reports the job
173
173
  // either way. `live` was already identity-probed a few lines above, so cross-check
174
174
  // rather than print registration as if it were service.
175
+ const baseServiceSummary = serviceStatusSummary(service);
175
176
  const serviceSummary = service.installed && !live
176
- ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service install'`
177
- : service.summary;
177
+ ? `${baseServiceSummary} — registered but NOT serving; re-run 'ocx service install'`
178
+ : baseServiceSummary;
178
179
  const codexShim = diagnoseCodexShim();
179
180
  const codexShimSummary = codexShim.summary;
180
181
  const startup = collectStartupHealth(config, {
package/src/config.ts CHANGED
@@ -2045,9 +2045,10 @@ export function resolveEnvValue(value: string | undefined): string | undefined {
2045
2045
  */
2046
2046
  export function applyProxyEnv(config: OcxConfig): void {
2047
2047
  const proxy = resolveEnvValue(config.proxy);
2048
- if (!proxy) return;
2049
- if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
2050
- if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
2048
+ if (proxy) {
2049
+ if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
2050
+ if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
2051
+ }
2051
2052
  const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
2052
2053
  const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
2053
2054
  const seen = new Set(entries.map(e => e.toLowerCase()));