feat(KB-185): add global/project settings hierarchy with scope-aware UI

- Add GlobalSettingsStore with file-based storage in ~/.pi/kb/settings.json
- Implement two-tier hierarchy: global (user prefs) and project (.kb/config.json)
- Update TaskStore to merge settings with project overriding global
- Add API endpoints: GET/PUT /api/settings/global and GET /api/settings/scopes
- Add scope indicators (🌐 global, 📁 project) to settings modal sidebar
- Implement scope-aware save: only save active scope, reject global-only fields
- Add frontend API functions for global settings operations
- Add tests for GlobalSettingsStore, settings routes, and SettingsModal scopes
- Create changeset for the new settings hierarchy feature
- Update AGENTS.md with settings hierarchy documentation
This commit is contained in:
gsxdsm
2026-03-31 03:12:33 -07:00
parent 714f4bc415
commit 2554904421
15 changed files with 1178 additions and 104 deletions

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { GlobalSettingsStore } from "./global-settings.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
}
describe("GlobalSettingsStore", () => {
let dir: string;
let store: GlobalSettingsStore;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("init()", () => {
it("creates the directory and settings.json if missing", async () => {
const nested = join(dir, "nested", "deep");
const nestedStore = new GlobalSettingsStore(nested);
const created = await nestedStore.init();
expect(created).toBe(true);
expect(existsSync(join(nested, "settings.json"))).toBe(true);
});
it("creates settings.json with defaults on first init", async () => {
await store.init();
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("dark");
expect(parsed.colorTheme).toBe("default");
expect(parsed.ntfyEnabled).toBe(false);
});
it("returns false if settings.json already exists", async () => {
await store.init(); // creates file
const created = await store.init(); // second call
expect(created).toBe(false);
});
it("preserves existing settings on re-init", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const created = await store.init();
expect(created).toBe(false);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
});
});
describe("getSettings()", () => {
it("returns defaults when file does not exist", async () => {
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns persisted values merged with defaults", async () => {
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "settings.json"),
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
// Defaults are filled in for missing fields
expect(settings.ntfyEnabled).toBe(false);
expect(settings.defaultProvider).toBeUndefined();
});
it("returns defaults on invalid JSON", async () => {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "settings.json"), "not-json{{{");
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns defaults when directory does not exist", async () => {
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
const settings = await nonExistent.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
});
describe("updateSettings()", () => {
it("persists a partial update and returns merged settings", async () => {
await store.init();
const updated = await store.updateSettings({ themeMode: "system" });
expect(updated.themeMode).toBe("system");
expect(updated.colorTheme).toBe("default"); // unchanged default
// Verify persistence
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("system");
});
it("merges multiple updates without losing fields", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.ntfyEnabled).toBe(true);
expect(settings.ntfyTopic).toBe("my-topic");
expect(settings.themeMode).toBe("dark"); // preserved default
});
it("creates directory if missing", async () => {
const nested = join(dir, "auto", "create");
const nestedStore = new GlobalSettingsStore(nested);
await nestedStore.updateSettings({ themeMode: "light" });
expect(existsSync(join(nested, "settings.json"))).toBe(true);
const settings = await nestedStore.getSettings();
expect(settings.themeMode).toBe("light");
});
it("can clear a field by setting it to undefined", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic" });
await store.updateSettings({ defaultProvider: undefined });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
});
it("handles concurrent updates safely via locking", async () => {
await store.init();
// Fire 10 concurrent updates
const promises = Array.from({ length: 10 }, (_, i) =>
store.updateSettings({ ntfyTopic: `topic-${i}` }),
);
await Promise.all(promises);
// The final value should be one of the submitted values (last writer wins)
const settings = await store.getSettings();
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
});
});
describe("getSettingsPath()", () => {
it("returns the path to settings.json", () => {
const path = store.getSettingsPath();
expect(path).toBe(join(dir, "settings.json"));
});
});
describe("atomic writes", () => {
it("does not leave tmp files after a successful write", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const tmpPath = join(dir, "settings.json.tmp");
expect(existsSync(tmpPath)).toBe(false);
});
});
});

View File

@@ -0,0 +1,122 @@
/**
* Global settings store — manages user-level settings in `~/.pi/kb/settings.json`.
*
* Global settings persist across all kb projects for the current user.
* They include UI theme preferences, default AI model selection, and
* notification configuration.
*
* @see {@link GlobalSettings} for the full list of global fields.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
/** Default directory for global kb settings: `~/.pi/kb/` */
function defaultGlobalDir(): string {
return join(homedir(), ".pi", "kb");
}
export class GlobalSettingsStore {
private readonly settingsPath: string;
private readonly dir: string;
/** Promise chain for serializing read-modify-write cycles */
private lock: Promise<void> = Promise.resolve();
/**
* Create a GlobalSettingsStore.
* @param dir — Directory to store settings.json. Defaults to `~/.pi/kb/`.
* Accepts a custom path for testing.
*/
constructor(dir?: string) {
this.dir = dir ?? defaultGlobalDir();
this.settingsPath = join(this.dir, "settings.json");
}
/**
* Ensure the settings directory exists. Creates it recursively if needed.
* If the settings file doesn't exist, creates it with defaults.
* Returns true if the file was created for the first time.
*/
async init(): Promise<boolean> {
await mkdir(this.dir, { recursive: true });
if (!existsSync(this.settingsPath)) {
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
return true;
}
return false;
}
/**
* Read global settings from disk. Returns defaults merged with persisted values.
* If the file doesn't exist or is invalid, returns defaults without throwing.
*/
async getSettings(): Promise<GlobalSettings> {
try {
const raw = await readFile(this.settingsPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<GlobalSettings>;
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed };
} catch {
// File missing, unreadable, or invalid JSON → return defaults
return { ...DEFAULT_GLOBAL_SETTINGS };
}
}
/**
* Update global settings by merging a partial patch into the existing values.
* Only fields present in the patch are overwritten; other fields are preserved.
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
*
* @returns The full updated settings after merge.
*/
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
return this.withLock(async () => {
const current = await this.getSettings();
const updated = { ...current, ...patch };
await mkdir(this.dir, { recursive: true });
await this.atomicWrite(updated);
return updated;
});
}
/**
* Get the path to the settings file (useful for diagnostics/logging).
*/
getSettingsPath(): string {
return this.settingsPath;
}
// ── Private helpers ─────────────────────────────────────────────
/**
* Atomically write settings to disk. Writes to a temp file first,
* then renames into place (atomic on POSIX).
*/
private async atomicWrite(settings: GlobalSettings): Promise<void> {
const tmpPath = this.settingsPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(settings, null, 2));
await rename(tmpPath, this.settingsPath);
}
/**
* Serialize operations via promise chain to prevent lost-update races.
*/
private withLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.lock;
this.lock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
}

View File

@@ -1,6 +1,7 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
export { TaskStore } from "./store.js";
export { GlobalSettingsStore } from "./global-settings.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export {
isGhAvailable,

View File

@@ -12,17 +12,20 @@ function makeTmpDir(): string {
describe("TaskStore", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.stopWatching();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
async function createTestTask(): Promise<Task> {
@@ -356,8 +359,8 @@ describe("TaskStore", () => {
// ── Settings tests ────────────────────────────────────────────────
describe("model settings", () => {
it("persists defaultProvider and defaultModelId and returns them via getSettings", async () => {
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
it("persists defaultProvider and defaultModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
@@ -409,6 +412,111 @@ describe("TaskStore", () => {
});
});
// ── Global/Project Settings Merging ─────────────────────────────
describe("global/project settings merging", () => {
it("getSettings returns global defaults when no overrides exist", async () => {
const settings = await store.getSettings();
expect(settings.themeMode).toBe("dark");
expect(settings.colorTheme).toBe("default");
expect(settings.maxConcurrent).toBe(2);
});
it("global settings are visible through getSettings", async () => {
await store.updateGlobalSettings({ themeMode: "light", colorTheme: "ocean" });
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
});
it("project settings override global defaults", async () => {
await store.updateSettings({ maxConcurrent: 8 });
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(8);
});
it("updateSettings silently filters out global-only fields", async () => {
// themeMode is a global field — should not be persisted to project config
await store.updateSettings({ maxConcurrent: 5, themeMode: "light" } as any);
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
// themeMode should still be the global default, not "light"
expect(settings.themeMode).toBe("dark");
// Verify the project config doesn't contain themeMode
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.themeMode).toBeUndefined();
});
it("updateGlobalSettings persists global fields", async () => {
await store.updateGlobalSettings({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("openai");
expect(settings.defaultModelId).toBe("gpt-4o");
});
it("updateGlobalSettings emits settings:updated event", async () => {
const events: Array<{ settings: any; previous: any }> = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test" });
expect(events).toHaveLength(1);
expect(events[0].settings.ntfyEnabled).toBe(true);
expect(events[0].settings.ntfyTopic).toBe("test");
});
it("getSettingsByScope returns separated global and project settings", async () => {
await store.updateGlobalSettings({ themeMode: "system", defaultProvider: "anthropic" });
await store.updateSettings({ maxConcurrent: 4, autoMerge: false });
const { global, project } = await store.getSettingsByScope();
expect(global.themeMode).toBe("system");
expect(global.defaultProvider).toBe("anthropic");
expect(project.maxConcurrent).toBe(4);
expect(project.autoMerge).toBe(false);
});
it("getSettingsByScope does not include global keys in project settings", async () => {
// Write global-key directly into config.json for backward compat testing
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 3, themeMode: "light" };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
const { project } = await store.getSettingsByScope();
expect(project.maxConcurrent).toBe(3);
// themeMode is a global key — should not appear in project scope
expect((project as any).themeMode).toBeUndefined();
});
it("backward compat: existing projects with global fields in config.json still work", async () => {
// Simulate an old config that has both global and project fields
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 6, themeMode: "system", ntfyEnabled: true };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
// getSettings should still see these values (project overrides global)
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(6);
// These are global fields stored in old config — they show up via config.settings spread
expect(settings.themeMode).toBe("system");
expect(settings.ntfyEnabled).toBe(true);
});
it("getGlobalSettingsStore returns the store instance", () => {
const globalStore = store.getGlobalSettingsStore();
expect(globalStore).toBeDefined();
expect(globalStore.getSettingsPath()).toContain("settings.json");
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {
@@ -2773,7 +2881,7 @@ describe("TaskStore", () => {
await store.cleanupArchivedTasks();
// Create new store instance
const newStore = new TaskStore(rootDir);
const newStore = new TaskStore(rootDir, globalDir);
await newStore.init();
const entries = await newStore.readArchiveLog();
@@ -2866,7 +2974,7 @@ describe("TaskStore", () => {
await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" });
// Create new store instance
const newStore = new TaskStore(rootDir);
const newStore = new TaskStore(rootDir, globalDir);
await newStore.init();
const logs = await newStore.getActivityLog();
@@ -2915,7 +3023,8 @@ describe("TaskStore", () => {
});
it("records activity on settings:updated for important changes", async () => {
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "settings:updated" });

View File

@@ -3,8 +3,9 @@ import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
@@ -37,8 +38,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve();
/** Global settings store (`~/.pi/kb/settings.json`) */
private globalSettingsStore: GlobalSettingsStore;
constructor(private rootDir: string) {
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
this.setMaxListeners(100);
this.kbDir = join(rootDir, ".kb");
@@ -46,6 +49,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.configPath = join(this.kbDir, "config.json");
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
}
async init(): Promise<void> {
@@ -232,23 +236,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await rename(tmpPath, taskJsonPath);
}
/**
* Get merged settings: global defaults ← global user prefs ← project overrides.
*
* Returns the combined view that most consumers should use. Project-level
* values in `.kb/config.json` override global values from `~/.pi/kb/settings.json`.
*/
async getSettings(): Promise<Settings> {
const config = await this.readConfig();
return { ...DEFAULT_SETTINGS, ...config.settings };
const [globalSettings, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
return {
...DEFAULT_SETTINGS,
...globalSettings,
...config.settings,
};
}
/**
* Get settings separated by scope. Returns both the global and
* project-level settings independently (useful for the UI to show
* which scope a value comes from).
*/
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
const [globalSettings, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
// Extract only project-level keys from config.settings
const projectSettings: Partial<ProjectSettings> = {};
if (config.settings) {
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
for (const key of Object.keys(config.settings)) {
if (!globalKeySet.has(key)) {
(projectSettings as any)[key] = (config.settings as any)[key];
}
}
}
return { global: globalSettings, project: projectSettings };
}
/**
* Update project-level settings in `.kb/config.json`.
*
* Accepts `Partial<Settings>` for backward compatibility. Any global-only
* fields in the patch are silently filtered out — they will not be persisted
* to the project config. Use `updateGlobalSettings()` for global fields.
*/
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
// Filter out global-only fields — they should go through updateGlobalSettings()
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(patch)) {
if (!(GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key)) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
return this.withConfigLock(async () => {
const config = await this.readConfig();
const previous = { ...DEFAULT_SETTINGS, ...config.settings };
const updated = { ...previous, ...patch };
config.settings = updated;
const globalSettings = await this.globalSettingsStore.getSettings();
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
const updatedProjectSettings = { ...config.settings, ...projectPatch };
config.settings = updatedProjectSettings as Settings;
await this.writeConfig(config);
this.emit("settings:updated", { settings: updated, previous });
return updated;
const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings;
this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged });
return updatedMerged;
});
}
/**
* Update global (user-level) settings in `~/.pi/kb/settings.json`.
*
* These settings persist across all kb projects for the current user.
* Only fields defined in `GlobalSettings` are accepted.
*/
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> {
// Read previous state BEFORE writing so the diff is correct
const [previousGlobal, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
const updatedGlobal = await this.globalSettingsStore.updateSettings(patch);
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
// Emit settings:updated so SSE listeners pick up the change
this.emit("settings:updated", { settings: merged, previous });
return merged;
}
/**
* Get the GlobalSettingsStore instance (used by API routes).
*/
getGlobalSettingsStore(): GlobalSettingsStore {
return this.globalSettingsStore;
}
private async readConfig(): Promise<BoardConfig> {
const data = await readFile(this.configPath, "utf-8");
return JSON.parse(data);
@@ -1917,7 +2005,11 @@ ${notificationsSection}`;
/**
* Synchronous version of getSettings for internal use.
* Returns cached settings or default settings if not loaded.
* Returns project-level settings merged with defaults.
* Note: This does NOT merge global settings because it's synchronous
* and global settings require async I/O. For prompt generation this
* is fine since the fields used (ntfyEnabled, ntfyTopic) will be
* present in project config for backward compatibility.
*/
private getSettingsSync(): Settings {
// Since we can't easily make generateSpecifiedPrompt async,

View File

@@ -236,7 +236,69 @@ export interface TaskCreateInput {
validatorModelId?: string;
}
export interface Settings {
// ── Settings Scope Types ────────────────────────────────────────────────
//
// Settings are split into two scopes:
//
// 1. **GlobalSettings** — User preferences stored in `~/.pi/kb/settings.json`.
// These persist across all kb projects for the current user (theme, default
// AI models, notification preferences).
//
// 2. **ProjectSettings** — Project-specific workflow and resource settings stored
// in `.kb/config.json`. These control how the engine operates for this
// particular project (concurrency, merge strategy, worktree management, etc.).
//
// The merged view (`Settings`) combines both scopes: project values override
// global values. This is the type returned by `TaskStore.getSettings()` and
// used by most consumers.
//
// Computed/server-only fields (like `githubTokenConfigured`) live only on
// `Settings` and are injected at read time by the API layer.
/** Settings scope discriminator for UI and validation. */
export type SettingsScope = "global" | "project";
/**
* Global (user-level) settings stored in `~/.pi/kb/settings.json`.
*
* These are user preferences that persist across all kb projects.
* The dashboard UI shows these under a "Global" section.
*/
export interface GlobalSettings {
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "default". */
colorTheme?: ColorTheme;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultProvider?: string;
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultModelId?: string;
/** Default thinking effort level for AI agent sessions.
* Controls how much reasoning effort the model uses — higher levels
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
ntfyTopic?: string;
}
/**
* Project-level settings stored in `.kb/config.json`.
*
* These control how the engine operates for this particular project:
* concurrency, merge strategy, worktree management, build/test commands, etc.
* Runtime state fields (globalPause, enginePaused) also live here because
* different projects may need independent pause control.
*/
export interface ProjectSettings {
/** Hard stop: when true, all automated agent activity is **immediately**
* terminated — active triage, execution, and merge agent sessions are
* killed, and the scheduler stops dispatching new work. Acts as a
@@ -288,21 +350,10 @@ export interface Settings {
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */
taskPrefix?: string;
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
/** When true, merge commit messages include the task ID as the conventional
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
* omitted (e.g. `feat: ...`). Default: true. */
includeTaskIdInCommit?: boolean;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultProvider?: string;
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultModelId?: string;
/** AI model provider for planning/triage (specification) agent.
* Must be set together with `planningModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
@@ -325,11 +376,6 @@ export interface Settings {
autoSelectModelPreset?: boolean;
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
defaultPresetBySize?: { S?: string; M?: string; L?: string };
/** Default thinking effort level for AI agent sessions.
* Controls how much reasoning effort the model uses — higher levels
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, auto-merge will automatically resolve common conflict patterns
* (lock files, generated files, trivial conflicts) without requiring AI
* intervention. When AI resolution fails, the system will retry with escalating
@@ -344,24 +390,41 @@ export interface Settings {
* remain in triage with status "awaiting-approval" until a user approves
* or rejects the plan. Default: false. */
requirePlanApproval?: boolean;
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
ntfyTopic?: string;
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
* shows no activity (no text deltas, tool calls, or progress updates) for longer
* than this duration, the task is considered stuck and will be terminated and retried.
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
taskStuckTimeoutMs?: number;
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "default". */
colorTheme?: ColorTheme;
}
export const DEFAULT_SETTINGS: Settings = {
/**
* Merged settings view combining global and project scopes.
*
* This is the primary type returned by `TaskStore.getSettings()` and used
* by most consumers. Project settings override global settings.
*
* Also includes computed/server-only fields like `githubTokenConfigured`
* that are injected at read time by the API layer.
*/
export interface Settings extends GlobalSettings, ProjectSettings {
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
}
/** Default values for global (user-level) settings. */
export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode" | "colorTheme">> & GlobalSettings = {
themeMode: "dark",
colorTheme: "default",
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
ntfyEnabled: false,
ntfyTopic: undefined,
};
/** Default values for project-level settings. */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
@@ -375,8 +438,6 @@ export const DEFAULT_SETTINGS: Settings = {
worktreeNaming: "random",
taskPrefix: undefined,
includeTaskIdInCommit: true,
defaultProvider: undefined,
defaultModelId: undefined,
planningProvider: undefined,
planningModelId: undefined,
validatorProvider: undefined,
@@ -384,17 +445,63 @@ export const DEFAULT_SETTINGS: Settings = {
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultThinkingLevel: undefined,
autoResolveConflicts: true,
smartConflictResolution: true,
requirePlanApproval: false,
ntfyEnabled: false,
ntfyTopic: undefined,
taskStuckTimeoutMs: undefined,
themeMode: "dark",
colorTheme: "default",
};
/**
* Merged default settings (backward compatible).
* This combines global and project defaults into a single object
* that matches the legacy `DEFAULT_SETTINGS` shape.
*/
export const DEFAULT_SETTINGS: Settings = {
...DEFAULT_GLOBAL_SETTINGS,
...DEFAULT_PROJECT_SETTINGS,
};
/** Keys that belong to the global settings scope. */
export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
"themeMode",
"colorTheme",
"defaultProvider",
"defaultModelId",
"defaultThinkingLevel",
"ntfyEnabled",
"ntfyTopic",
] as const;
/** Keys that belong to the project settings scope. */
export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"globalPause",
"enginePaused",
"maxConcurrent",
"maxWorktrees",
"pollIntervalMs",
"groupOverlappingFiles",
"autoMerge",
"mergeStrategy",
"worktreeInitCommand",
"testCommand",
"buildCommand",
"recycleWorktrees",
"worktreeNaming",
"taskPrefix",
"includeTaskIdInCommit",
"planningProvider",
"planningModelId",
"validatorProvider",
"validatorModelId",
"modelPresets",
"autoSelectModelPreset",
"defaultPresetBySize",
"autoResolveConflicts",
"smartConflictResolution",
"requirePlanApproval",
"taskStuckTimeoutMs",
] as const;
export interface BoardConfig {
nextId: number;
settings?: Settings;