From 9a9831362d7a1df9537823a7d22c1d391c9183f6 Mon Sep 17 00:00:00 2001 From: ework-daemon Date: Wed, 26 Aug 2026 08:55:18 +0800 Subject: [PATCH] feat(config): move acp.json under agent dir with legacy fallback (closes #231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global config now lives at /acp.json (~/.pi/agent/acp.json) and project config at /.pi/agent/acp.json, matching where most pi plugin configs live (and where config-sync plugins look). Legacy locations (~/.pi/acp.json, /.pi/acp.json) are still read when the new location is absent, so existing setups keep working unchanged; the new location wins when both are present. No files are written automatically — moving an existing config is a manual copy. - src/user-config.ts: resolve per-scope fresh/legacy path (fresh wins) - tests: new-location read, legacy backward-compat, precedence, hot-reload - e2e harness + docs (CONFIGURATION/README, en+zh) updated to new paths --- CONFIGURATION.md | 10 +-- CONFIGURATION.zh-CN.md | 10 +-- README.md | 2 +- README.zh-CN.md | 2 +- scripts/e2e/README.md | 2 +- scripts/e2e/run-e2e.mjs | 4 +- src/config.ts | 3 +- src/runtime.ts | 7 +- src/user-config.ts | 59 ++++++++++++++--- tests/integration.test.ts | 5 +- tests/user-config.test.ts | 132 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 207 insertions(+), 29 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 60e9bc3..23199ff 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -14,18 +14,20 @@ Settings are read from JSON files named `acp.json`. The global file applies to e | Scope | Path | Applies to | |-------|------|------------| -| **Global** | `~/.pi/acp.json` | All projects on this machine | -| **Project** | `/.pi/acp.json` | The current project only (overrides global per-field) | +| **Global** | `~/.pi/agent/acp.json` | All projects on this machine | +| **Project** | `/.pi/agent/acp.json` | The current project only (overrides global per-field) | -> **Precedence:** Environment variable > Project file > Global file > Built-in default. +> **Precedence:** Environment variable > Project file > Global file > Built-in default. Within a scope, the new (agent-dir) location wins over the legacy location when both are present. Files are loaded at session start. Missing files, malformed JSON, and unknown keys are silently ignored — the extension never fails to start because of a config issue. Only the documented keys are read; everything else is discarded. +> **Legacy locations (backward compatible):** `~/.pi/acp.json` (global) and `/.pi/acp.json` (project) are still read when the new location is absent, so existing setups keep working unchanged. To move an existing config, copy the file to the new location (e.g. `cp ~/.pi/acp.json ~/.pi/agent/acp.json`); the new location then takes precedence. No files are written automatically. + --- ## Quick start -Create `~/.pi/acp.json` (or `/.pi/acp.json`) and drop in whichever keys you want to change. Every field below is optional — omit a key to keep its default. +Create `~/.pi/agent/acp.json` (or `/.pi/agent/acp.json`) and drop in whichever keys you want to change. Every field below is optional — omit a key to keep its default. ```json { diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 02d4783..aa8ce75 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -14,18 +14,20 @@ | 范围 | 路径 | 生效范围 | |------|------|----------| -| **全局** | `~/.pi/acp.json` | 本机所有项目 | -| **项目** | `<项目>/.pi/acp.json` | 仅当前项目(逐字段覆盖全局) | +| **全局** | `~/.pi/agent/acp.json` | 本机所有项目 | +| **项目** | `<项目>/.pi/agent/acp.json` | 仅当前项目(逐字段覆盖全局) | -> **优先级:** 环境变量 > 项目文件 > 全局文件 > 内置默认值。 +> **优先级:** 环境变量 > 项目文件 > 全局文件 > 内置默认值。同一范围内,新旧两个位置都存在时,新位置(agent 目录)优先。 文件在会话启动时加载。缺失文件、格式错误的 JSON、未知键都会被静默忽略——扩展绝不会因为配置问题而无法启动。只有文档中列出的键会被读取,其余一律丢弃。 +> **旧位置(向后兼容):** `~/.pi/acp.json`(全局)和 `<项目>/.pi/acp.json`(项目)在新位置不存在时仍会被读取,因此现有配置无需改动即可继续生效。要迁移现有配置,把文件复制到新位置即可(例如 `cp ~/.pi/acp.json ~/.pi/agent/acp.json`),新位置随后会优先生效。不会自动写入任何文件。 + --- ## 快速开始 -创建 `~/.pi/acp.json`(或 `<项目>/.pi/acp.json`),放入你想修改的键即可。以下每个字段都是可选的——省略某个键则保持其默认值。 +创建 `~/.pi/agent/acp.json`(或 `<项目>/.pi/agent/acp.json`),放入你想修改的键即可。以下每个字段都是可选的——省略某个键则保持其默认值。 ```json { diff --git a/README.md b/README.md index 0344dff..e6e1b95 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ It discovers the agents and their tool baselines from the installed pi-subagents billion-context-pi works out of the box with no configuration — it reads your model's context window automatically and applies sensible defaults. -Behavior is tuned via an optional `acp.json` config file (`~/.pi/acp.json` for global defaults, `/.pi/acp.json` for per-project overrides) plus a few environment variables. For the complete reference — every key, type, default, and the precedence order — see **[CONFIGURATION.md](./CONFIGURATION.md)** ([中文](./CONFIGURATION.zh-CN.md)). +Behavior is tuned via an optional `acp.json` config file (`~/.pi/agent/acp.json` for global defaults, `/.pi/agent/acp.json` for per-project overrides) plus a few environment variables. The legacy locations (`~/.pi/acp.json`, `/.pi/acp.json`) are still read for backward compatibility when the new one is absent. For the complete reference — every key, type, default, and the precedence order — see **[CONFIGURATION.md](./CONFIGURATION.md)** ([中文](./CONFIGURATION.zh-CN.md)). ### Logging diff --git a/README.zh-CN.md b/README.zh-CN.md index 04f1a81..76f005f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,7 +148,7 @@ billion-context-pi 自带的 `acp_delegate` 工具可独立工作。如果你另 billion-context-pi 开箱即用,无需任何配置——它会自动读取模型的上下文窗口并应用合理的默认值。 -行为通过可选的 `acp.json` 配置文件(`~/.pi/acp.json` 为全局默认,`<项目>/.pi/acp.json` 为项目级覆盖)以及若干环境变量来调优。完整参考——每个 key、类型、默认值与优先级顺序——请查阅 **[CONFIGURATION.zh-CN.md](./CONFIGURATION.zh-CN.md)** ([English](./CONFIGURATION.md))。 +行为通过可选的 `acp.json` 配置文件(`~/.pi/agent/acp.json` 为全局默认,`<项目>/.pi/agent/acp.json` 为项目级覆盖)以及若干环境变量来调优。旧位置(`~/.pi/acp.json`、`<项目>/.pi/acp.json`)在新位置不存在时仍会被读取(向后兼容)。完整参考——每个 key、类型、默认值与优先级顺序——请查阅 **[CONFIGURATION.zh-CN.md](./CONFIGURATION.zh-CN.md)** ([English](./CONFIGURATION.md))。 ### 日志 diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 632955a..d21e07e 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -78,7 +78,7 @@ docker run --rm bcp-e2e 03-nudge # filtered { "name": "basic-compress", "description": "...", - "acpConfig": { "modelContextLimit": 1500 }, // optional, merged into ~/.pi/acp.json + "acpConfig": { "modelContextLimit": 1500 }, // optional, merged into ~/.pi/agent/acp.json "turns": [ { "respond": "text", "userText": "...", "text": "..." }, // assistant text reply { "respond": "compress", "range": "all", "topic": "...", "summary": "...", diff --git a/scripts/e2e/run-e2e.mjs b/scripts/e2e/run-e2e.mjs index 85b0ee0..9e0ab2d 100644 --- a/scripts/e2e/run-e2e.mjs +++ b/scripts/e2e/run-e2e.mjs @@ -149,11 +149,11 @@ function writePiConfig(home) { }, }; fs.writeFileSync(path.join(home, ".pi", "agent", "models.json"), JSON.stringify(models, null, 2)); - fs.writeFileSync(path.join(home, ".pi", "acp.json"), JSON.stringify({ autoUpdate: false, debug: false }, null, 2)); + fs.writeFileSync(path.join(home, ".pi", "agent", "acp.json"), JSON.stringify({ autoUpdate: false, debug: false }, null, 2)); } function applyScenarioAcpConfig(scenarioPath, home) { - const acpPath = path.join(home, ".pi", "acp.json"); + const acpPath = path.join(home, ".pi", "agent", "acp.json"); const base = JSON.parse(fs.readFileSync(acpPath, "utf8")); const scenario = JSON.parse(fs.readFileSync(scenarioPath, "utf8")); const merged = Object.assign({}, base, scenario.acpConfig || {}); diff --git a/src/config.ts b/src/config.ts index e19e411..c168acd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,7 +105,8 @@ export interface AdapterConfig { /** Override acp-kernel's load-bearing compression prompt rules (the 4 * Prompts fields). Each set field replaces the kernel default verbatim. * Requires acknowledgePromptsRisk: true — without it, overrides are dropped - * (defaults used) and a warning is logged. Set via ~/.pi/acp.json. */ + * (defaults used) and a warning is logged. Set via acp.json (see + * CONFIGURATION.md for locations). */ prompts?: Partial; /** Must be true for `prompts` overrides to take effect. Acknowledges that * replacing the kernel's tuned compression rules may reduce summary quality diff --git a/src/runtime.ts b/src/runtime.ts index 908b5bb..76b14cc 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -83,9 +83,10 @@ export interface AcpRuntime { clearCompressRetryTracking(): void; liveContextLimit(ctx: ExtensionContext): number; configFor(ctx: ExtensionContext): Config; - /** Re-read ~/./acp.json + //acp.json and re-derive the adapter - * config when the contents change. Cheap no-op when unchanged. Called at - * session_start and on every context event so config edits apply live. */ + /** Re-read /acp.json + /.pi/agent/acp.json (legacy ~/.pi/acp.json + * and /.pi/acp.json still honored) and re-derive the adapter config when + * the contents change. Cheap no-op when unchanged. Called at session_start and + * on every context event so config edits apply live. */ reloadConfig(cwd: string): Promise; stateFor(ctx: ExtensionContext, liveMessages?: AgentMessage[]): Promise<{ state: CompressionState; coreMessages: ReturnType; entries: SessionEntry[] }>; save(state: CompressionState, ctx: ExtensionContext): Promise; diff --git a/src/user-config.ts b/src/user-config.ts index 46dd035..5ccba8c 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -1,15 +1,17 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; -import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import type { Prompts } from "acp-kernel"; import type { AdapterConfig, CompressConfig, DelegateConfig } from "./config.js"; import type { ThrottleRetryConfig } from "./throttle-retry.js"; import { debug, logWarn } from "./log.js"; /** User-facing config keys (subset of AdapterConfig). Loaded from - * ~/./acp.json (global) and /./acp.json - * (project-local overrides project-global). Project wins over global. */ + * /acp.json (global, e.g. ~/.pi/agent/acp.json) and + * /.pi/agent/acp.json (project-local). Project wins over global per-field. + * Legacy locations (~/.pi/acp.json, /.pi/acp.json) are still read as a + * fallback for backward compatibility (issue #231). */ export interface UserAcpConfig { debug?: boolean; autoUpdate?: boolean; @@ -24,19 +26,40 @@ export interface UserAcpConfig { acknowledgePromptsRisk?: boolean; } -/** Read global + project acp.json, project overrides global. Returns {} on any - * error (missing file, bad JSON) — never throws. */ +/** Read global + project acp.json, project overrides global per-field. Returns + * {} on any error (missing file, bad JSON) — never throws. + * + * Locations (issue #231): the canonical config now lives under the agent dir — + * global at /acp.json (e.g. ~/.pi/agent/acp.json), project at + * /.pi/agent/acp.json. The legacy locations (~/.pi/acp.json and + * /.pi/acp.json) remain readable so existing setups keep working: when the + * new location is absent the legacy file is used, and the new location wins when + * both are present. No files are written — to move an existing config, copy it + * to the new location (see CONFIGURATION.md). */ export async function loadUserConfig(cwd: string): Promise { const home = homedir(); + const scopes: { name: "global" | "project"; fresh: string; legacy: string }[] = [ + { + name: "global", + fresh: path.join(getAgentDir(), CONFIG_FILE_NAME), + legacy: path.join(home, CONFIG_DIR_NAME, CONFIG_FILE_NAME), + }, + { + name: "project", + fresh: path.join(cwd, CONFIG_DIR_NAME, "agent", CONFIG_FILE_NAME), + legacy: path.join(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME), + }, + ]; const merged: UserAcpConfig = {}; - for (const base of [join(home, CONFIG_DIR_NAME), join(cwd, CONFIG_DIR_NAME)]) { - const file = join(base, "acp.json"); + for (const scope of scopes) { + const file = await resolveConfigFile(scope); + if (!file) continue; try { const raw = await fs.readFile(file, "utf8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { Object.assign(merged, pickKnown(parsed)); - debug.event("config-loaded", { file }); + debug.event("config-loaded", { file, scope: scope.name }); } } catch (e) { const code = (e as NodeJS.ErrnoException).code; @@ -48,8 +71,24 @@ export async function loadUserConfig(cwd: string): Promise { return merged; } -function join(... parts: string[]): string { - return path.join(...parts); +const CONFIG_FILE_NAME = "acp.json"; + +/** Pick the effective config file for a scope: prefer the fresh (agent-dir) + * location; fall back to the legacy location when the fresh one is absent. + * Returns the path to read, or null when neither location exists. */ +async function resolveConfigFile(scope: { fresh: string; legacy: string }): Promise { + if (await fileExists(scope.fresh)) return scope.fresh; + if (await fileExists(scope.legacy)) return scope.legacy; + return null; +} + +async function fileExists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } } const KNOWN = new Set([ diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 989c6f1..6cbbca6 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -728,9 +728,10 @@ test("delegate:false omits the ACP_DELEGATE NOTIFICATIONS section from the syste assert.ok(result.systemPrompt.includes("ACP TAGS"), "core ACP prompt still present when delegate disabled"); }); -// ─── ISSUE-9: modelContextLimit changes in /.pi/acp.json hot-reload ── +// ─── ISSUE-9: modelContextLimit hot-reload (legacy /.pi/acp.json path, +// ─── verifying backward compatibility — legacy project config is still read) ─ -test("modelContextLimit changes in .pi/acp.json are picked up on the next context event", async () => { +test("modelContextLimit changes in legacy .pi/acp.json are picked up on the next context event", async () => { (globalThis as Record).CURRENT_VERSION ??= "0.0.0-test"; const { mkdtempSync, writeFileSync, rmSync, mkdirSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts index 631f4dd..59a4ad1 100644 --- a/tests/user-config.test.ts +++ b/tests/user-config.test.ts @@ -16,6 +16,24 @@ async function writeConfig(dir: string, data: object): Promise { return filePath; } +/** Write to the new canonical (agent-dir) location: /.pi/agent/acp.json. */ +async function writeFreshConfig(base: string, data: object): Promise { + const dirPath = path.join(base, CONFIG_DIR_NAME, "agent"); + await fs.mkdir(dirPath, { recursive: true }); + const filePath = path.join(dirPath, "acp.json"); + await fs.writeFile(filePath, JSON.stringify(data), "utf8"); + return filePath; +} + +async function fileExists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + type HomeEnv = { HOME: string | undefined; USERPROFILE: string | undefined }; function snapshotHome(): HomeEnv { @@ -134,6 +152,120 @@ test("loadUserConfig handles bad JSON gracefully", async () => { } }); +test("loadUserConfig reads global config from agent dir (new location)", async () => { + const tmpCwd = path.join(os.tmpdir(), `acp-test-agentdir-cwd-${Date.now()}`); + const tmpHome = path.join(os.tmpdir(), `acp-test-agentdir-home-${Date.now()}`); + await fs.mkdir(tmpCwd, { recursive: true }); + await fs.mkdir(tmpHome, { recursive: true }); + const savedHome = snapshotHome(); + setHome(tmpHome); + try { + await writeFreshConfig(tmpHome, { debug: true }); + const config = await loadUserConfig(tmpCwd); + assert.equal(config.debug, true, "global config read from ~/.pi/agent/acp.json"); + } finally { + restoreHome(savedHome); + await fs.rm(tmpCwd, { recursive: true, force: true }); + await fs.rm(tmpHome, { recursive: true, force: true }); + } +}); + +test("loadUserConfig reads project config from .pi/agent (new location)", async () => { + const tmpDir = path.join(os.tmpdir(), `acp-test-project-agent-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + try { + await writeFreshConfig(tmpDir, { modelContextLimit: 12345 }); + const config = await loadUserConfig(tmpDir); + assert.equal(config.modelContextLimit, 12345, "project config read from /.pi/agent/acp.json"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("loadUserConfig reads legacy global config (backward compatibility)", async () => { + const tmpCwd = path.join(os.tmpdir(), `acp-test-legacy-cwd-${Date.now()}`); + const tmpHome = path.join(os.tmpdir(), `acp-test-legacy-home-${Date.now()}`); + await fs.mkdir(tmpCwd, { recursive: true }); + await fs.mkdir(tmpHome, { recursive: true }); + const savedHome = snapshotHome(); + setHome(tmpHome); + try { + await writeConfig(tmpHome, { debug: true, autoUpdate: false }); + const config = await loadUserConfig(tmpCwd); + assert.equal(config.debug, true, "legacy global config still read"); + assert.equal(config.autoUpdate, false); + // No files are written: the legacy file is left in place and no new file is + // auto-created (moving to the new location is a manual step). + const legacy = path.join(tmpHome, CONFIG_DIR_NAME, "acp.json"); + assert.ok(await fileExists(legacy), "legacy file untouched"); + const fresh = path.join(tmpHome, CONFIG_DIR_NAME, "agent", "acp.json"); + assert.ok(!(await fileExists(fresh)), "no file auto-created at the new location"); + } finally { + restoreHome(savedHome); + await fs.rm(tmpCwd, { recursive: true, force: true }); + await fs.rm(tmpHome, { recursive: true, force: true }); + } +}); + +test("loadUserConfig prefers new location over legacy when both exist", async () => { + const tmpCwd = path.join(os.tmpdir(), `acp-test-prec-cwd-${Date.now()}`); + const tmpHome = path.join(os.tmpdir(), `acp-test-prec-home-${Date.now()}`); + await fs.mkdir(tmpCwd, { recursive: true }); + await fs.mkdir(tmpHome, { recursive: true }); + const savedHome = snapshotHome(); + setHome(tmpHome); + try { + await writeConfig(tmpHome, { debug: true }); + await writeFreshConfig(tmpHome, { debug: false }); + const config = await loadUserConfig(tmpCwd); + assert.equal(config.debug, false, "new global location wins over legacy"); + } finally { + restoreHome(savedHome); + await fs.rm(tmpCwd, { recursive: true, force: true }); + await fs.rm(tmpHome, { recursive: true, force: true }); + } +}); + +test("loadUserConfig project (new) overrides global (new) per-field", async () => { + const tmpCwd = path.join(os.tmpdir(), `acp-test-prec2-cwd-${Date.now()}`); + const tmpHome = path.join(os.tmpdir(), `acp-test-prec2-home-${Date.now()}`); + await fs.mkdir(tmpCwd, { recursive: true }); + await fs.mkdir(tmpHome, { recursive: true }); + const savedHome = snapshotHome(); + setHome(tmpHome); + try { + await writeFreshConfig(tmpHome, { debug: true, modelContextLimit: 200_000 }); + await writeFreshConfig(tmpCwd, { debug: false }); + const config = await loadUserConfig(tmpCwd); + assert.equal(config.debug, false, "project (new) debug overrides global (new)"); + assert.equal(config.modelContextLimit, 200_000, "global (new) modelContextLimit preserved"); + } finally { + restoreHome(savedHome); + await fs.rm(tmpCwd, { recursive: true, force: true }); + await fs.rm(tmpHome, { recursive: true, force: true }); + } +}); + +test("loadUserConfig handles invalid legacy global JSON gracefully", async () => { + const tmpCwd = path.join(os.tmpdir(), `acp-test-global-badjson-cwd-${Date.now()}`); + const tmpHome = path.join(os.tmpdir(), `acp-test-global-badjson-home-${Date.now()}`); + await fs.mkdir(tmpCwd, { recursive: true }); + await fs.mkdir(tmpHome, { recursive: true }); + const savedHome = snapshotHome(); + setHome(tmpHome); + try { + const piDir = path.join(tmpHome, CONFIG_DIR_NAME); + await fs.mkdir(piDir, { recursive: true }); + await fs.writeFile(path.join(piDir, "acp.json"), "{ bad json }", "utf8"); + const config = await loadUserConfig(tmpCwd); + assert.deepEqual(config, {}, "invalid legacy global JSON yields empty config"); + } finally { + restoreHome(savedHome); + await fs.rm(tmpCwd, { recursive: true, force: true }); + await fs.rm(tmpHome, { recursive: true, force: true }); + } +}); + test("applyUserConfig merges user config onto adapter config", () => { const adapter: AdapterConfig = { modelContextLimit: 200_000,