@yhong91/cpac 0.1.32 → 0.1.34

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/dist/cpac.js CHANGED
@@ -1,22 +1,22 @@
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
- import { runClaude, runClaudeConfig, runClaudeModels, } from "./targets/claude.js";
8
- import { inject, multiAgentV2Enabled } from "./targets/codex.js";
7
+ import { runClaude, runClaudeConfig } from "./targets/claude.js";
8
+ import { multiAgentV2Enabled } from "./targets/codex.js";
9
9
  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";
14
- import { proxyIsHealthy, runProxy, runProxyChild } from "./proxy.js";
13
+ import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, catalogMaxContextLive, } from "./config.js";
14
+ import { proxyIsHealthy, 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})`);
@@ -57,11 +69,11 @@ export async function status(config) {
57
69
  }
58
70
  console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
59
71
  const piInstalled = isPiExtensionInstalled();
60
- console.log(`pi: ${keyConfigured ? (piInstalled ? "ready" : "extension not installed; run: cpac install --target pi") : "not configured"}`);
72
+ console.log(`pi: ${keyConfigured ? (piInstalled ? "ready" : "extension not installed; run: cpac install pi") : "not configured"}`);
61
73
  const kimiInstalled = isKimiConfigInstalled();
62
- console.log(`kimi: ${keyConfigured ? (kimiInstalled ? "ready" : "config not installed; run: cpac install --target kimi") : "not configured"}`);
74
+ console.log(`kimi: ${keyConfigured ? (kimiInstalled ? "ready" : "config not installed; run: cpac install kimi") : "not configured"}`);
63
75
  const grokInstalled = isGrokConfigInstalled();
64
- console.log(`grok: ${keyConfigured ? (grokInstalled ? "ready" : "config not installed; run: cpac install --target grok") : "not configured"}`);
76
+ console.log(`grok: ${keyConfigured ? (grokInstalled ? "ready" : "config not installed; run: cpac install grok") : "not configured"}`);
65
77
  if (!state)
66
78
  return 1;
67
79
  const proxy = stateProxy(state);
@@ -70,9 +82,9 @@ export async function status(config) {
70
82
  return 0;
71
83
  }
72
84
  async function guide(config) {
73
- console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac <agent> [args...] Launch an agent (codex, kimi, grok, pi, claude, opencode) through CPA\n cpac inject Inject CPA into Codex and start its loopback proxy\n cpac proxy Run the injected loopback proxy in the foreground\n cpac status Show agent support status (Codex, Claude, Pi, Kimi, Grok)\n cpac restore --target <id> | --all Detach injected agents
85
+ console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac <agent> [args...] Launch an agent (codex, kimi, grok, pi, claude, opencode) through CPA\n cpac setup <codex|claude> Configure Codex or Claude, then launch\n cpac status Show agent support status (Codex, Claude, Pi, Kimi, Grok)\n cpac restore <id> | --all Detach injected agents
74
86
  cpac detect Show supported targets and install status
75
- cpac install Install CPA integration into detected targets
87
+ cpac install [ids...] Install CPA into detected targets, or named agents
76
88
  cpac sync Refresh installed Codex/Pi/Kimi/Grok injections from CPA
77
89
  cpac uninstall Detach all agents, then remove the cpac package (confirms first)\n cpac upgrade Update cpac from npm and sync installed agents\n cpac --help Show command usage`);
78
90
  if (process.env[config.api_key_env]?.trim())
@@ -90,21 +102,33 @@ function usage() {
90
102
  return [
91
103
  "Usage: cpac",
92
104
  " cpac <codex|kimi|grok|pi|claude|opencode> [args...]",
93
- " cpac <codex|claude> --setup [args...]",
105
+ " cpac setup <codex|claude> [options]",
94
106
  " cpac <codex|claude|kimi|grok|pi> clear",
95
- " cpac inject [--v2_off] [--v2_models] [--max_context] [--config PATH]",
96
- " cpac restore --target <id> | --all [--dry-run] [--config PATH]",
107
+ " cpac restore <id...> | --all [--dry-run] [--config PATH]",
97
108
  " cpac status [--config PATH]",
98
- " cpac proxy [--config PATH]",
99
109
  " cpac detect [--json] [--home PATH]",
100
- " cpac install [--target codex,pi,kimi,grok] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
101
- " cpac sync [--target codex,pi,kimi,grok] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
110
+ " cpac install [codex|pi|kimi|grok...] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
111
+ " cpac sync [codex|pi|kimi|grok...] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
102
112
  " cpac uninstall [--dry-run]",
103
113
  " cpac upgrade [--check]",
104
114
  " cpac -v | --version",
105
115
  "",
106
116
  "launch an agent directly with `cpac <agent> [args...]` (all args forwarded).",
107
- "configure then launch with `cpac <codex|claude> --setup`; restore with `cpac <agent> clear`.",
117
+ "configure then launch with `cpac setup <codex|claude>`; restore with `cpac <agent> clear`.",
118
+ ].join("\n");
119
+ }
120
+ function setupUsage() {
121
+ return [
122
+ "Usage: cpac setup claude [--opus [M]] [--sonnet [M]] [--haiku [M]] [--reset] [--config PATH]",
123
+ " cpac setup codex [--v2_off] [--v2_models] [--max_context] [--config PATH]",
124
+ "",
125
+ "Claude slots (saved to config, applied on every `cpac claude`):",
126
+ " --opus heavy reasoning / planning fallback (ANTHROPIC_DEFAULT_OPUS_MODEL)",
127
+ " --sonnet main conversation default (ANTHROPIC_DEFAULT_SONNET_MODEL)",
128
+ " --haiku high-frequency small tasks (ANTHROPIC_DEFAULT_HAIKU_MODEL)",
129
+ "",
130
+ "A slot without a value opens an arrow-key picker; --reset clears overrides.",
131
+ "No flags on a TTY opens the wizard, then launches.",
108
132
  ].join("\n");
109
133
  }
110
134
  function parseArgs(args) {
@@ -172,6 +196,12 @@ function parseArgs(args) {
172
196
  else if (arg === "--max_context" || arg === "--max-context") {
173
197
  parsed.maxContext = true;
174
198
  }
199
+ else if (!arg.startsWith("-")) {
200
+ parsed.targets.push(...arg
201
+ .split(",")
202
+ .map((entry) => entry.trim())
203
+ .filter(Boolean));
204
+ }
175
205
  else {
176
206
  throw new CPACError(`unknown option: ${arg}`);
177
207
  }
@@ -223,6 +253,17 @@ function parseArgs(args) {
223
253
  interactive: false,
224
254
  };
225
255
  }
256
+ if (targetId === "codex") {
257
+ return {
258
+ command: "codex-config",
259
+ configPath,
260
+ v2Off: false,
261
+ v2Models: false,
262
+ maxContext: false,
263
+ reset: true,
264
+ interactive: false,
265
+ };
266
+ }
226
267
  return {
227
268
  command: "restore",
228
269
  configPath,
@@ -274,7 +315,7 @@ function parseArgs(args) {
274
315
  args: launchArgs,
275
316
  };
276
317
  }
277
- throw new CPACError(`${targetId} does not require setup (all CPA models are auto-synced). Run 'cpac ${targetId}' or 'cpac install --target ${targetId}'.`);
318
+ throw new CPACError(`${targetId} does not require setup (all CPA models are auto-synced). Run 'cpac ${targetId}' or 'cpac install ${targetId}'.`);
278
319
  }
279
320
  const passArgs = rest;
280
321
  if (targetId === "codex") {
@@ -300,31 +341,53 @@ function parseArgs(args) {
300
341
  args: passArgs,
301
342
  };
302
343
  }
303
- if (args[0] === "claude-models") {
344
+ if (args[0] === "setup") {
304
345
  if (args.includes("-h") || args.includes("--help")) {
305
- console.log([
306
- "Usage: cpac claude --config [--opus [M]] [--sonnet [M]] [--haiku [M]] [--reset] [--config PATH]",
307
- "",
308
- "Sets the model Claude Code uses per task tier. Saved to the config file",
309
- "and applied on every `cpac claude` launch:",
310
- " --opus heavy reasoning / planning fallback tier (ANTHROPIC_DEFAULT_OPUS_MODEL)",
311
- " --sonnet main conversation default tier (ANTHROPIC_DEFAULT_SONNET_MODEL)",
312
- " --haiku high-frequency small tasks: titles, summaries, background",
313
- " (ANTHROPIC_DEFAULT_HAIKU_MODEL + ANTHROPIC_SMALL_FAST_MODEL)",
314
- "",
315
- "Unset slots auto-select from the catalog: same-family claude model first,",
316
- "then gemini → grok → luna → first model (opus stays unset without a match).",
317
- "",
318
- "A slot without a value opens an arrow-key picker over the CPA catalog;",
319
- "--reset clears all overrides back to catalog auto-selection.",
320
- ].join("\n"));
346
+ console.log(setupUsage());
321
347
  return null;
322
348
  }
349
+ const agent = args[1];
350
+ if (agent !== "claude" && agent !== "codex") {
351
+ throw new CPACError("setup requires claude or codex");
352
+ }
323
353
  let configPath = defaultConfigPath();
354
+ if (agent === "codex") {
355
+ let v2Off = false;
356
+ let v2Models = false;
357
+ let maxContext = false;
358
+ for (let index = 2; index < args.length; index++) {
359
+ const arg = args[index];
360
+ if (arg === "--config") {
361
+ const value = args[++index];
362
+ if (!value)
363
+ throw new CPACError("--config requires a path");
364
+ configPath = resolve(expandUserPath(value));
365
+ }
366
+ else if (arg === "--v2_off" || arg === "--v2-off")
367
+ v2Off = true;
368
+ else if (arg === "--v2_models" || arg === "--v2-models")
369
+ v2Models = true;
370
+ else if (arg === "--max_context" || arg === "--max-context")
371
+ maxContext = true;
372
+ else
373
+ throw new CPACError(`unknown option: ${arg}`);
374
+ }
375
+ return {
376
+ command: "codex-config",
377
+ configPath,
378
+ v2Off,
379
+ v2Models,
380
+ maxContext,
381
+ reset: false,
382
+ interactive: true,
383
+ launch: true,
384
+ args: [],
385
+ };
386
+ }
324
387
  const models = {};
325
388
  const pick = [];
326
389
  let reset = false;
327
- for (let index = 1; index < args.length; index += 1) {
390
+ for (let index = 2; index < args.length; index += 1) {
328
391
  const arg = args[index];
329
392
  if (arg === "--config") {
330
393
  const value = args[++index];
@@ -352,15 +415,20 @@ function parseArgs(args) {
352
415
  throw new CPACError(`unknown option: ${arg}`);
353
416
  }
354
417
  }
418
+ const interactive = !reset && Object.keys(models).length === 0 && pick.length === 0;
355
419
  return {
356
420
  command: "claude-config",
357
421
  configPath,
358
422
  models,
359
423
  pick,
360
424
  reset,
361
- interactive: !reset && Object.keys(models).length === 0 && pick.length === 0,
425
+ interactive,
426
+ launch: interactive && !reset,
362
427
  };
363
428
  }
429
+ if (args[0] === "claude-models") {
430
+ throw new CPACError("claude-models was removed; use: cpac setup claude");
431
+ }
364
432
  if (args.includes("-h") || args.includes("--help")) {
365
433
  console.log(usage());
366
434
  return null;
@@ -393,20 +461,19 @@ function parseArgs(args) {
393
461
  else
394
462
  positional.push(args[index]);
395
463
  }
396
- if (positional.length !== 1 ||
397
- !["inject", "status", "proxy"].includes(positional[0])) {
398
- throw new CPACError(usage());
464
+ if (positional.length === 1 && positional[0] === "inject") {
465
+ throw new CPACError("inject was removed; use: cpac install codex");
466
+ }
467
+ if (positional.length === 1 && positional[0] === "proxy") {
468
+ throw new CPACError("proxy was removed; restart with: cpac install codex (or kimi / grok)");
399
469
  }
400
- if (positional[0] !== "inject" && (v2Off || v2Models || maxContext)) {
401
- throw new CPACError("--v2_off / --v2_models / --max_context is only valid with inject or install");
470
+ if (positional.length !== 1 || positional[0] !== "status") {
471
+ throw new CPACError(usage());
402
472
  }
403
- if (positional[0] === "inject") {
404
- return { command: "inject", configPath, v2Off, v2Models, maxContext };
473
+ if (v2Off || v2Models || maxContext) {
474
+ throw new CPACError("--v2_off / --v2_models / --max_context is only valid with install or setup");
405
475
  }
406
- return {
407
- command: positional[0],
408
- configPath,
409
- };
476
+ return { command: "status", configPath };
410
477
  }
411
478
  export async function main(args = process.argv.slice(2)) {
412
479
  try {
@@ -468,7 +535,7 @@ export async function main(args = process.argv.slice(2)) {
468
535
  });
469
536
  if (parsed.command === "uninstall") {
470
537
  if (parsed.targets.length || parsed.all) {
471
- throw new CPACError("uninstall removes the cpac package; detach agents with: cpac restore --target <id> | --all");
538
+ throw new CPACError("uninstall removes the cpac package; detach agents with: cpac restore <id> | --all");
472
539
  }
473
540
  return await runUninstall(config, { dryRun: parsed.dryRun });
474
541
  }
@@ -520,23 +587,7 @@ export async function main(args = process.argv.slice(2)) {
520
587
  parsed.command === "pi") {
521
588
  return await runTargetLauncher(config, parsed.command, parsed.args);
522
589
  }
523
- if (parsed.command === "claude-models")
524
- return await runClaudeModels(parsed);
525
- if (parsed.command === "proxy")
526
- return await runProxy(config);
527
- if (parsed.command === "inject") {
528
- let injectConfig = config;
529
- if (parsed.v2Models) {
530
- const picked = await pickSpawnModels(config);
531
- saveSpawnModels(parsed.configPath, picked);
532
- console.log(`spawn_models saved to ${parsed.configPath}: ${picked.join(", ")}`);
533
- injectConfig = { ...config, spawn_models: picked };
534
- }
535
- await inject(injectConfig, parsed.v2Off, parsed.maxContext);
536
- }
537
- else
538
- return await status(config);
539
- return 0;
590
+ return await status(config);
540
591
  }
541
592
  catch (error) {
542
593
  console.error(`cpac: ${error instanceof Error ? error.message : String(error)}`);
@@ -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)