@yhong91/cpac 0.1.32 → 0.1.33

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
@@ -174,8 +174,8 @@ cpac -v | --version
174
174
 
175
175
  - `detect` 列出每个目标是否被检测到、是否已接入 CPAC;`--json` 输出机器可读结果。
176
176
  - `install` 默认作用于检测到的目标;`--target` 指定目标、`--all` 全部。各目标映射到既有逻辑:`codex` → `cpac inject`,`pi` → `cpac pi install`,`kimi` → `cpac kimi install`。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
177
- - `sync` 按当前 `cpa_url` 和 CPA 目录强制刷新**已经接入**的目标(Codex 重新注入、Pi 扩展重写、Kimi 配置重写),用来在 CPA 地址或模型目录变化后一次对齐本地配置。不带参数只动已安装目标;`--all` 会对检测到的目标做同样的强制刷新(尚未安装的会装上)。`--max_context` 仍只对 `--target codex` 有效。
178
- - Codex 注入默认开启 multi-agent v2(写入 `[features.multi_agent_v2] enabled = true`);`install --target codex --v2_off` 写入 `enabled = false`。服务端还需在 CPA 配置中开启 `codex.optimize-multi-agent-v2`。
177
+ - `sync` 按当前 `cpa_url` 和 CPA 目录强制刷新**已经接入**的目标(Codex 重新注入、Pi 确认 `npm:@yhong91/cpac` 并清理旧的 `extensions/pi-cpac.ts`、Kimi 配置重写),用来在 CPA 地址或模型目录变化后一次对齐本地配置。不带参数只动已安装目标;`--all` 会对检测到的目标做同样的强制刷新(尚未安装的会装上)。`--max_context` 仍只对 `--target codex` 有效。
178
+ - Codex 注入默认开启 multi-agent v2(写入 `[features.multi_agent_v2] enabled = true`,catalog 行打 `multi_agent_version: "v2"`);`install --target codex --v2_off` 关掉 toml 并去掉 catalog 戳。v2 开着时会剥掉 `[agents] max_threads`(Codex 否则拒启动)。`cpac status` 对照 config 里的 `v2_off` / `max_context` 和 live toml / catalog。服务端还需在 CPA 配置中开启 `codex.optimize-multi-agent-v2`。
179
179
  - `--v2_models` 进入 arrow-key 复选框选择最多 5 个 spawn_agent 模型(勾选顺序即推荐顺序),选择结果存入配置文件的 `spawn_models`,注入时把这些模型排到 `codex-models.json` 最前——Codex 客户端只把 catalog 前 5 个可见模型广告为 spawn 覆盖项。
180
180
  - `restore` 必须带 `--target` 或 `--all`,卸掉指定(或全部)已注入目标。`--all` 时未安装的目标静默跳过。
181
181
  - `uninstall` 先用和 `cpac codex --setup` 相同的方向键选择器确认(默认 Cancel),再 `restore --all`,最后 `npm uninstall -g @yhong91/cpac`。不要用 `--target`。非终端取消。`--dry-run` 跳过确认。
@@ -248,7 +248,7 @@ 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
- `config.toml` 不存在时拒绝注入。当前是 native 时把原文件备份到 `state_dir/codex/config.toml`(覆盖更旧的 native 快照);已注入后再 inject 不覆盖备份。kimi/grok/pi 同样:没有旧配置不写空备份,native 覆盖旧快照,已接入则保留。`restore --target codex`:若 `config.toml` 相对注入未改,按原 mode 逐字节还原备份;若用户在注入期间改过文件,只撤掉 CPAC 写入的 catalog、url 和标记。没有其它需要代理的 agent 时才关闭代理。
251
+ `config.toml` 不存在时拒绝注入。当前是 native 时把原文件备份到 `state_dir/codex/config.toml`(覆盖更旧的 native 快照);已注入后再 inject 不覆盖备份。kimi/grok 同样:没有旧配置不写空备份,native 覆盖旧快照,已接入则保留。Pi 只改 `settings.json` 的 `packages`,不备份。`restore --target codex`:若 `config.toml` 相对注入未改,按原 mode 逐字节还原备份;若用户在注入期间改过文件,只撤掉 CPAC 写入的 catalog、url 和标记。没有其它需要代理的 agent 时才关闭代理。
252
252
 
253
253
  loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 或 `cpac kimi` / `cpac grok` 即可恢复。
254
254
 
@@ -267,7 +267,6 @@ Codex 配置:$CODEX_HOME/config.toml,未设置 CODEX_HOME 时为 ~/.codex/co
267
267
  CPAC state:~/.cpac/state.json(公用 proxy 字段 + 已接入 agent 记录)
268
268
  各 agent 配置备份:~/.cpac/<agent>/<原文件名>
269
269
  Codex ~/.cpac/codex/config.toml
270
- Pi ~/.cpac/pi/pi-cpac.ts
271
270
  Kimi ~/.cpac/kimi/config.toml
272
271
  Grok ~/.cpac/grok/config.toml
273
272
  restore / `<agent> clear` 会删掉该 agent 的备份目录和 state.json 里的对应记录
@@ -291,13 +290,13 @@ cpac opencode [--] [opencode args...]
291
290
  cpac pi install
292
291
  ```
293
292
 
294
- 它将生成 `pi-cpac.ts` 写入 `~/.pi/agent/extensions/`,Pi 启动时自动加载并从 CPA 动态注册模型目录。卸载:
293
+ `~/.pi/agent/settings.json` 的 `packages` 中加入 `npm:@yhong91/cpac`。Pi 启动时按 npm 包加载 `extensions/pi-cpac.ts`,从 CPA 动态注册模型。旧版写入的 `~/.pi/agent/extensions/pi-cpac.ts` 会在 install / restore 时删掉。卸载:
295
294
 
296
295
  ```bash
297
296
  cpac restore --target pi
298
297
  ```
299
298
 
300
- Pi 扩展尊重 `PI_CODING_AGENT_DIR` 环境变量。
299
+ 尊重 `PI_CODING_AGENT_DIR`。扩展代码随 npm 包更新,已装过的副本用 `pi update npm:@yhong91/cpac`(或 `pi update --extensions`)。
301
300
 
302
301
  ### Kimi Code
303
302
 
@@ -321,7 +320,7 @@ Kimi Code 配置目录尊重 `KIMI_CODE_HOME` 环境变量。首次写入前若
321
320
  | --- | --- | --- |
322
321
  | `CPA_API_KEY` | 无 | CPA Bearer key;可由 `cpac` 首次引导写入 shell 启动文件 |
323
322
  | `CPA_BASE_URL` | `http://124.223.178.52:8317` | 覆盖内置 CPA 地址 |
324
- | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | 覆盖 Pi agent 目录(影响 `cpac pi install` 写入位置) |
323
+ | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | 覆盖 Pi agent 目录(影响 `settings.json` 写入位置) |
325
324
  | `KIMI_CODE_HOME` | `~/.kimi-code` | 覆盖 Kimi Code 目录(影响 `cpac kimi install` 写入位置) |
326
325
  | `CPAC_CONFIG` | `~/.config/cpac/config.json` | 指定可选 JSON 配置路径 |
327
326
  | `CODEX_HOME` | `~/.codex` | Codex home 目录 |
@@ -332,7 +331,7 @@ CPAC CLI 可临时覆盖 CPA 地址:
332
331
  CPA_BASE_URL='https://cpa.example.com' cpac claude
333
332
  ```
334
333
 
335
- `pi-cpac.ts` 插件固定连接配置指定的 CPA 地址,并且只从 Pi 进程环境读取 `CPA_API_KEY`;不读取用户 home 中的私有文件。
334
+ Pi 扩展只从进程环境读取 `CPA_BASE_URL`(默认内置地址)和 `CPA_API_KEY`;不读取 `config.json` `cpa_url`,也不读用户 home 中的私有文件。
336
335
 
337
336
  ## 可选 JSON 配置
338
337
 
package/cpac.example.json CHANGED
@@ -7,5 +7,7 @@
7
7
  "sonnet": "claude-sonnet-4-6",
8
8
  "haiku": "glm-5.3"
9
9
  },
10
- "spawn_models": ["gpt-5.5", "claude-opus-4-8"]
10
+ "spawn_models": ["gpt-5.5", "claude-opus-4-8"],
11
+ "max_context": false,
12
+ "v2_off": false
11
13
  }
package/dist/agents.js CHANGED
@@ -3,12 +3,16 @@ 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 { pickSpawnModels, saveSpawnModels } from "./config.js";
6
+ import { MAX_SPAWN_MODELS, loadCatalogSlugs, pickSpawnModels, saveCodexSetup, saveSpawnModels, } from "./config.js";
7
7
  import { MANAGED_MARKER, inject, restore } from "./targets/codex.js";
8
8
  import { grokConfigPath, grokHome, installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
9
9
  import { installKimiConfig, isKimiConfigInstalled, kimiConfigPath, uninstallKimiConfig, } from "./targets/kimi.js";
10
- import { installPiExtension, isPiExtensionInstalled, piExtensionsDir, uninstallPiExtension, } from "./targets/pi.js";
11
- import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, } from "./util.js";
10
+ import { PI_PACKAGE, installPiExtension, isPiExtensionInstalled, piAgentDir, piSettingsPath, uninstallPiExtension, } from "./targets/pi.js";
11
+ import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, tabWizard, } from "./util.js";
12
+ const CODEX_CTX_STD = "Standard (default input budget, e.g. 200K~272K)";
13
+ const CODEX_CTX_MAX = "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)";
14
+ const CODEX_V2_ON = "Enabled (Default)";
15
+ const CODEX_V2_OFF = "Disabled (--v2_off)";
12
16
  export async function runCodexConfig(config, configPath, options = {}) {
13
17
  if (options.reset) {
14
18
  let raw = {};
@@ -26,38 +30,75 @@ export async function runCodexConfig(config, configPath, options = {}) {
26
30
  }
27
31
  }
28
32
  delete raw.spawn_models;
33
+ delete raw.max_context;
34
+ delete raw.v2_off;
29
35
  atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
30
36
  await runRestore(config, ["codex"], { all: false, dryRun: false });
31
37
  console.log("Codex configuration reset to default");
32
38
  return 0;
33
39
  }
34
40
  if (options.interactive && (!process.stdin.isTTY || !process.stderr.isTTY)) {
35
- console.log(config.spawn_models
36
- ? JSON.stringify(config.spawn_models, null, 2)
37
- : "spawn_models not set");
41
+ console.log(JSON.stringify({
42
+ spawn_models: config.spawn_models ?? [],
43
+ max_context: !!config.max_context,
44
+ v2_off: !!config.v2_off,
45
+ }, null, 2));
38
46
  return 0;
39
47
  }
40
48
  let spawnModels = config.spawn_models;
41
- let v2Off = options.v2Off ?? false;
42
- let maxContext = options.maxContext ?? false;
43
- if (options.interactive || options.v2Models) {
49
+ let v2Off = options.v2Off || !!config.v2_off;
50
+ let maxContext = options.maxContext || !!config.max_context;
51
+ if (options.interactive) {
52
+ const slugs = await loadCatalogSlugs(config);
53
+ const picked = await tabWizard([
54
+ {
55
+ id: "models",
56
+ label: "Models",
57
+ items: slugs,
58
+ max: MAX_SPAWN_MODELS,
59
+ selected: config.spawn_models?.filter((slug) => slugs.includes(slug)),
60
+ },
61
+ {
62
+ id: "context",
63
+ label: "Context",
64
+ items: [CODEX_CTX_STD, CODEX_CTX_MAX],
65
+ max: 1,
66
+ selected: [maxContext ? CODEX_CTX_MAX : CODEX_CTX_STD],
67
+ },
68
+ {
69
+ id: "v2",
70
+ label: "V2",
71
+ items: [CODEX_V2_ON, CODEX_V2_OFF],
72
+ max: 1,
73
+ selected: [v2Off ? CODEX_V2_OFF : CODEX_V2_ON],
74
+ },
75
+ ]);
76
+ if (picked.models.length === 0)
77
+ throw new CPACError("no models selected");
78
+ spawnModels = picked.models;
79
+ maxContext = picked.context[0] === CODEX_CTX_MAX;
80
+ v2Off = picked.v2[0] === CODEX_V2_OFF;
81
+ saveCodexSetup(configPath, {
82
+ spawn_models: picked.models,
83
+ max_context: maxContext,
84
+ v2_off: v2Off,
85
+ });
86
+ console.log(`codex setup saved to ${configPath}: models=${picked.models.join(", ")} max_context=${maxContext} v2_off=${v2Off}`);
87
+ }
88
+ else if (options.v2Models) {
44
89
  const picked = await pickSpawnModels(config);
45
90
  if (picked.length > 0) {
46
91
  spawnModels = picked;
47
92
  saveSpawnModels(configPath, picked);
48
93
  console.log(`spawn_models saved to ${configPath}: ${picked.join(", ")}`);
49
94
  }
50
- if (options.interactive) {
51
- const [maxCtxChoice] = await checkboxPicker("Codex Context Window Size:", [
52
- "Standard (default input budget, e.g. 200K~272K)",
53
- "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)",
54
- ], 1);
55
- maxContext = maxCtxChoice?.startsWith("Max Context") ?? false;
56
- const [v2Choice] = await checkboxPicker("Codex Multi-Agent V2 mode:", ["Enabled (Default)", "Disabled (--v2_off)"], 1);
57
- v2Off = v2Choice === "Disabled (--v2_off)";
58
- }
59
95
  }
60
- const updatedConfig = { ...config, spawn_models: spawnModels };
96
+ const updatedConfig = {
97
+ ...config,
98
+ spawn_models: spawnModels,
99
+ max_context: maxContext || undefined,
100
+ v2_off: v2Off || undefined,
101
+ };
61
102
  await runInstall(updatedConfig, ["codex"], {
62
103
  all: false,
63
104
  dryRun: false,
@@ -174,20 +215,20 @@ const TARGETS = [
174
215
  },
175
216
  {
176
217
  id: "pi",
177
- home: () => piExtensionsDir(),
178
- detected: () => binaryOnPath("pi") || existsSync(piExtensionsDir()),
218
+ home: () => piAgentDir(),
219
+ detected: () => binaryOnPath("pi") || existsSync(piAgentDir()),
179
220
  installed: () => isPiExtensionInstalled(),
180
221
  version: () => detectClientVersion("pi"),
181
222
  install: async (config, dryRun) => {
182
223
  if (dryRun) {
183
- console.log(`would write Pi extension: ${join(piExtensionsDir(), "pi-cpac.ts")}`);
224
+ console.log(`would add ${PI_PACKAGE} to ${piSettingsPath()}`);
184
225
  return;
185
226
  }
186
227
  await installPiExtension(config);
187
228
  },
188
229
  uninstall: async (config, dryRun) => {
189
230
  if (dryRun) {
190
- console.log(`would remove Pi extension: ${join(piExtensionsDir(), "pi-cpac.ts")}`);
231
+ console.log(`would remove ${PI_PACKAGE} from ${piSettingsPath()}`);
191
232
  return;
192
233
  }
193
234
  await uninstallPiExtension(config);
package/dist/config.js CHANGED
@@ -15,10 +15,12 @@ const CONFIG_KEYS = new Set([
15
15
  "state_dir",
16
16
  "claude_models",
17
17
  "spawn_models",
18
+ "max_context",
19
+ "v2_off",
18
20
  ]);
19
21
  // Codex advertises only the first 5 picker-visible catalog models as
20
22
  // spawn_agent overrides (codex-rs MAX_SPAWN_AGENT_MODEL_OVERRIDES).
21
- const MAX_SPAWN_MODELS = 5;
23
+ export const MAX_SPAWN_MODELS = 5;
22
24
  export const CLAUDE_SLOT_KEYS = ["opus", "sonnet", "haiku"];
23
25
  export const AGENT_IDS = ["codex", "pi", "kimi", "grok"];
24
26
  const AGENT_ID_SET = new Set(AGENT_IDS);
@@ -143,6 +145,12 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
143
145
  return model.trim();
144
146
  });
145
147
  }
148
+ const maxContextRaw = value.max_context;
149
+ if (maxContextRaw !== undefined && typeof maxContextRaw !== "boolean")
150
+ throw new CPACError("max_context must be a boolean");
151
+ const v2OffRaw = value.v2_off;
152
+ if (v2OffRaw !== undefined && typeof v2OffRaw !== "boolean")
153
+ throw new CPACError("v2_off must be a boolean");
146
154
  const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
147
155
  const codexConfig = resolve(expandUserPath(value.codex_config || join(codexHome, "config.toml")));
148
156
  const stateDir = resolve(expandUserPath(value.state_dir || join(homedir(), ".cpac")));
@@ -160,6 +168,8 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
160
168
  state_dir: stateDir,
161
169
  claude_models: claudeModels,
162
170
  spawn_models: spawnModels,
171
+ max_context: maxContextRaw === true ? true : undefined,
172
+ v2_off: v2OffRaw === true ? true : undefined,
163
173
  };
164
174
  }
165
175
  export function apiBase(cpaUrl) {
@@ -233,6 +243,10 @@ export async function fetchCatalog(cpaUrl, apiKey) {
233
243
  if (models.some((model) => !objectValue(model) || typeof model.slug !== "string" || !model.slug.trim())) {
234
244
  throw new CPACError("CPA catalog contains a model without a slug");
235
245
  }
246
+ for (const model of models) {
247
+ if (objectValue(model))
248
+ sanitizeModelModalities(model);
249
+ }
236
250
  return {
237
251
  bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
238
252
  modelCount: models.length,
@@ -292,7 +306,65 @@ export function reorderCatalog(bytes, order) {
292
306
  document.models = models.map((entry) => entry.model);
293
307
  return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
294
308
  }
295
- export function saveSpawnModels(configPath, models) {
309
+ function catalogDocument(bytes) {
310
+ try {
311
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
312
+ }
313
+ catch {
314
+ throw new CPACError("invalid CPA catalog");
315
+ }
316
+ }
317
+ // Codex parses input_modalities as a closed enum. One extra value (e.g. "video")
318
+ // rejects the entire catalog. Keep unknown fields, but coerce this one.
319
+ function sanitizeModelModalities(model) {
320
+ if (!("input_modalities" in model))
321
+ return;
322
+ const raw = model.input_modalities;
323
+ const accepted = Array.isArray(raw)
324
+ ? raw.filter((value) => value === "text" || value === "image" || value === "audio")
325
+ : [];
326
+ model.input_modalities = accepted.length > 0 ? accepted : ["text"];
327
+ }
328
+ export function sanitizeCatalogModalities(bytes) {
329
+ const document = catalogDocument(bytes);
330
+ for (const model of document.models) {
331
+ if (objectValue(model))
332
+ sanitizeModelModalities(model);
333
+ }
334
+ return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
335
+ }
336
+ export function stampMultiAgentVersion(bytes, enabled) {
337
+ const document = catalogDocument(bytes);
338
+ for (const model of document.models) {
339
+ if (!objectValue(model))
340
+ continue;
341
+ if (enabled)
342
+ model.multi_agent_version = "v2";
343
+ else
344
+ delete model.multi_agent_version;
345
+ }
346
+ return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
347
+ }
348
+ export function catalogMaxContextLive(bytes) {
349
+ const document = catalogDocument(bytes);
350
+ for (const model of document.models) {
351
+ if (!objectValue(model))
352
+ continue;
353
+ const maxWindow = typeof model.max_context_window === "number" && model.max_context_window > 0
354
+ ? Math.floor(model.max_context_window)
355
+ : 0;
356
+ const window = typeof model.context_window === "number"
357
+ ? Math.floor(model.context_window)
358
+ : 0;
359
+ if (maxWindow > 0 &&
360
+ window === maxWindow &&
361
+ model.auto_compact_token_limit === Math.floor(maxWindow * 0.9)) {
362
+ return true;
363
+ }
364
+ }
365
+ return false;
366
+ }
367
+ function patchConfig(configPath, fields) {
296
368
  let document = {};
297
369
  if (existsSync(configPath)) {
298
370
  let parsed;
@@ -306,23 +378,33 @@ export function saveSpawnModels(configPath, models) {
306
378
  throw new CPACError("config must be a JSON object");
307
379
  document = parsed;
308
380
  }
309
- document.spawn_models = models;
381
+ Object.assign(document, fields);
310
382
  atomicWrite(configPath, Buffer.from(`${JSON.stringify(document, null, 2)}\n`));
311
383
  }
312
- // Arrow-key checkbox picker on raw stdin; resolves with the checked labels in
313
- export async function pickSpawnModels(config) {
314
- const apiKey = process.env[config.api_key_env]?.trim();
315
- if (!apiKey)
316
- throw new CPACError(`environment variable ${config.api_key_env} is not set`);
317
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
384
+ export function saveSpawnModels(configPath, models) {
385
+ patchConfig(configPath, { spawn_models: models });
386
+ }
387
+ export function saveCodexSetup(configPath, setup) {
388
+ patchConfig(configPath, setup);
389
+ }
390
+ export function catalogSlugs(bytes) {
318
391
  let document;
319
392
  try {
320
- document = JSON.parse(new TextDecoder().decode(catalog.bytes));
393
+ document = JSON.parse(new TextDecoder().decode(bytes));
321
394
  }
322
395
  catch {
323
396
  throw new CPACError("invalid CPA catalog");
324
397
  }
325
- const slugs = document.models.map((model) => model.slug);
398
+ return document.models.map((model) => model.slug);
399
+ }
400
+ export async function loadCatalogSlugs(config) {
401
+ const apiKey = process.env[config.api_key_env]?.trim();
402
+ if (!apiKey)
403
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
404
+ return catalogSlugs((await fetchCatalog(config.cpa_url, apiKey)).bytes);
405
+ }
406
+ export async function pickSpawnModels(config) {
407
+ const slugs = await loadCatalogSlugs(config);
326
408
  const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
327
409
  if (picked.length === 0)
328
410
  throw new CPACError("no models selected");
package/dist/cpac.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
- import { resolve } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { CPAC_VERSION, runCodexConfig, runDetect, runInstall, runRestore, runSync, runTargetLauncher, runUninstall, runUpgrade, } from "./agents.js";
7
7
  import { runClaude, runClaudeConfig, runClaudeModels, } from "./targets/claude.js";
@@ -10,13 +10,13 @@ import { isGrokConfigInstalled } from "./targets/grok.js";
10
10
  import { isKimiConfigInstalled } from "./targets/kimi.js";
11
11
  import { runOpencode } from "./targets/opencode.js";
12
12
  import { isPiExtensionInstalled } from "./targets/pi.js";
13
- import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, } from "./config.js";
13
+ import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, catalogMaxContextLive, } from "./config.js";
14
14
  import { proxyIsHealthy, runProxy, runProxyChild } from "./proxy.js";
15
15
  import { CPACError, expandUserPath, promptSecret, saveApiKeyExport, shellProfile, } from "./util.js";
16
- export { PROVIDER, loadConfig, saveSpawnModels, liftContextWindows, reorderCatalog, readState, stateProxy, } from "./config.js";
16
+ export { PROVIDER, loadConfig, saveSpawnModels, saveCodexSetup, liftContextWindows, reorderCatalog, sanitizeCatalogModalities, stampMultiAgentVersion, catalogMaxContextLive, readState, stateProxy, } from "./config.js";
17
17
  export { saveApiKeyExport } from "./util.js";
18
18
  export { stopProxyProcess } from "./proxy.js";
19
- export { buildCodexConfig, hasProviderTable, inject, multiAgentV2Enabled, restore, withMultiAgentV2, } from "./targets/codex.js";
19
+ export { buildCodexConfig, hasProviderTable, inject, multiAgentV2Enabled, restore, stripAgentsMaxThreads, withMultiAgentV2, } from "./targets/codex.js";
20
20
  export { claudeTierSlots, createClaudeProxy, runClaude, } from "./targets/claude.js";
21
21
  export { detectClientVersion, detectTargets, runInstall, runRestore, runSync, runTargetLauncher, } from "./agents.js";
22
22
  export { installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
@@ -42,11 +42,23 @@ export async function status(config) {
42
42
  console.log(`codex: injected proxy=http://127.0.0.1:${proxy.port}/v1 sha256=${digest}`);
43
43
  }
44
44
  try {
45
- console.log(`codex multi-agent v2: ${multiAgentV2Enabled(readFileSync(config.codex_config, "utf8")) ? "on" : "off"}`);
45
+ const liveV2 = multiAgentV2Enabled(readFileSync(config.codex_config, "utf8"));
46
+ console.log(`codex v2: expected=${config.v2_off ? "off" : "on"} live=${liveV2 ? "on" : "off"}`);
46
47
  }
47
48
  catch {
48
49
  // Unreadable config: the injected line above already reports it.
49
50
  }
51
+ const catalogPath = join(config.state_dir, "codex-models.json");
52
+ let liveMax = false;
53
+ if (existsSync(catalogPath)) {
54
+ try {
55
+ liveMax = catalogMaxContextLive(readFileSync(catalogPath));
56
+ }
57
+ catch {
58
+ // Unreadable catalog: report live=off.
59
+ }
60
+ }
61
+ console.log(`codex max_context: expected=${config.max_context ? "on" : "off"} live=${liveMax ? "on" : "off"}`);
50
62
  }
51
63
  else {
52
64
  console.log(`codex: injected (config missing: ${config.codex_config})`);
@@ -223,6 +235,17 @@ function parseArgs(args) {
223
235
  interactive: false,
224
236
  };
225
237
  }
238
+ if (targetId === "codex") {
239
+ return {
240
+ command: "codex-config",
241
+ configPath,
242
+ v2Off: false,
243
+ v2Models: false,
244
+ maxContext: false,
245
+ reset: true,
246
+ interactive: false,
247
+ };
248
+ }
226
249
  return {
227
250
  command: "restore",
228
251
  configPath,
@@ -2,9 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { createServer, request as httpRequest, } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
- import { apiBase, catalogModelId, catalogModelRows, fetchCatalog, loadConfig, } from "../config.js";
5
+ import { apiBase, catalogModelId, catalogModelRows, catalogSlugs, fetchCatalog, loadConfig, } from "../config.js";
6
6
  import { proxyHeaders, responseHeaders, upstreamUrl } from "../proxy.js";
7
- import { CPACError, atomicWrite, checkboxPicker, objectValue, resolveApiKey, } from "../util.js";
7
+ import { CPACError, atomicWrite, objectValue, resolveApiKey, tabWizard, } from "../util.js";
8
8
  const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
9
9
  // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
10
10
  // A single global window cannot be per-model; 350K is opencodex's user-approved
@@ -235,25 +235,34 @@ export async function runClaudeConfig(config, configPath, options = {}) {
235
235
  if (shouldPick) {
236
236
  const apiKey = await resolveApiKey(config.api_key_env);
237
237
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
238
- let document;
239
- try {
240
- document = JSON.parse(new TextDecoder().decode(catalog.bytes));
241
- }
242
- catch {
243
- throw new CPACError("CPA catalog response is not valid JSON");
244
- }
245
- const slugs = (document.models ?? [])
246
- .map((model) => (typeof model?.slug === "string" ? model.slug : ""))
247
- .filter(Boolean);
238
+ const slugs = catalogSlugs(catalog.bytes);
239
+ const auto = "(auto-select from catalog)";
248
240
  const slotsToPick = options.pick && options.pick.length > 0
249
241
  ? options.pick
250
242
  : ["sonnet", "haiku", "opus"];
243
+ const current = { ...config.claude_models, ...models };
244
+ const picked = await tabWizard(slotsToPick.map((slot) => {
245
+ const items = slot === "sonnet" ? slugs : [auto, ...slugs];
246
+ const preset = current[slot];
247
+ const selected = preset && slugs.includes(preset)
248
+ ? [preset]
249
+ : slot === "sonnet"
250
+ ? undefined
251
+ : [auto];
252
+ return {
253
+ id: slot,
254
+ label: slot.toUpperCase(),
255
+ items,
256
+ max: 1,
257
+ selected,
258
+ };
259
+ }));
251
260
  for (const slot of slotsToPick) {
252
- const choices = slot === "sonnet" ? slugs : ["(auto-select from catalog)", ...slugs];
253
- const [picked] = await checkboxPicker(`Select Claude Code ${slot.toUpperCase()} tier model:`, choices, 1);
254
- if (picked && picked !== "(auto-select from catalog)") {
255
- models[slot] = picked;
256
- }
261
+ const choice = picked[slot]?.[0];
262
+ if (choice && choice !== auto)
263
+ models[slot] = choice;
264
+ else
265
+ delete models[slot];
257
266
  }
258
267
  }
259
268
  if (!Object.keys(models).length && !shouldPick) {
@@ -280,7 +289,19 @@ export async function runClaudeConfig(config, configPath, options = {}) {
280
289
  ? { ...raw.claude_models }
281
290
  : {};
282
291
  Object.assign(existing, models);
283
- raw.claude_models = existing;
292
+ if (shouldPick) {
293
+ const slots = options.pick && options.pick.length > 0
294
+ ? options.pick
295
+ : ["sonnet", "haiku", "opus"];
296
+ for (const slot of slots) {
297
+ if (!models[slot])
298
+ delete existing[slot];
299
+ }
300
+ }
301
+ if (Object.keys(existing).length === 0)
302
+ delete raw.claude_models;
303
+ else
304
+ raw.claude_models = existing;
284
305
  atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
285
306
  loadConfig(configPath);
286
307
  console.log(`Claude Code tier models updated in ${configPath}:\n${JSON.stringify(raw.claude_models, null, 2)}`);
@@ -1,6 +1,6 @@
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, dropAgent, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, readState, reorderCatalog, saveState, sha256Hex, stateBytes, } from "../config.js";
3
+ import { PROVIDER, STATE_FILES, cleanupStateFiles, dropAgent, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, readState, reorderCatalog, stampMultiAgentVersion, saveState, sha256Hex, stateBytes, } from "../config.js";
4
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*=/;
@@ -239,6 +239,27 @@ function hasAgentsMaxThreads(content) {
239
239
  }
240
240
  return false;
241
241
  }
242
+ export function stripAgentsMaxThreads(content) {
243
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
244
+ const kept = [];
245
+ let table = "root";
246
+ for (const line of lines) {
247
+ if (/^\s*\[/.test(line)) {
248
+ table = /^\s*\[\s*(?:agents|"agents"|'agents')\s*\]\s*(?:#.*)?$/.test(line)
249
+ ? "agents"
250
+ : "other";
251
+ kept.push(line);
252
+ continue;
253
+ }
254
+ if (table === "agents" && /^\s*max_threads\s*=/.test(line))
255
+ continue;
256
+ if (table === "root" &&
257
+ /^\s*(?:agents|"agents"|'agents')\s*\.\s*max_threads\s*=/.test(line))
258
+ continue;
259
+ kept.push(line);
260
+ }
261
+ return kept.join("\n");
262
+ }
242
263
  export function withMultiAgentV2(content, enabled, eol) {
243
264
  let body = stripMultiAgentV2(content);
244
265
  if (body && !body.endsWith("\n"))
@@ -247,6 +268,8 @@ export function withMultiAgentV2(content, enabled, eol) {
247
268
  return eol === "\n" ? output : output.replace(/\n/g, "\r\n");
248
269
  }
249
270
  export async function inject(config, v2Off = false, maxContext = false) {
271
+ v2Off = v2Off || !!config.v2_off;
272
+ maxContext = maxContext || !!config.max_context;
250
273
  const apiKey = process.env[config.api_key_env]?.trim();
251
274
  if (!apiKey)
252
275
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
@@ -288,6 +311,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
288
311
  catalog.bytes = reorderCatalog(catalog.bytes, config.spawn_models);
289
312
  if (maxContext)
290
313
  catalog.bytes = liftContextWindows(catalog.bytes);
314
+ catalog.bytes = stampMultiAgentVersion(catalog.bytes, !v2Off);
291
315
  const stateDirExisted = existsSync(config.state_dir);
292
316
  if (!state &&
293
317
  STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
@@ -309,11 +333,14 @@ export async function inject(config, v2Off = false, maxContext = false) {
309
333
  let injected;
310
334
  try {
311
335
  injected = buildCodexConfig(original, proxy.port, catalogPath);
312
- const injectedText = new TextDecoder("utf-8").decode(injected);
313
- injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, dominantEol(injectedText)));
336
+ let injectedText = new TextDecoder("utf-8").decode(injected);
337
+ const eol = dominantEol(injectedText);
314
338
  if (!v2Off && hasAgentsMaxThreads(injectedText)) {
315
- console.warn("warning: [agents] max_threads is set; codex refuses to start with multi_agent_v2 enabled, remove it");
339
+ const stripped = stripAgentsMaxThreads(injectedText);
340
+ injectedText = eol === "\n" ? stripped : stripped.replace(/\n/g, "\r\n");
341
+ console.warn("stripped [agents] max_threads (codex refuses it with multi_agent_v2)");
316
342
  }
343
+ injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, eol));
317
344
  }
318
345
  catch (error) {
319
346
  if (startedProxy)
@@ -1,51 +1,135 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
3
+ import { join } from "node:path";
5
4
  import { dropAgent, recordAgent } from "../config.js";
6
5
  import { stopProxyProcess } from "../proxy.js";
7
- import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, } from "../util.js";
8
- export function piExtensionsDir() {
9
- return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
6
+ import { CPACError, atomicWrite, expandUserPath, objectValue, } from "../util.js";
7
+ export const PI_PACKAGE = "npm:@yhong91/cpac";
8
+ const PI_PACKAGE_NAME = "@yhong91/cpac";
9
+ const LEGACY_EXTENSIONS = ["pi-cpac.ts", "cpac.ts", "pi-cpa.ts"];
10
+ export function piAgentDir() {
11
+ return expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"));
10
12
  }
11
- export function isPiExtensionInstalled() {
12
- const dir = piExtensionsDir();
13
- return existsSync(join(dir, "pi-cpac.ts")) || existsSync(join(dir, "cpac.ts"));
13
+ export function piSettingsPath() {
14
+ return join(piAgentDir(), "settings.json");
15
+ }
16
+ function leftoverExtensions() {
17
+ const dir = join(piAgentDir(), "extensions");
18
+ return LEGACY_EXTENSIONS.map((name) => join(dir, name)).filter(existsSync);
19
+ }
20
+ function specName(source) {
21
+ const rest = source.startsWith("npm:") ? source.slice(4) : source;
22
+ if (rest.startsWith("@")) {
23
+ const cut = rest.lastIndexOf("@");
24
+ return cut > 0 ? rest.slice(0, cut) : rest;
25
+ }
26
+ const cut = rest.lastIndexOf("@");
27
+ return cut === -1 ? rest : rest.slice(0, cut);
28
+ }
29
+ function packageSource(entry) {
30
+ if (typeof entry === "string")
31
+ return entry.replace(/^-/, "");
32
+ if (objectValue(entry) && typeof entry.source === "string")
33
+ return entry.source;
34
+ }
35
+ function isCpacPackage(entry) {
36
+ const source = packageSource(entry);
37
+ return source !== undefined && specName(source) === PI_PACKAGE_NAME;
38
+ }
39
+ function isEnabledCpacPackage(entry) {
40
+ if (!isCpacPackage(entry))
41
+ return false;
42
+ if (typeof entry === "string")
43
+ return !entry.startsWith("-");
44
+ return !objectValue(entry) || entry.enabled !== false;
45
+ }
46
+ function readSettings(path) {
47
+ let raw;
48
+ try {
49
+ raw = JSON.parse(readFileSync(path, "utf8"));
50
+ }
51
+ catch (error) {
52
+ if (error.code === "ENOENT")
53
+ return {};
54
+ throw new CPACError(`cannot parse Pi settings: ${path}`);
55
+ }
56
+ if (!objectValue(raw)) {
57
+ throw new CPACError(`Pi settings must be a JSON object: ${path}`);
58
+ }
59
+ return raw;
60
+ }
61
+ function settingsPackages(settings, path) {
62
+ if (!Object.hasOwn(settings, "packages"))
63
+ return undefined;
64
+ if (!Array.isArray(settings.packages)) {
65
+ throw new CPACError(`Pi settings packages must be an array: ${path}`);
66
+ }
67
+ return settings.packages;
68
+ }
69
+ function writeSettings(path, value) {
70
+ atomicWrite(path, Buffer.from(`${JSON.stringify(value, null, 2)}\n`));
14
71
  }
15
- function piTemplatePath() {
16
- return join(dirname(fileURLToPath(import.meta.url)), "..", "pi-extension.template");
72
+ function removeLeftovers() {
73
+ const removed = [];
74
+ for (const file of leftoverExtensions()) {
75
+ unlinkSync(file);
76
+ removed.push(file);
77
+ }
78
+ return removed;
17
79
  }
18
- function piExtensionContent(cpaUrl) {
19
- return readFileSync(piTemplatePath(), "utf8").replace("__CPA_URL__", cpaUrl);
80
+ export function isPiExtensionInstalled() {
81
+ if (leftoverExtensions().length > 0)
82
+ return true;
83
+ const path = piSettingsPath();
84
+ if (!existsSync(path))
85
+ return false;
86
+ try {
87
+ const packages = settingsPackages(readSettings(path), path);
88
+ return packages?.some(isEnabledCpacPackage) ?? false;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
20
93
  }
21
94
  export async function installPiExtension(config) {
22
- const dir = piExtensionsDir();
23
- mkdirSync(dir, { recursive: true, mode: 0o700 });
24
- const target = join(dir, "pi-cpac.ts");
25
- ensureCpacBackup(config.state_dir, "pi", target, !isPiExtensionInstalled());
26
- atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
27
- recordAgent(config.state_dir, "pi", target);
28
- for (const legacy of ["cpac.ts", "pi-cpa.ts"]) {
29
- const legacyPath = join(dir, legacy);
30
- if (existsSync(legacyPath) && legacyPath !== target) {
31
- try {
32
- unlinkSync(legacyPath);
33
- }
34
- catch { }
35
- }
95
+ const path = piSettingsPath();
96
+ mkdirSync(piAgentDir(), { recursive: true, mode: 0o700 });
97
+ const settings = existsSync(path) ? readSettings(path) : {};
98
+ const packages = settingsPackages(settings, path) ?? [];
99
+ if (!packages.some(isEnabledCpacPackage)) {
100
+ settings.packages = [
101
+ ...packages.filter((entry) => !isCpacPackage(entry)),
102
+ PI_PACKAGE,
103
+ ];
104
+ writeSettings(path, settings);
105
+ }
106
+ recordAgent(config.state_dir, "pi", path);
107
+ for (const file of removeLeftovers()) {
108
+ console.log(`Removed leftover Pi extension: ${file}`);
36
109
  }
37
- console.log(`Installed Pi extension: ${target}`);
110
+ console.log(`Installed Pi package ${PI_PACKAGE} in ${path}`);
38
111
  }
39
112
  export async function uninstallPiExtension(config) {
40
- const dir = piExtensionsDir();
41
- const target = join(dir, "pi-cpac.ts");
42
- const legacyTargets = [join(dir, "cpac.ts"), join(dir, "pi-cpa.ts")];
113
+ const path = piSettingsPath();
43
114
  let removed = false;
44
- for (const file of [target, ...legacyTargets]) {
45
- if (existsSync(file)) {
46
- unlinkSync(file);
47
- console.log(`Removed Pi extension: ${file}`);
48
- removed = true;
115
+ for (const file of removeLeftovers()) {
116
+ console.log(`Removed leftover Pi extension: ${file}`);
117
+ removed = true;
118
+ }
119
+ if (existsSync(path)) {
120
+ try {
121
+ const settings = readSettings(path);
122
+ const packages = settingsPackages(settings, path);
123
+ if (packages?.some(isCpacPackage)) {
124
+ settings.packages = packages.filter((entry) => !isCpacPackage(entry));
125
+ writeSettings(path, settings);
126
+ console.log(`Removed Pi package ${PI_PACKAGE} from ${path}`);
127
+ removed = true;
128
+ }
129
+ }
130
+ catch (error) {
131
+ if (!removed)
132
+ throw error;
49
133
  }
50
134
  }
51
135
  if (!removed)
package/dist/util.js CHANGED
@@ -114,6 +114,179 @@ export async function checkboxPicker(title, items, max) {
114
114
  render();
115
115
  });
116
116
  }
117
+ export function wizardFrame(tabs, index) {
118
+ const confirming = index >= tabs.length;
119
+ const bar = tabs
120
+ .map((tab, i) => (i === index && !confirming ? `[${tab.label}]` : tab.label))
121
+ .concat(confirming ? ["[Confirm]"] : [])
122
+ .join(" ");
123
+ if (confirming) {
124
+ const width = Math.max(...tabs.map((tab) => tab.label.length));
125
+ return [
126
+ bar,
127
+ "",
128
+ ...tabs.map((tab) => {
129
+ const values = [...tab.selected]
130
+ .sort((a, b) => a - b)
131
+ .map((i) => tab.items[i])
132
+ .join(", ");
133
+ return ` ${tab.label.padEnd(width)} ${values || "(none)"}`;
134
+ }),
135
+ "",
136
+ "enter: apply ←: back q: cancel",
137
+ ];
138
+ }
139
+ const tab = tabs[index];
140
+ const hint = tab.max === 1
141
+ ? "space/enter: select ←/→: tab q: cancel"
142
+ : `space: toggle enter: next ←/→: tab q: cancel max ${tab.max}`;
143
+ return [
144
+ bar,
145
+ hint,
146
+ ...tab.items.map((item, i) => {
147
+ const box = tab.selected.has(i) ? "[x]" : "[ ]";
148
+ return `${i === tab.cursor ? ">" : " "} ${box} ${item}`;
149
+ }),
150
+ ];
151
+ }
152
+ function initWizardTab(tab) {
153
+ if (tab.items.length === 0)
154
+ throw new CPACError(`${tab.label} has no options`);
155
+ const selected = new Set();
156
+ for (const label of tab.selected ?? []) {
157
+ const found = tab.items.indexOf(label);
158
+ if (found < 0)
159
+ continue;
160
+ selected.add(found);
161
+ if (selected.size >= tab.max)
162
+ break;
163
+ }
164
+ if (tab.max === 1 && selected.size === 0)
165
+ selected.add(0);
166
+ return {
167
+ id: tab.id,
168
+ label: tab.label,
169
+ items: tab.items,
170
+ max: tab.max,
171
+ selected,
172
+ cursor: selected.size > 0 ? Math.min(...selected) : 0,
173
+ };
174
+ }
175
+ export async function tabWizard(tabs) {
176
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
177
+ throw new CPACError("model selection requires an interactive terminal");
178
+ }
179
+ if (tabs.length === 0)
180
+ throw new CPACError("setup has no options");
181
+ const state = tabs.map(initWizardTab);
182
+ let index = 0;
183
+ const stdin = process.stdin;
184
+ const out = process.stderr;
185
+ let painted = 0;
186
+ const paint = (lines) => {
187
+ if (painted > 0) {
188
+ out.write(`\x1b[${painted}F`);
189
+ for (let line = 0; line < painted; line += 1)
190
+ out.write("\x1b[2K\x1b[1E");
191
+ out.write(`\x1b[${painted}F`);
192
+ }
193
+ out.write(`${lines.join("\n")}\n`);
194
+ painted = lines.length;
195
+ };
196
+ const clear = () => {
197
+ if (painted > 0) {
198
+ out.write(`\x1b[${painted}F`);
199
+ for (let line = 0; line < painted; line += 1)
200
+ out.write("\x1b[2K\x1b[1E");
201
+ out.write(`\x1b[${painted}F`);
202
+ painted = 0;
203
+ }
204
+ out.write("\x1b[?25h");
205
+ };
206
+ const resultOf = () => Object.fromEntries(state.map((tab) => [
207
+ tab.id,
208
+ [...tab.selected]
209
+ .sort((a, b) => a - b)
210
+ .map((i) => tab.items[i]),
211
+ ]));
212
+ const goNext = () => {
213
+ if (index < state.length && state[index].selected.size === 0)
214
+ return;
215
+ if (index < state.length)
216
+ index += 1;
217
+ };
218
+ const goPrev = () => {
219
+ if (index > 0)
220
+ index -= 1;
221
+ };
222
+ return await new Promise((resolvePromise, rejectPromise) => {
223
+ const finish = (error) => {
224
+ stdin.removeListener("data", onData);
225
+ try {
226
+ stdin.setRawMode(false);
227
+ }
228
+ catch {
229
+ // Terminal already gone; nothing to restore.
230
+ }
231
+ stdin.pause();
232
+ clear();
233
+ if (error)
234
+ rejectPromise(error);
235
+ else
236
+ resolvePromise(resultOf());
237
+ };
238
+ const onData = (chunk) => {
239
+ const key = chunk.toString("utf8");
240
+ if (key === "\x03" || key === "q" || key === "\x1b") {
241
+ finish(new CPACError("model selection cancelled"));
242
+ return;
243
+ }
244
+ if (index >= state.length) {
245
+ if (key === "\r" || key === "\n") {
246
+ finish();
247
+ return;
248
+ }
249
+ if (key === "\x1b[D")
250
+ goPrev();
251
+ paint(wizardFrame(state, index));
252
+ return;
253
+ }
254
+ const tab = state[index];
255
+ if (key === "\x1b[A")
256
+ tab.cursor = (tab.cursor + tab.items.length - 1) % tab.items.length;
257
+ else if (key === "\x1b[B")
258
+ tab.cursor = (tab.cursor + 1) % tab.items.length;
259
+ else if (key === "\x1b[C")
260
+ goNext();
261
+ else if (key === "\x1b[D")
262
+ goPrev();
263
+ else if (key === " ") {
264
+ if (tab.max === 1) {
265
+ tab.selected.clear();
266
+ tab.selected.add(tab.cursor);
267
+ goNext();
268
+ }
269
+ else if (tab.selected.has(tab.cursor))
270
+ tab.selected.delete(tab.cursor);
271
+ else if (tab.selected.size < tab.max)
272
+ tab.selected.add(tab.cursor);
273
+ }
274
+ else if (key === "\r" || key === "\n") {
275
+ if (tab.max === 1) {
276
+ tab.selected.clear();
277
+ tab.selected.add(tab.cursor);
278
+ }
279
+ goNext();
280
+ }
281
+ paint(wizardFrame(state, index));
282
+ };
283
+ stdin.setRawMode(true);
284
+ stdin.resume();
285
+ stdin.on("data", onData);
286
+ out.write("\x1b[?25l");
287
+ paint(wizardFrame(state, index));
288
+ });
289
+ }
117
290
  export function tomlString(value) {
118
291
  return JSON.stringify(value);
119
292
  }
@@ -1,29 +1,67 @@
1
1
  // Pi CPAC extension: register CPA models as Pi providers
2
- // Installed by: cpac pi install
2
+ // Loaded via package.json "pi.extensions" when settings include npm:@yhong91/cpac
3
3
 
4
- const CPA = "__CPA_URL__";
5
- const BUILTIN = new Set(["openai", "github-copilot", "xai", "deepseek", "anthropic", "google"]);
4
+ // keep in sync with src/config.ts DEFAULT_CPA_URL
5
+ const CPA = (process.env.CPA_BASE_URL || "http://124.223.178.52:8317").replace(
6
+ /\/+$/,
7
+ "",
8
+ );
9
+ const BUILTIN = new Set([
10
+ "openai",
11
+ "github-copilot",
12
+ "xai",
13
+ "deepseek",
14
+ "anthropic",
15
+ "google",
16
+ ]);
6
17
  const VENDOR_PREFIXES = [
7
- ["gpt-", "openai"], ["o1-", "openai"], ["o3-", "openai"], ["o4-", "openai"],
18
+ ["gpt-", "openai"],
19
+ ["o1-", "openai"],
20
+ ["o3-", "openai"],
21
+ ["o4-", "openai"],
8
22
  ["gpt-image", "openai"],
9
- ["claude-", "anthropic"], ["gemini-", "google"], ["grok-", "xai"],
10
- ["deepseek-", "deepseek"], ["glm-", "zhipu"], ["kimi-", "moonshot"],
11
- ["mimo-", "xiaomi"], ["doubao-", "volcengine"], ["ark-", "volcengine"],
12
- ["minimax-", "minimax"], ["step-", "stepfun"], ["qwen-", "alibaba"],
23
+ ["claude-", "anthropic"],
24
+ ["gemini-", "google"],
25
+ ["grok-", "xai"],
26
+ ["deepseek-", "deepseek"],
27
+ ["glm-", "zhipu"],
28
+ ["kimi-", "moonshot"],
29
+ ["mimo-", "xiaomi"],
30
+ ["doubao-", "volcengine"],
31
+ ["ark-", "volcengine"],
32
+ ["minimax-", "minimax"],
33
+ ["step-", "stepfun"],
34
+ ["qwen-", "alibaba"],
13
35
  ["hunyuan-", "tencent"],
14
36
  ];
15
37
  const MAX_TOKENS = {
16
- "gpt-5.6-sol": 128000, "gpt-5.6-terra": 128000, "gpt-5.6-luna": 128000,
17
- "claude-opus-4-6-thinking": 128000, "claude-sonnet-4-6": 64000,
18
- "gemini-3.6-flash": 65536, "gemini-3.6-flash-high": 65536,
19
- "deepseek-v4-pro": 128000, "deepseek-v4-flash": 128000,
20
- "glm-5.3": 128000, "kimi-k2.7-code": 128000, "minimax-m3": 128000,
21
- "mimo-v2.5": 131072, "mimo-v2.5-pro": 131072, "grok-4.5": 128000,
22
- "doubao-seed-2.0-lite": 128000, "doubao-seed-2.1-turbo": 128000,
38
+ "gpt-5.6-sol": 128000,
39
+ "gpt-5.6-terra": 128000,
40
+ "gpt-5.6-luna": 128000,
41
+ "claude-opus-4-6-thinking": 128000,
42
+ "claude-sonnet-4-6": 64000,
43
+ "gemini-3.6-flash": 65536,
44
+ "gemini-3.6-flash-high": 65536,
45
+ "deepseek-v4-pro": 128000,
46
+ "deepseek-v4-flash": 128000,
47
+ "glm-5.3": 128000,
48
+ "kimi-k2.7-code": 128000,
49
+ "minimax-m3": 128000,
50
+ "mimo-v2.5": 131072,
51
+ "mimo-v2.5-pro": 131072,
52
+ "grok-4.5": 128000,
53
+ "doubao-seed-2.0-lite": 128000,
54
+ "doubao-seed-2.1-turbo": 128000,
23
55
  };
24
56
  const DEFAULT_MAX_TOKENS = 65536;
25
57
  const REASONING_EFFORTS = new Set([
26
- "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
58
+ "minimal",
59
+ "low",
60
+ "medium",
61
+ "high",
62
+ "xhigh",
63
+ "max",
64
+ "ultra",
27
65
  ]);
28
66
 
29
67
  function vendorFor(slug) {
@@ -40,21 +78,27 @@ function groupName(vendor) {
40
78
  function effortsFor(row) {
41
79
  const levels = row.supported_reasoning_levels;
42
80
  if (!Array.isArray(levels)) return undefined;
43
- const efforts = levels.flatMap(function (level) {
81
+ const efforts = levels.flatMap((level) => {
44
82
  if (!level || typeof level !== "object") return [];
45
83
  const effort = level.effort;
46
- return typeof effort === "string" && REASONING_EFFORTS.has(effort) ? [effort] : [];
84
+ return typeof effort === "string" && REASONING_EFFORTS.has(effort)
85
+ ? [effort]
86
+ : [];
47
87
  });
48
88
  return efforts.length > 0 ? efforts : undefined;
49
89
  }
50
90
 
51
91
  function thinkingLevelMapFor(efforts) {
52
- if (!efforts || !efforts.some(function (effort) { return REASONING_EFFORTS.has(effort); })) {
92
+ if (!efforts || !efforts.some((effort) => REASONING_EFFORTS.has(effort))) {
53
93
  return undefined;
54
94
  }
55
95
  const available = new Set(efforts);
56
96
  return {
57
- minimal: available.has("minimal") ? "minimal" : available.has("low") ? "low" : null,
97
+ minimal: available.has("minimal")
98
+ ? "minimal"
99
+ : available.has("low")
100
+ ? "low"
101
+ : null,
58
102
  low: available.has("low") ? "low" : null,
59
103
  medium: available.has("medium") ? "medium" : null,
60
104
  high: available.has("high") ? "high" : null,
@@ -65,7 +109,8 @@ function thinkingLevelMapFor(efforts) {
65
109
 
66
110
  function intField(value) {
67
111
  return typeof value === "number" && Number.isFinite(value) && value > 0
68
- ? Math.floor(value) : undefined;
112
+ ? Math.floor(value)
113
+ : undefined;
69
114
  }
70
115
 
71
116
  export default async function (pi) {
@@ -86,12 +131,18 @@ export default async function (pi) {
86
131
  }
87
132
  payload = await res.json();
88
133
  } catch (error) {
89
- console.error("[pi-cpac] CPA models request failed: " + (error && error.message || error));
134
+ console.error(
135
+ "[pi-cpac] CPA models request failed: " +
136
+ ((error && error.message) || error),
137
+ );
90
138
  return;
91
139
  }
92
- const source = payload && Array.isArray(payload.models)
93
- ? payload.models
94
- : payload && Array.isArray(payload.data) ? payload.data : null;
140
+ const source =
141
+ payload && Array.isArray(payload.models)
142
+ ? payload.models
143
+ : payload && Array.isArray(payload.data)
144
+ ? payload.data
145
+ : null;
95
146
  if (!source) {
96
147
  console.error("[pi-cpac] CPA models response has no models list");
97
148
  return;
@@ -99,15 +150,19 @@ export default async function (pi) {
99
150
  const rows = [];
100
151
  for (const m of source) {
101
152
  if (!m || typeof m !== "object") continue;
102
- const slug = typeof m.slug === "string" && m.slug.trim()
103
- ? m.slug.trim()
104
- : typeof m.id === "string" && m.id.trim() ? m.id.trim() : null;
105
- if (slug) rows.push({
106
- slug: slug,
107
- display_name: m.display_name,
108
- supported_reasoning_levels: m.supported_reasoning_levels,
109
- context_window: m.context_window,
110
- });
153
+ const slug =
154
+ typeof m.slug === "string" && m.slug.trim()
155
+ ? m.slug.trim()
156
+ : typeof m.id === "string" && m.id.trim()
157
+ ? m.id.trim()
158
+ : null;
159
+ if (slug)
160
+ rows.push({
161
+ slug: slug,
162
+ display_name: m.display_name,
163
+ supported_reasoning_levels: m.supported_reasoning_levels,
164
+ context_window: m.context_window,
165
+ });
111
166
  }
112
167
  if (rows.length === 0) {
113
168
  console.error("[pi-cpac] CPA models response has no usable models");
@@ -126,7 +181,7 @@ export default async function (pi) {
126
181
  baseUrl: CPA + "/v1",
127
182
  apiKey: apiKey,
128
183
  api: "openai-responses",
129
- models: models.map(function (m) {
184
+ models: models.map((m) => {
130
185
  const efforts = effortsFor(m);
131
186
  const thinkingLevelMap = thinkingLevelMapFor(efforts);
132
187
  const modelObj = {
@@ -139,7 +194,7 @@ export default async function (pi) {
139
194
  maxTokens: MAX_TOKENS[m.slug] || DEFAULT_MAX_TOKENS,
140
195
  compat: {
141
196
  sendSessionAffinityHeaders: true,
142
- sessionAffinityFormat: "openai",
197
+ sessionAffinityFormat: "openai-nosession",
143
198
  },
144
199
  };
145
200
  if (thinkingLevelMap) modelObj.thinkingLevelMap = thinkingLevelMap;
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cpac": "dist/cpac.js"
8
8
  },
9
+ "keywords": [
10
+ "pi-package"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./extensions/pi-cpac.ts"
15
+ ]
16
+ },
9
17
  "files": [
10
18
  "dist",
19
+ "extensions",
11
20
  "cpac.example.json",
12
21
  "README.md"
13
22
  ],
@@ -28,9 +37,9 @@
28
37
  },
29
38
  "scripts": {
30
39
  "clean": "node -e \"for (const d of ['dist','dist-test']) require('fs').rmSync(d,{recursive:true,force:true})\"",
31
- "build": "npm run clean --if-present && tsc -p tsconfig.build.json && cp src/pi-extension.template dist/ && node -e \"const fs=require('fs');const p='dist/cpac.js';const s=fs.readFileSync(p,'utf8');if(!s.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+s);try{fs.chmodSync(p,0o755)}catch{}\"",
40
+ "build": "npm run clean --if-present && tsc -p tsconfig.build.json && node -e \"const fs=require('fs');const p='dist/cpac.js';const s=fs.readFileSync(p,'utf8');if(!s.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+s);try{fs.chmodSync(p,0o755)}catch{}\"",
32
41
  "check": "tsc -p tsconfig.json --noEmit",
33
- "test": "npm run clean && tsc -p tsconfig.test.json && cp src/pi-extension.template dist-test/src/ && node --test --test-reporter=spec dist-test/cpac.test.js",
42
+ "test": "npm run clean && tsc -p tsconfig.test.json && node --test --test-reporter=spec dist-test/cpac.test.js",
34
43
  "pack:check": "npm pack --dry-run"
35
44
  },
36
45
  "devDependencies": {