@coseung2/opencodex 2.8.0-cs.1 → 2.8.0-cs.11

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.
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Memory watchdog (#314 WP3 / #509) — warn-only observability for the Windows
3
- * native-memory growth reported upstream (Bun fetch buffers / socket handles).
2
+ * Memory watchdog (#314 WP3 / #509) — observability plus a bounded Windows
3
+ * native-memory reclamation attempt for the Bun fetch-buffer retention shape.
4
4
  *
5
5
  * Samples process.memoryUsage() on an unref'd interval into a bounded ring and
6
- * logs ONE rate-limited warning when observed memory crosses the threshold. It never
7
- * restarts anything (threshold auto-restart is deliberately deferred; the
8
- * service managers' crash-respawn already covers hard failures). The active
9
- * instance is a module-level singleton so the management API can expose the
10
- * snapshot without threading server state through route contexts.
6
+ * logs ONE rate-limited warning when observed memory crosses the threshold. On
7
+ * Windows, a supported Bun.gc(true) is attempted once per pressure episode,
8
+ * only after observed memory reaches the reclamation threshold and the active
9
+ * turn registry is idle. The active instance is a module-level singleton so
10
+ * the management API can expose the snapshot without threading server state
11
+ * through route contexts.
11
12
  *
12
13
  * Privacy: samples are scalar numbers only; the warn line never interpolates
13
14
  * paths, hostnames, or tokens.
14
15
  */
15
16
 
17
+ import { getActiveTurnCount, onActiveTurnsIdle } from "./lifecycle";
18
+
16
19
  export type MemorySampleBase = {
17
20
  /** Epoch ms. */
18
21
  at: number;
@@ -52,6 +55,7 @@ export type MemoryWatchdog = {
52
55
 
53
56
  const DEFAULT_INTERVAL_MS = 60_000;
54
57
  const DEFAULT_WARN_THRESHOLD_BYTES = 4 * 1024 ** 3; // 4 GiB
58
+ const DEFAULT_RECLAIM_THRESHOLD_BYTES = 2 * 1024 ** 3; // 2 GiB, based on Windows reproduction
55
59
  const DEFAULT_RING_SIZE = 360; // ≈6h at 60s
56
60
  const WARN_INTERVAL_MS = 30 * 60_000;
57
61
  const DOCS_URL = "https://opencodex.me/troubleshooting/windows-memory/";
@@ -71,6 +75,24 @@ export function observedMemoryCounter(sample: Pick<MemorySampleBase, "rss" | "ex
71
75
  return { observedBytes: best.bytes, observedMetric: best.metric };
72
76
  }
73
77
 
78
+ export type MemoryReclamationPolicyInput = {
79
+ observedBytes: number;
80
+ pressureThresholdBytes: number;
81
+ activeTurns: number;
82
+ platform: NodeJS.Platform;
83
+ gcSupported: boolean;
84
+ pressureLatched: boolean;
85
+ };
86
+
87
+ /** Pure policy gate for the pressure-triggered, idle-only reclamation path. */
88
+ export function shouldAttemptMemoryReclamation(input: MemoryReclamationPolicyInput): boolean {
89
+ return input.platform === "win32"
90
+ && input.gcSupported
91
+ && input.activeTurns === 0
92
+ && input.observedBytes >= input.pressureThresholdBytes
93
+ && !input.pressureLatched;
94
+ }
95
+
74
96
  /** The running watchdog, if any — read by /api/system/memory. */
75
97
  export function getActiveMemoryWatchdog(): MemoryWatchdog | null {
76
98
  return active;
@@ -93,6 +115,12 @@ function normalizeSample(sample: MemorySampleBase): MemorySample {
93
115
  return { ...sample, ...observedMemoryCounter(sample) };
94
116
  }
95
117
 
118
+ function defaultGarbageCollector(platform: NodeJS.Platform): (() => void) | null {
119
+ if (platform !== "win32") return null;
120
+ if (typeof Bun === "undefined" || typeof Bun.gc !== "function") return null;
121
+ return () => Bun.gc(true);
122
+ }
123
+
96
124
  /**
97
125
  * Start (or replace) the process-wide memory watchdog. Idempotent: a previous
98
126
  * active instance is stopped first, so repeated startServer() calls in tests
@@ -102,23 +130,57 @@ function normalizeSample(sample: MemorySampleBase): MemorySample {
102
130
  export function startMemoryWatchdog(opts?: {
103
131
  intervalMs?: number;
104
132
  warnThresholdBytes?: number;
133
+ pressureThresholdBytes?: number;
105
134
  ringSize?: number;
106
135
  now?: () => number;
107
136
  sample?: () => MemorySampleBase;
108
137
  warn?: (msg: string) => void;
138
+ platform?: NodeJS.Platform;
139
+ activeTurnCount?: () => number;
140
+ gc?: (() => void) | null;
109
141
  }): MemoryWatchdog {
110
142
  active?.stop();
111
143
  const intervalMs = opts?.intervalMs ?? DEFAULT_INTERVAL_MS;
112
144
  const warnThresholdBytes = opts?.warnThresholdBytes ?? DEFAULT_WARN_THRESHOLD_BYTES;
145
+ const pressureThresholdBytes = opts?.pressureThresholdBytes ?? DEFAULT_RECLAIM_THRESHOLD_BYTES;
113
146
  const ringSize = opts?.ringSize ?? DEFAULT_RING_SIZE;
114
147
  const now = opts?.now ?? Date.now;
115
148
  const sample = opts?.sample ?? (() => defaultSample(now));
116
149
  const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
150
+ const platform = opts?.platform ?? process.platform;
151
+ const activeTurnCount = opts?.activeTurnCount ?? getActiveTurnCount;
152
+ const collectGarbage = opts !== undefined && "gc" in opts
153
+ ? opts.gc ?? null
154
+ : defaultGarbageCollector(platform);
117
155
 
118
156
  const samples: MemorySample[] = [];
119
157
  let lastWarnAt: number | null = null;
120
158
  let observedBytes = 0;
121
159
  let observedMetric: MemoryMetric = "rss";
160
+ let pressureLatched = false;
161
+
162
+ const reclaimIfIdle = (): void => {
163
+ if (!shouldAttemptMemoryReclamation({
164
+ observedBytes,
165
+ pressureThresholdBytes,
166
+ activeTurns: activeTurnCount(),
167
+ platform,
168
+ gcSupported: collectGarbage !== null,
169
+ pressureLatched,
170
+ })) return;
171
+ // Latch before calling Bun.gc so a sustained high sample cannot turn into
172
+ // a GC on every timer tick or every later turn completion.
173
+ pressureLatched = true;
174
+ try {
175
+ collectGarbage?.();
176
+ } catch {
177
+ // Reclamation is a best-effort runtime hint. A collector failure must not
178
+ // escape the watchdog timer and terminate the proxy.
179
+ }
180
+ };
181
+ const removeIdleListener = platform === "win32" && collectGarbage !== null
182
+ ? onActiveTurnsIdle(reclaimIfIdle)
183
+ : () => {};
122
184
 
123
185
  const tick = () => {
124
186
  let s: MemorySample;
@@ -131,6 +193,8 @@ export function startMemoryWatchdog(opts?: {
131
193
  if (samples.length > ringSize) samples.splice(0, samples.length - ringSize);
132
194
  observedBytes = s.observedBytes;
133
195
  observedMetric = s.observedMetric;
196
+ if (observedBytes < pressureThresholdBytes) pressureLatched = false;
197
+ reclaimIfIdle();
134
198
  if (s.observedBytes >= warnThresholdBytes && (lastWarnAt === null || now() - lastWarnAt >= WARN_INTERVAL_MS)) {
135
199
  lastWarnAt = now();
136
200
  const observedMb = Math.round(s.observedBytes / (1024 * 1024));
@@ -145,6 +209,7 @@ export function startMemoryWatchdog(opts?: {
145
209
  const instance: MemoryWatchdog = {
146
210
  stop() {
147
211
  clearInterval(timer);
212
+ removeIdleListener();
148
213
  if (active === instance) active = null;
149
214
  },
150
215
  snapshot() {
@@ -68,7 +68,11 @@ export interface LiveProxy {
68
68
  */
69
69
  export function probeHostname(hostname: string | undefined): string {
70
70
  const trimmed = (hostname ?? "").trim();
71
- if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
71
+ if (!trimmed || /^localhost$/i.test(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") {
72
+ // startServer canonicalizes localhost to IPv4 loopback because Codex's injected URL does
73
+ // the same. Keep liveness probes on that exact address (Windows otherwise prefers ::1).
74
+ return "127.0.0.1";
75
+ }
72
76
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
73
77
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
74
78
  }
package/src/service.ts CHANGED
@@ -2326,8 +2326,8 @@ export function diagnoseService(): ServiceDiagnostic {
2326
2326
  return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
2327
2327
  }
2328
2328
 
2329
- export function serviceStatusSummary(): string {
2330
- return diagnoseService().summary;
2329
+ export function serviceStatusSummary(service: ServiceDiagnostic = diagnoseService()): string {
2330
+ return `${service.summary}; log: ${serviceLogPath()}`;
2331
2331
  }
2332
2332
 
2333
2333
  /**
@@ -2394,6 +2394,16 @@ export interface ParsedServiceArgs {
2394
2394
  invalid: string[];
2395
2395
  }
2396
2396
 
2397
+ const SERVICE_SUBCOMMANDS = new Set(["install", "repair", "start", "stop", "status", "uninstall", "remove"]);
2398
+
2399
+ function exitServiceUsage(): never {
2400
+ console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
2401
+ console.error(" With no subcommand, installs/updates and starts the background service.");
2402
+ console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
2403
+ console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2404
+ process.exit(1);
2405
+ }
2406
+
2397
2407
  /**
2398
2408
  * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
2399
2409
  * subcommand; backend flags are only meaningful for `install` (validated by the caller).
@@ -2421,6 +2431,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
2421
2431
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
2422
2432
  const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a)));
2423
2433
  const command = parsed.sub;
2434
+ if (!SERVICE_SUBCOMMANDS.has(command)) exitServiceUsage();
2424
2435
  if (parsed.invalid.length > 0) {
2425
2436
  console.error(`Unknown service option: ${parsed.invalid.join(" ")}`);
2426
2437
  process.exit(1);
@@ -2545,10 +2556,6 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2545
2556
  console.log("✅ service uninstalled.");
2546
2557
  break;
2547
2558
  default:
2548
- console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
2549
- console.error(" With no subcommand, installs/updates and starts the background service.");
2550
- console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
2551
- console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2552
- process.exit(1);
2559
+ exitServiceUsage();
2553
2560
  }
2554
2561
  }
package/src/types.ts CHANGED
@@ -9,6 +9,8 @@ export interface OcxParsedRequest {
9
9
  _rawBody?: unknown;
10
10
  /** Number of leading raw input items restored from local previous_response_id state. */
11
11
  _replayPrefixLen?: number;
12
+ /** Number of parsed messages produced by that restored raw-input prefix. */
13
+ _replayMessagePrefixLen?: number;
12
14
  /** True when the proxy expanded a previous_response_id request into a full input replay. */
13
15
  _previousResponseInputExpanded?: boolean;
14
16
  /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
@@ -722,6 +724,13 @@ export interface OcxConfig {
722
724
  codexAccounts?: CodexAccount[];
723
725
  /** Account ids administratively excluded from future pool selection until resumed. */
724
726
  pausedCodexAccountIds?: string[];
727
+ /**
728
+ * OAuth provider account ids administratively excluded from pool selection until
729
+ * resumed, keyed by provider name (for example `kiro`, `anthropic`, `xai`).
730
+ * A paused account cannot be activated; pausing the active account promotes the
731
+ * first non-paused account (or clears the active selection when none remain).
732
+ */
733
+ pausedOauthAccountIds?: Record<string, string[]>;
725
734
  /**
726
735
  * Public model-selector namespaces bound to one Codex account. Values are stored account ids;
727
736
  * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases