@yhong91/cpac 0.1.30 → 0.1.32

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
@@ -248,9 +248,9 @@ Codex App 的长驻 `app-server` 可能缓存旧目录。`inject` 会删除 `mod
248
248
 
249
249
  注入命令可选 `--max_context`(`cpac inject --max_context` 或 `cpac install --target codex --max_context`,默认不带):把 catalog 中 `max_context_window` 高于 `context_window` 的模型提升到上限,并把 `auto_compact_token_limit` 设为上限的 90%。Codex 把 `context_window` 当输入预算而非展示标签,上游默认保留保守运营值(如 GPT-5.6 家族 272k),带上该参数后可用到实测上限(约 921k,opencodex 实测);90% 压缩线确保在硬上限前触发 auto-compact。不带参数注入的仍是原本上下文的目录。该参数仅限 codex,与其他 target 组合使用会直接报错。
250
250
 
251
- 第一次注入时,原文件按原始 bytes 备份到 `state_dir/codex/config.toml`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore`:若 `config.toml` 相对首次注入未改,按原 mode 逐字节还原备份;若用户在注入期间改过文件,只撤掉 CPAC 写入的 catalog、url 和标记。原先没有 `config.toml` 且撤掉后为空则删除文件。关闭 CPAC loopback 代理。
251
+ `config.toml` 不存在时拒绝注入。当前是 native 时把原文件备份到 `state_dir/codex/config.toml`(覆盖更旧的 native 快照);已注入后再 inject 不覆盖备份。kimi/grok/pi 同样:没有旧配置不写空备份,native 覆盖旧快照,已接入则保留。`restore --target codex`:若 `config.toml` 相对注入未改,按原 mode 逐字节还原备份;若用户在注入期间改过文件,只撤掉 CPAC 写入的 catalog、url 和标记。没有其它需要代理的 agent 时才关闭代理。
252
252
 
253
- loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 即可恢复。
253
+ loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 或 `cpac kimi` / `cpac grok` 即可恢复。
254
254
 
255
255
  需要由现有进程管理器监督时,可使用前台入口:
256
256
 
@@ -258,18 +258,19 @@ loopback 代理是 detached 用户进程,不安装系统服务。机器重启
258
258
  cpac proxy
259
259
  ```
260
260
 
261
- 它复用当前注入记录的端口和实例身份;没有活动注入时会拒绝启动。
261
+ 它复用 `state.json` 里记录的端口和实例身份;没有 loopback 记录时会拒绝启动。
262
262
 
263
263
  默认路径:
264
264
 
265
265
  ```text
266
266
  Codex 配置:$CODEX_HOME/config.toml,未设置 CODEX_HOME 时为 ~/.codex/config.toml
267
- CPAC state:~/.cpac
267
+ CPAC state:~/.cpac/state.json(公用 proxy 字段 + 已接入 agent 记录)
268
268
  各 agent 配置备份:~/.cpac/<agent>/<原文件名>
269
269
  Codex ~/.cpac/codex/config.toml
270
270
  Pi ~/.cpac/pi/pi-cpac.ts
271
271
  Kimi ~/.cpac/kimi/config.toml
272
272
  Grok ~/.cpac/grok/config.toml
273
+ restore / `<agent> clear` 会删掉该 agent 的备份目录和 state.json 里的对应记录
273
274
  ```
274
275
 
275
276
  为防止误删或覆盖,`state_dir` 不能是文件系统根目录、用户 home、系统临时目录,也不能包含 Codex 配置文件。
@@ -306,7 +307,7 @@ Pi 扩展尊重 `PI_CODING_AGENT_DIR` 环境变量。
306
307
  cpac kimi install
307
308
  ```
308
309
 
309
- 它在 `~/.kimi-code/config.toml` 追加一个由 CPAC 管理的块,注册 `cpac` OpenAI 兼容 provider,并把 CPA 目录里的每个模型映射为一个 `cpac/<模型名>` 条目。带 `supported_reasoning_levels` 的模型会写入 `capabilities`、`support_efforts` 和 `default_effort`,否则 Kimi 只给 Claude 名套内置 thinking profile,其它模型会停在 thinking off 且无法改 effort。`base_url` 指向 Codex 注入使用的 loopback 代理,因此密钥不落盘;代理未运行时 install 会提示先执行 `cpac inject`。卸载与状态:
310
+ 它在 `~/.kimi-code/config.toml` 追加一个由 CPAC 管理的块,注册 `cpac` OpenAI 兼容 provider,并把 CPA 目录里的每个模型映射为一个 `cpac/<模型名>` 条目。带 `supported_reasoning_levels` 的模型会写入 `capabilities`、`support_efforts` 和 `default_effort`,否则 Kimi 只给 Claude 名套内置 thinking profile,其它模型会停在 thinking off 且无法改 effort。`base_url` 指向共享 loopback 代理,因此密钥不落盘。install 会写入 `state.json` 并在需要时启动代理,不必先 `cpac inject`。卸载与状态:
310
311
 
311
312
  ```bash
312
313
  cpac restore --target kimi
package/dist/agents.js CHANGED
@@ -1,15 +1,14 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { fetchCatalog, injectedOwnership, pickSpawnModels, proxyFingerprint, readState, saveSpawnModels, stateBytes, stateProxy, } from "./config.js";
7
- import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "./proxy.js";
6
+ import { pickSpawnModels, saveSpawnModels } from "./config.js";
8
7
  import { MANAGED_MARKER, inject, restore } from "./targets/codex.js";
9
8
  import { grokConfigPath, grokHome, installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
10
9
  import { installKimiConfig, isKimiConfigInstalled, kimiConfigPath, uninstallKimiConfig, } from "./targets/kimi.js";
11
10
  import { installPiExtension, isPiExtensionInstalled, piExtensionsDir, uninstallPiExtension, } from "./targets/pi.js";
12
- import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, resolveApiKey, } from "./util.js";
11
+ import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, } from "./util.js";
13
12
  export async function runCodexConfig(config, configPath, options = {}) {
14
13
  if (options.reset) {
15
14
  let raw = {};
@@ -73,34 +72,6 @@ export async function runTargetLauncher(config, targetId, args, executable = tar
73
72
  const target = TARGETS.find((t) => t.id === targetId);
74
73
  if (!target)
75
74
  throw new CPACError(`unknown target: ${targetId}`);
76
- if (["kimi", "grok"].includes(targetId)) {
77
- const state = readState(config.state_dir);
78
- const proxy = state ? stateProxy(state) : null;
79
- const apiKey = await resolveApiKey(config.api_key_env);
80
- const fingerprint = proxyFingerprint(config, apiKey);
81
- const reuse = proxy !== null &&
82
- state?.proxy_fingerprint === fingerprint &&
83
- (await proxyIsHealthy(proxy));
84
- if (!reuse) {
85
- if (proxy)
86
- await stopProxyProcess(proxy);
87
- const started = await startProxyProcess(config, apiKey);
88
- try {
89
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
90
- const catalogPath = join(config.state_dir, "codex-models.json");
91
- mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
92
- atomicWrite(catalogPath, catalog.bytes);
93
- const originalMode = existsSync(config.codex_config)
94
- ? statSync(config.codex_config).mode & 0o7777
95
- : 0o600;
96
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state ? state.config_existed : false, state ? state.config_mode : originalMode, started, fingerprint, injectedOwnership(state)));
97
- }
98
- catch (error) {
99
- await stopProxyProcess(started);
100
- throw error;
101
- }
102
- }
103
- }
104
75
  if (!options.skipSync) {
105
76
  if (target.installed(config)) {
106
77
  try {
@@ -214,12 +185,12 @@ const TARGETS = [
214
185
  }
215
186
  await installPiExtension(config);
216
187
  },
217
- uninstall: async (_config, dryRun) => {
188
+ uninstall: async (config, dryRun) => {
218
189
  if (dryRun) {
219
190
  console.log(`would remove Pi extension: ${join(piExtensionsDir(), "pi-cpac.ts")}`);
220
191
  return;
221
192
  }
222
- await uninstallPiExtension();
193
+ await uninstallPiExtension(config);
223
194
  },
224
195
  },
225
196
  {
@@ -235,12 +206,12 @@ const TARGETS = [
235
206
  }
236
207
  await installKimiConfig(config);
237
208
  },
238
- uninstall: async (_config, dryRun) => {
209
+ uninstall: async (config, dryRun) => {
239
210
  if (dryRun) {
240
211
  console.log(`would remove CPA provider block: ${kimiConfigPath()}`);
241
212
  return;
242
213
  }
243
- await uninstallKimiConfig();
214
+ await uninstallKimiConfig(config);
244
215
  },
245
216
  },
246
217
  {
@@ -256,12 +227,12 @@ const TARGETS = [
256
227
  }
257
228
  await installGrokConfig(config);
258
229
  },
259
- uninstall: async (_config, dryRun) => {
230
+ uninstall: async (config, dryRun) => {
260
231
  if (dryRun) {
261
232
  console.log(`would remove CPA provider block: ${grokConfigPath()}`);
262
233
  return;
263
234
  }
264
- await uninstallGrokConfig();
235
+ await uninstallGrokConfig(config);
265
236
  },
266
237
  },
267
238
  ];
package/dist/config.js CHANGED
@@ -20,13 +20,12 @@ const CONFIG_KEYS = new Set([
20
20
  // spawn_agent overrides (codex-rs MAX_SPAWN_AGENT_MODEL_OVERRIDES).
21
21
  const MAX_SPAWN_MODELS = 5;
22
22
  export const CLAUDE_SLOT_KEYS = ["opus", "sonnet", "haiku"];
23
- const REQUIRED_STATE_KEYS = new Set([
23
+ export const AGENT_IDS = ["codex", "pi", "kimi", "grok"];
24
+ const AGENT_ID_SET = new Set(AGENT_IDS);
25
+ const STATE_KEYS = new Set([
24
26
  "config_path",
25
27
  "config_existed",
26
28
  "config_mode",
27
- ]);
28
- const STATE_KEYS = new Set([
29
- ...REQUIRED_STATE_KEYS,
30
29
  "proxy_id",
31
30
  "proxy_fingerprint",
32
31
  "proxy_pid",
@@ -34,6 +33,7 @@ const STATE_KEYS = new Set([
34
33
  "injected_config_hash",
35
34
  "injected_openai_base_url",
36
35
  "injected_catalog_path",
36
+ "agents",
37
37
  ]);
38
38
  export const STATE_FILES = [
39
39
  "state.json",
@@ -359,19 +359,37 @@ export function readState(stateDir) {
359
359
  throw new CPACError(`cannot read state: ${error instanceof Error ? error.message : String(error)}`);
360
360
  }
361
361
  if (!objectValue(value) ||
362
- Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
363
- [...REQUIRED_STATE_KEYS].some((key) => !(key in value))) {
362
+ Object.keys(value).some((key) => !STATE_KEYS.has(key))) {
364
363
  throw new CPACError("invalid state file");
365
364
  }
366
- if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
367
- throw new CPACError("invalid config path in state");
365
+ const hasCodex = value.config_path !== undefined ||
366
+ value.config_existed !== undefined ||
367
+ value.config_mode !== undefined;
368
+ if (hasCodex) {
369
+ if (typeof value.config_path !== "string" || !isAbsolute(value.config_path))
370
+ throw new CPACError("invalid config path in state");
371
+ if (typeof value.config_existed !== "boolean")
372
+ throw new CPACError("invalid config_existed value in state");
373
+ if (!Number.isInteger(value.config_mode) ||
374
+ value.config_mode < 0 ||
375
+ value.config_mode > 0o7777) {
376
+ throw new CPACError("invalid config_mode value in state");
377
+ }
368
378
  }
369
- if (typeof value.config_existed !== "boolean")
370
- throw new CPACError("invalid config_existed value in state");
371
- if (!Number.isInteger(value.config_mode) ||
372
- value.config_mode < 0 ||
373
- value.config_mode > 0o7777) {
374
- throw new CPACError("invalid config_mode value in state");
379
+ if (value.agents !== undefined) {
380
+ if (!objectValue(value.agents))
381
+ throw new CPACError("invalid agents in state");
382
+ for (const [id, rec] of Object.entries(value.agents)) {
383
+ if (!AGENT_ID_SET.has(id))
384
+ throw new CPACError(`unknown agent in state: ${id}`);
385
+ if (!objectValue(rec) ||
386
+ typeof rec.path !== "string" ||
387
+ !rec.path ||
388
+ (rec.existed !== undefined && typeof rec.existed !== "boolean") ||
389
+ Object.keys(rec).some((key) => key !== "path" && key !== "existed")) {
390
+ throw new CPACError(`invalid agent record: ${id}`);
391
+ }
392
+ }
375
393
  }
376
394
  const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
377
395
  const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
@@ -413,6 +431,90 @@ export function stateProxy(state) {
413
431
  ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
414
432
  : null;
415
433
  }
434
+ export function needsProxy(state) {
435
+ if (state.config_path)
436
+ return true;
437
+ const agents = state.agents ?? {};
438
+ return Boolean(agents.codex || agents.kimi || agents.grok);
439
+ }
440
+ export function saveState(stateDir, state) {
441
+ atomicWrite(join(stateDir, "state.json"), Buffer.from(`${JSON.stringify(state, null, 2)}\n`));
442
+ }
443
+ export function recordAgent(stateDir, id, path, proxy, existed) {
444
+ const current = readState(stateDir) ?? {};
445
+ const prev = current.agents?.[id];
446
+ const record = { path };
447
+ if (!(prev?.existed ?? existed ?? true))
448
+ record.existed = false;
449
+ const next = {
450
+ ...current,
451
+ agents: { ...current.agents, [id]: record },
452
+ };
453
+ if (proxy) {
454
+ next.proxy_id = proxy.id;
455
+ next.proxy_pid = proxy.pid;
456
+ next.proxy_port = proxy.port;
457
+ next.proxy_fingerprint = proxy.fingerprint;
458
+ }
459
+ saveState(stateDir, next);
460
+ return next;
461
+ }
462
+ export function removeCreatedTarget(stateDir, id, target) {
463
+ if (readState(stateDir)?.agents?.[id]?.existed === false)
464
+ rmSync(target, { force: true });
465
+ }
466
+ export function dropAgent(stateDir, id) {
467
+ rmSync(join(stateDir, id), { recursive: true, force: true });
468
+ const current = readState(stateDir);
469
+ if (!current) {
470
+ try {
471
+ rmdirSync(stateDir);
472
+ }
473
+ catch (error) {
474
+ const code = error.code;
475
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
476
+ throw error;
477
+ }
478
+ return { state: null, stopProxy: null };
479
+ }
480
+ const proxy = stateProxy(current);
481
+ const agents = { ...current.agents };
482
+ delete agents[id];
483
+ const next = { ...current, agents };
484
+ if (id === "codex") {
485
+ delete next.config_path;
486
+ delete next.config_existed;
487
+ delete next.config_mode;
488
+ delete next.injected_config_hash;
489
+ delete next.injected_openai_base_url;
490
+ delete next.injected_catalog_path;
491
+ }
492
+ if (Object.keys(agents).length === 0)
493
+ delete next.agents;
494
+ const leftover = Boolean(next.config_path) ||
495
+ (next.agents && Object.keys(next.agents).length > 0);
496
+ const stopProxy = proxy && !needsProxy(next) ? proxy : null;
497
+ if (stopProxy) {
498
+ delete next.proxy_id;
499
+ delete next.proxy_pid;
500
+ delete next.proxy_port;
501
+ delete next.proxy_fingerprint;
502
+ }
503
+ if (!leftover) {
504
+ rmSync(join(stateDir, "state.json"), { force: true });
505
+ try {
506
+ rmdirSync(stateDir);
507
+ }
508
+ catch (error) {
509
+ const code = error.code;
510
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
511
+ throw error;
512
+ }
513
+ return { state: null, stopProxy };
514
+ }
515
+ saveState(stateDir, next);
516
+ return { state: next, stopProxy };
517
+ }
416
518
  export function injectedOwnership(state) {
417
519
  if (!state?.injected_config_hash ||
418
520
  !state.injected_openai_base_url ||
@@ -427,7 +529,7 @@ export function injectedOwnership(state) {
427
529
  export function sha256Hex(bytes) {
428
530
  return createHash("sha256").update(bytes).digest("hex");
429
531
  }
430
- export function stateBytes(config, existed, mode, proxy, proxyFingerprint, injected) {
532
+ export function stateBytes(config, existed, mode, proxy, proxyFingerprint, injected, existing) {
431
533
  return Buffer.from(`${JSON.stringify({
432
534
  config_path: config.codex_config,
433
535
  config_existed: existed,
@@ -443,9 +545,19 @@ export function stateBytes(config, existed, mode, proxy, proxyFingerprint, injec
443
545
  injected_catalog_path: injected.catalogPath,
444
546
  }
445
547
  : {}),
548
+ agents: {
549
+ ...existing?.agents,
550
+ codex: {
551
+ path: config.codex_config,
552
+ ...(existed ? {} : { existed: false }),
553
+ },
554
+ },
446
555
  }, null, 2)}\n`);
447
556
  }
448
557
  export function originalBytes(stateDir, state, expectedPath) {
558
+ if (!state.config_path) {
559
+ throw new CPACError("no Codex injection in state");
560
+ }
449
561
  if (state.config_path !== expectedPath) {
450
562
  throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
451
563
  }
package/dist/cpac.js CHANGED
@@ -27,7 +27,7 @@ export async function status(config) {
27
27
  const apiKey = process.env[config.api_key_env]?.trim();
28
28
  const keyConfigured = !!apiKey;
29
29
  const state = readState(config.state_dir);
30
- if (state) {
30
+ if (state?.config_path) {
31
31
  originalBytes(config.state_dir, state, config.codex_config);
32
32
  if (existsSync(config.codex_config)) {
33
33
  const proxy = stateProxy(state);
@@ -65,7 +65,7 @@ export async function status(config) {
65
65
  if (!state)
66
66
  return 1;
67
67
  const proxy = stateProxy(state);
68
- if (!proxy || !(await proxyIsHealthy(proxy)))
68
+ if (proxy && !(await proxyIsHealthy(proxy)))
69
69
  return 2;
70
70
  return 0;
71
71
  }
@@ -137,6 +137,10 @@ export default async function (pi) {
137
137
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
138
138
  contextWindow: intField(m.context_window) || 200000,
139
139
  maxTokens: MAX_TOKENS[m.slug] || DEFAULT_MAX_TOKENS,
140
+ compat: {
141
+ sendSessionAffinityHeaders: true,
142
+ sessionAffinityFormat: "openai",
143
+ },
140
144
  };
141
145
  if (thinkingLevelMap) modelObj.thinkingLevelMap = thinkingLevelMap;
142
146
  return modelObj;
package/dist/proxy.js CHANGED
@@ -3,10 +3,9 @@ import { randomBytes } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { createServer, request as httpRequest, } from "node:http";
5
5
  import { request as httpsRequest } from "node:https";
6
- import { join } from "node:path";
7
6
  import { fileURLToPath } from "node:url";
8
- import { apiBase, injectedOwnership, originalBytes, proxyFingerprint, readState, stateBytes, stateProxy, } from "./config.js";
9
- import { CPACError, atomicWrite, objectValue, resolveApiKey } from "./util.js";
7
+ import { apiBase, proxyFingerprint, readState, saveState, stateProxy, } from "./config.js";
8
+ import { CPACError, objectValue, resolveApiKey } from "./util.js";
10
9
  const HOP_BY_HOP_HEADERS = new Set([
11
10
  "connection",
12
11
  "keep-alive",
@@ -307,22 +306,43 @@ export async function stopProxyProcess(proxy) {
307
306
  await new Promise((resolveWait) => setTimeout(resolveWait, 200));
308
307
  killCpacProxy(proxy.pid, "SIGKILL");
309
308
  }
309
+ export async function ensureLoopbackProxy(config, apiKey) {
310
+ const fingerprint = proxyFingerprint(config, apiKey);
311
+ const recorded = readState(config.state_dir);
312
+ const existing = recorded ? stateProxy(recorded) : null;
313
+ if (existing &&
314
+ recorded?.proxy_fingerprint === fingerprint &&
315
+ (config.codex_proxy_port === 0 ||
316
+ config.codex_proxy_port === existing.port) &&
317
+ (await proxyIsHealthy(existing))) {
318
+ return { proxy: existing, fingerprint, started: false };
319
+ }
320
+ if (existing)
321
+ await stopProxyProcess(existing);
322
+ const proxy = await startProxyProcess(config, apiKey);
323
+ return { proxy, fingerprint, started: true };
324
+ }
310
325
  export async function runProxy(config) {
311
326
  const apiKey = await resolveApiKey(config.api_key_env);
312
327
  const state = readState(config.state_dir);
313
328
  if (!state)
314
- throw new CPACError("no active CPAC injection; run cpac inject first");
315
- originalBytes(config.state_dir, state, config.codex_config);
329
+ throw new CPACError("no active CPAC state; run cpac inject or install an agent first");
316
330
  const recorded = stateProxy(state);
317
331
  if (!recorded) {
318
- throw new CPACError("injection has no loopback proxy state; run cpac inject to migrate it");
332
+ throw new CPACError("state has no loopback proxy; run cpac inject or install kimi/grok first");
319
333
  }
320
334
  if (await proxyIsHealthy(recorded)) {
321
335
  throw new CPACError(`loopback proxy is already running on 127.0.0.1:${recorded.port}`);
322
336
  }
323
337
  const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
324
338
  try {
325
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state.config_existed, state.config_mode, { id: recorded.id, pid: process.pid, port: proxy.port }, proxyFingerprint(config, apiKey), injectedOwnership(state)));
339
+ saveState(config.state_dir, {
340
+ ...state,
341
+ proxy_id: recorded.id,
342
+ proxy_pid: process.pid,
343
+ proxy_port: proxy.port,
344
+ proxy_fingerprint: proxyFingerprint(config, apiKey),
345
+ });
326
346
  }
327
347
  catch (error) {
328
348
  proxy.server.closeAllConnections?.();
@@ -1,7 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, sha256Hex, stateBytes, stateProxy, } from "../config.js";
4
- import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "../proxy.js";
3
+ import { PROVIDER, STATE_FILES, cleanupStateFiles, dropAgent, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, readState, reorderCatalog, saveState, sha256Hex, stateBytes, } from "../config.js";
4
+ import { ensureLoopbackProxy, stopProxyProcess } from "../proxy.js";
5
5
  import { CPACError, atomicWrite, dominantEol, tomlString } from "../util.js";
6
6
  const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
7
7
  const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
@@ -251,10 +251,14 @@ export async function inject(config, v2Off = false, maxContext = false) {
251
251
  if (!apiKey)
252
252
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
253
253
  const state = readState(config.state_dir);
254
+ const prior = state;
255
+ if (!state?.config_path && !existsSync(config.codex_config)) {
256
+ throw new CPACError(`Codex config not found at ${config.codex_config}. Is Codex installed?`);
257
+ }
254
258
  let existed;
255
259
  let original;
256
260
  let originalMode;
257
- if (state) {
261
+ if (state?.config_path) {
258
262
  const backup = originalBytes(config.state_dir, state, config.codex_config);
259
263
  try {
260
264
  original = existsSync(config.codex_config)
@@ -286,31 +290,20 @@ export async function inject(config, v2Off = false, maxContext = false) {
286
290
  catalog.bytes = liftContextWindows(catalog.bytes);
287
291
  const stateDirExisted = existsSync(config.state_dir);
288
292
  if (!state &&
289
- (STATE_FILES.some((name) => existsSync(join(config.state_dir, name))) ||
290
- existsSync(join(config.state_dir, "codex", "config.toml")))) {
293
+ STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
291
294
  throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
292
295
  }
293
- const fingerprint = proxyFingerprint(config, apiKey);
294
- const previousProxy = state ? stateProxy(state) : null;
295
296
  let proxy;
297
+ let fingerprint;
296
298
  let startedProxy = false;
297
- if (previousProxy &&
298
- state?.proxy_fingerprint === fingerprint &&
299
- (config.codex_proxy_port === 0 ||
300
- config.codex_proxy_port === previousProxy.port) &&
301
- (await proxyIsHealthy(previousProxy))) {
302
- proxy = previousProxy;
299
+ try {
300
+ const ensured = await ensureLoopbackProxy(config, apiKey);
301
+ proxy = ensured.proxy;
302
+ fingerprint = ensured.fingerprint;
303
+ startedProxy = ensured.started;
303
304
  }
304
- else {
305
- if (previousProxy)
306
- await stopProxyProcess(previousProxy);
307
- try {
308
- proxy = await startProxyProcess(config, apiKey);
309
- startedProxy = true;
310
- }
311
- catch (error) {
312
- throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
313
- }
305
+ catch (error) {
306
+ throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
314
307
  }
315
308
  const catalogPath = join(config.state_dir, "codex-models.json");
316
309
  let injected;
@@ -335,11 +328,11 @@ export async function inject(config, v2Off = false, maxContext = false) {
335
328
  mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
336
329
  if (!stateDirExisted)
337
330
  chmodSync(config.state_dir, 0o700);
338
- if (state) {
331
+ if (state?.config_path) {
339
332
  try {
340
333
  atomicWrite(catalogPath, catalog.bytes);
341
334
  atomicWrite(config.codex_config, injected, originalMode);
342
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
335
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership, prior));
343
336
  }
344
337
  catch (error) {
345
338
  if (startedProxy)
@@ -350,12 +343,11 @@ export async function inject(config, v2Off = false, maxContext = false) {
350
343
  else {
351
344
  let configWritten = false;
352
345
  try {
353
- if (existed)
354
- atomicWrite(join(config.state_dir, "codex", "config.toml"), original);
346
+ atomicWrite(join(config.state_dir, "codex", "config.toml"), original);
355
347
  atomicWrite(catalogPath, catalog.bytes);
356
348
  atomicWrite(config.codex_config, injected, originalMode);
357
349
  configWritten = true;
358
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
350
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership, prior));
359
351
  }
360
352
  catch (error) {
361
353
  if (configWritten) {
@@ -371,7 +363,10 @@ export async function inject(config, v2Off = false, maxContext = false) {
371
363
  }
372
364
  let cleanupError;
373
365
  try {
374
- cleanupStateFiles(config.state_dir);
366
+ if (prior)
367
+ saveState(config.state_dir, prior);
368
+ else
369
+ cleanupStateFiles(config.state_dir);
375
370
  }
376
371
  catch (caught) {
377
372
  cleanupError = caught;
@@ -397,8 +392,8 @@ export async function inject(config, v2Off = false, maxContext = false) {
397
392
  }
398
393
  export async function restore(config) {
399
394
  const state = readState(config.state_dir);
400
- if (!state)
401
- throw new CPACError("no active CPAC injection");
395
+ if (!state?.config_path)
396
+ throw new CPACError("no active CPAC Codex injection");
402
397
  const original = originalBytes(config.state_dir, state, config.codex_config);
403
398
  const current = existsSync(config.codex_config)
404
399
  ? readFileSync(config.codex_config)
@@ -419,11 +414,18 @@ export async function restore(config) {
419
414
  else
420
415
  atomicWrite(config.codex_config, stripped, state.config_mode);
421
416
  }
422
- const proxy = stateProxy(state);
423
- if (proxy)
424
- await stopProxyProcess(proxy);
417
+ const { stopProxy } = dropAgent(config.state_dir, "codex");
418
+ if (stopProxy)
419
+ await stopProxyProcess(stopProxy);
425
420
  try {
426
- cleanupStateFiles(config.state_dir);
421
+ if (readState(config.state_dir)) {
422
+ rmSync(join(config.state_dir, "codex", "config.toml"), { force: true });
423
+ rmSync(join(config.state_dir, "config.toml.backup"), { force: true });
424
+ rmSync(join(config.state_dir, "codex-models.json"), { force: true });
425
+ }
426
+ else {
427
+ cleanupStateFiles(config.state_dir);
428
+ }
427
429
  }
428
430
  catch (error) {
429
431
  throw new CPACError(`config restored, but state cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { catalogModelId, catalogModelRows, fetchCatalog, readState, stateProxy, } from "../config.js";
5
- import { proxyIsHealthy } from "../proxy.js";
4
+ import { catalogModelId, catalogModelRows, dropAgent, fetchCatalog, recordAgent, removeCreatedTarget, } from "../config.js";
5
+ import { ensureLoopbackProxy, stopProxyProcess } from "../proxy.js";
6
6
  import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, resolveApiKey, tomlString, } from "../util.js";
7
7
  export function grokHome() {
8
8
  const home = process.env.GROK_HOME?.trim() || join(homedir(), ".grok");
@@ -70,12 +70,8 @@ export async function installGrokConfig(config) {
70
70
  const rows = catalogModelRows(document) ?? [];
71
71
  if (rows.length === 0)
72
72
  throw new CPACError("CPA catalog contains no models");
73
- const state = readState(config.state_dir);
74
- const recorded = state ? stateProxy(state) : null;
75
- const port = recorded?.port ?? config.codex_proxy_port;
76
- if (!port) {
77
- throw new CPACError("loopback proxy port unknown; run cpac inject first");
78
- }
73
+ const { proxy, fingerprint, started } = await ensureLoopbackProxy(config, apiKey);
74
+ const port = proxy.port;
79
75
  const lines = [GROK_BLOCK_START];
80
76
  for (const row of rows) {
81
77
  const slug = catalogModelId(row);
@@ -87,23 +83,38 @@ export async function installGrokConfig(config) {
87
83
  const name = typeof row.display_name === "string" && row.display_name.trim()
88
84
  ? `${row.display_name.trim()} (CPAC)`
89
85
  : `CPAC ${slug}`;
90
- lines.push(`[model."cpac/${slug}"]`, `model = ${tomlString(slug)}`, `base_url = "http://127.0.0.1:${port}/v1"`, 'api_backend = "responses"', '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.', 'api_key = "cpac-loopback"', `name = ${tomlString(name)}`, `context_window = ${context}`, "");
86
+ lines.push(`[model."cpac/${slug}"]`, `model = ${tomlString(slug)}`, `base_url = "http://127.0.0.1:${port}/v1"`, 'api_backend = "responses"', "# Placeholder: the CPAC loopback proxy replaces it with the CPA key.", 'api_key = "cpac-loopback"', `name = ${tomlString(name)}`, `context_window = ${context}`, "");
91
87
  }
92
88
  if (lines[lines.length - 1] === "")
93
89
  lines.pop();
94
90
  lines.push(GROK_BLOCK_END);
95
91
  const target = grokConfigPath();
96
- ensureCpacBackup(config.state_dir, "grok", target);
97
- writeGrokBlock(target, lines.join("\n"));
98
- console.log(`Installed Grok Build provider config: ${target}`);
99
- if (!recorded || !(await proxyIsHealthy(recorded))) {
100
- console.log("Loopback proxy is not running; run: cpac inject");
92
+ const hadFile = existsSync(target);
93
+ try {
94
+ ensureCpacBackup(config.state_dir, "grok", target, !isGrokConfigInstalled());
95
+ writeGrokBlock(target, lines.join("\n"));
96
+ recordAgent(config.state_dir, "grok", target, {
97
+ id: proxy.id,
98
+ pid: proxy.pid,
99
+ port: proxy.port,
100
+ fingerprint,
101
+ }, hadFile);
101
102
  }
103
+ catch (error) {
104
+ if (started)
105
+ await stopProxyProcess(proxy);
106
+ throw error;
107
+ }
108
+ console.log(`Installed Grok Build provider config: ${target}`);
102
109
  }
103
- export async function uninstallGrokConfig() {
110
+ export async function uninstallGrokConfig(config) {
104
111
  const target = grokConfigPath();
105
112
  if (!isGrokConfigInstalled())
106
113
  throw new CPACError("Grok Build config is not installed");
107
114
  writeGrokBlock(target, null);
115
+ removeCreatedTarget(config.state_dir, "grok", target);
116
+ const { stopProxy } = dropAgent(config.state_dir, "grok");
117
+ if (stopProxy)
118
+ await stopProxyProcess(stopProxy);
108
119
  console.log(`Removed Grok Build provider config: ${target}`);
109
120
  }
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { catalogModelId, catalogModelRows, fetchCatalog, readState, stateProxy, } from "../config.js";
5
- import { proxyIsHealthy } from "../proxy.js";
4
+ import { catalogModelId, catalogModelRows, dropAgent, fetchCatalog, recordAgent, removeCreatedTarget, } from "../config.js";
5
+ import { ensureLoopbackProxy, stopProxyProcess } from "../proxy.js";
6
6
  import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, objectValue, resolveApiKey, tomlString, tomlStringArray, } from "../util.js";
7
7
  export function kimiConfigPath() {
8
8
  const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), ".kimi-code");
@@ -111,18 +111,14 @@ export async function installKimiConfig(config) {
111
111
  const rows = catalogModelRows(document) ?? [];
112
112
  if (rows.length === 0)
113
113
  throw new CPACError("CPA catalog contains no models");
114
- const state = readState(config.state_dir);
115
- const recorded = state ? stateProxy(state) : null;
116
- const port = recorded?.port ?? config.codex_proxy_port;
117
- if (!port) {
118
- throw new CPACError("loopback proxy port unknown; run cpac inject first");
119
- }
114
+ const { proxy, fingerprint, started } = await ensureLoopbackProxy(config, apiKey);
115
+ const port = proxy.port;
120
116
  const lines = [
121
117
  KIMI_BLOCK_START,
122
118
  "[providers.cpac]",
123
119
  'type = "openai"',
124
120
  `base_url = "http://127.0.0.1:${port}/v1"`,
125
- '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.',
121
+ "# Placeholder: the CPAC loopback proxy replaces it with the CPA key.",
126
122
  'api_key = "cpac-loopback"',
127
123
  ];
128
124
  for (const row of rows) {
@@ -159,17 +155,32 @@ export async function installKimiConfig(config) {
159
155
  }
160
156
  lines.push(KIMI_BLOCK_END);
161
157
  const target = kimiConfigPath();
162
- ensureCpacBackup(config.state_dir, "kimi", target);
163
- writeKimiBlock(target, lines.join("\n"));
164
- console.log(`Installed Kimi Code provider config: ${target}`);
165
- if (!recorded || !(await proxyIsHealthy(recorded))) {
166
- console.log("Loopback proxy is not running; run: cpac inject");
158
+ const hadFile = existsSync(target);
159
+ try {
160
+ ensureCpacBackup(config.state_dir, "kimi", target, !isKimiConfigInstalled());
161
+ writeKimiBlock(target, lines.join("\n"));
162
+ recordAgent(config.state_dir, "kimi", target, {
163
+ id: proxy.id,
164
+ pid: proxy.pid,
165
+ port: proxy.port,
166
+ fingerprint,
167
+ }, hadFile);
167
168
  }
169
+ catch (error) {
170
+ if (started)
171
+ await stopProxyProcess(proxy);
172
+ throw error;
173
+ }
174
+ console.log(`Installed Kimi Code provider config: ${target}`);
168
175
  }
169
- export async function uninstallKimiConfig() {
176
+ export async function uninstallKimiConfig(config) {
170
177
  const target = kimiConfigPath();
171
178
  if (!isKimiConfigInstalled())
172
179
  throw new CPACError("Kimi Code config is not installed");
173
180
  writeKimiBlock(target, null);
181
+ removeCreatedTarget(config.state_dir, "kimi", target);
182
+ const { stopProxy } = dropAgent(config.state_dir, "kimi");
183
+ if (stopProxy)
184
+ await stopProxyProcess(stopProxy);
174
185
  console.log(`Removed Kimi Code provider config: ${target}`);
175
186
  }
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { dropAgent, recordAgent } from "../config.js";
6
+ import { stopProxyProcess } from "../proxy.js";
5
7
  import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, } from "../util.js";
6
8
  export function piExtensionsDir() {
7
9
  return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
@@ -20,8 +22,9 @@ export async function installPiExtension(config) {
20
22
  const dir = piExtensionsDir();
21
23
  mkdirSync(dir, { recursive: true, mode: 0o700 });
22
24
  const target = join(dir, "pi-cpac.ts");
23
- ensureCpacBackup(config.state_dir, "pi", target);
25
+ ensureCpacBackup(config.state_dir, "pi", target, !isPiExtensionInstalled());
24
26
  atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
27
+ recordAgent(config.state_dir, "pi", target);
25
28
  for (const legacy of ["cpac.ts", "pi-cpa.ts"]) {
26
29
  const legacyPath = join(dir, legacy);
27
30
  if (existsSync(legacyPath) && legacyPath !== target) {
@@ -33,7 +36,7 @@ export async function installPiExtension(config) {
33
36
  }
34
37
  console.log(`Installed Pi extension: ${target}`);
35
38
  }
36
- export async function uninstallPiExtension() {
39
+ export async function uninstallPiExtension(config) {
37
40
  const dir = piExtensionsDir();
38
41
  const target = join(dir, "pi-cpac.ts");
39
42
  const legacyTargets = [join(dir, "cpac.ts"), join(dir, "pi-cpa.ts")];
@@ -47,4 +50,7 @@ export async function uninstallPiExtension() {
47
50
  }
48
51
  if (!removed)
49
52
  throw new CPACError("Pi extension is not installed");
53
+ const { stopProxy } = dropAgent(config.state_dir, "pi");
54
+ if (stopProxy)
55
+ await stopProxyProcess(stopProxy);
50
56
  }
package/dist/util.js CHANGED
@@ -9,17 +9,16 @@ export class CPACError extends Error {
9
9
  export function objectValue(value) {
10
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
11
  }
12
- export function ensureCpacBackup(stateDir, agent, target) {
12
+ export function ensureCpacBackup(stateDir, agent, target, native = false) {
13
13
  const dest = join(stateDir, agent, basename(target));
14
14
  const sidecar = `${target}.cpac-backup`;
15
- if (!existsSync(dest)) {
16
- const source = existsSync(sidecar)
17
- ? sidecar
18
- : existsSync(target)
19
- ? target
20
- : null;
21
- if (!source)
22
- return;
15
+ if (existsSync(dest) && !native) {
16
+ if (existsSync(sidecar))
17
+ unlinkSync(sidecar);
18
+ return;
19
+ }
20
+ const source = native && existsSync(target) ? target : existsSync(sidecar) ? sidecar : null;
21
+ if (source) {
23
22
  mkdirSync(dirname(dest), { recursive: true, mode: 0o700 });
24
23
  try {
25
24
  chmodSync(stateDir, 0o700);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {