Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | `<project>/.pi/acp.json` | The current project only (overrides global per-field) |
| **Global** | `~/.pi/agent/acp.json` | All projects on this machine |
| **Project** | `<project>/.pi/agent/acp.json` | The current project only (overrides global per-field) |

> **Precedence:** Environment variable &gt; Project file &gt; Global file &gt; Built-in default.
> **Precedence:** Environment variable &gt; Project file &gt; Global file &gt; 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 `<project>/.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 `<project>/.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 `<project>/.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
{
Expand Down
10 changes: 6 additions & 4 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@

| 范围 | 路径 | 生效范围 |
|------|------|----------|
| **全局** | `~/.pi/acp.json` | 本机所有项目 |
| **项目** | `<项目>/.pi/acp.json` | 仅当前项目(逐字段覆盖全局) |
| **全局** | `~/.pi/agent/acp.json` | 本机所有项目 |
| **项目** | `<项目>/.pi/agent/acp.json` | 仅当前项目(逐字段覆盖全局) |

> **优先级:** 环境变量 &gt; 项目文件 &gt; 全局文件 &gt; 内置默认值。
> **优先级:** 环境变量 &gt; 项目文件 &gt; 全局文件 &gt; 内置默认值。同一范围内,新旧两个位置都存在时,新位置(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
{
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<project>/.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, `<project>/.pi/agent/acp.json` for per-project overrides) plus a few environment variables. The legacy locations (`~/.pi/acp.json`, `<project>/.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

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))。

### 日志

Expand Down
2 changes: 1 addition & 1 deletion scripts/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "...",
Expand Down
4 changes: 2 additions & 2 deletions scripts/e2e/run-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {});
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Prompts>;
/** Must be true for `prompts` overrides to take effect. Acknowledges that
* replacing the kernel's tuned compression rules may reduce summary quality
Expand Down
7 changes: 4 additions & 3 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ export interface AcpRuntime {
clearCompressRetryTracking(): void;
liveContextLimit(ctx: ExtensionContext): number;
configFor(ctx: ExtensionContext): Config;
/** Re-read ~/.<dir>/acp.json + <cwd>/<dir>/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 <agentDir>/acp.json + <cwd>/.pi/agent/acp.json (legacy ~/.pi/acp.json
* and <cwd>/.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<void>;
stateFor(ctx: ExtensionContext, liveMessages?: AgentMessage[]): Promise<{ state: CompressionState; coreMessages: ReturnType<typeof entriesToCoreMessages>; entries: SessionEntry[] }>;
save(state: CompressionState, ctx: ExtensionContext): Promise<void>;
Expand Down
59 changes: 49 additions & 10 deletions src/user-config.ts
Original file line number Diff line number Diff line change
@@ -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
* ~/.<CONFIG_DIR_NAME>/acp.json (global) and <cwd>/.<CONFIG_DIR_NAME>/acp.json
* (project-local overrides project-global). Project wins over global. */
* <agentDir>/acp.json (global, e.g. ~/.pi/agent/acp.json) and
* <cwd>/.pi/agent/acp.json (project-local). Project wins over global per-field.
* Legacy locations (~/.pi/acp.json, <cwd>/.pi/acp.json) are still read as a
* fallback for backward compatibility (issue #231). */
export interface UserAcpConfig {
debug?: boolean;
autoUpdate?: boolean;
Expand All @@ -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 <agentDir>/acp.json (e.g. ~/.pi/agent/acp.json), project at
* <cwd>/.pi/agent/acp.json. The legacy locations (~/.pi/acp.json and
* <cwd>/.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<UserAcpConfig> {
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;
Expand All @@ -48,8 +71,24 @@ export async function loadUserConfig(cwd: string): Promise<UserAcpConfig> {
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<string | null> {
if (await fileExists(scope.fresh)) return scope.fresh;
if (await fileExists(scope.legacy)) return scope.legacy;
return null;
}

async function fileExists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}

const KNOWN = new Set([
Expand Down
5 changes: 3 additions & 2 deletions tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cwd>/.pi/acp.json hot-reload ──
// ─── ISSUE-9: modelContextLimit hot-reload (legacy <cwd>/.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<string, unknown>).CURRENT_VERSION ??= "0.0.0-test";
const { mkdtempSync, writeFileSync, rmSync, mkdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
Expand Down
132 changes: 132 additions & 0 deletions tests/user-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ async function writeConfig(dir: string, data: object): Promise<string> {
return filePath;
}

/** Write to the new canonical (agent-dir) location: <base>/.pi/agent/acp.json. */
async function writeFreshConfig(base: string, data: object): Promise<string> {
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<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}

type HomeEnv = { HOME: string | undefined; USERPROFILE: string | undefined };

function snapshotHome(): HomeEnv {
Expand Down Expand Up @@ -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 <cwd>/.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,
Expand Down
Loading