@mariozechner/pi-coding-agent 0.52.12 → 0.53.0

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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
+ import lockfile from "proper-lockfile";
3
4
  import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
4
5
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
5
6
  function deepMergeSettings(base, overrides) {
@@ -26,54 +27,116 @@ function deepMergeSettings(base, overrides) {
26
27
  }
27
28
  return result;
28
29
  }
29
- export class SettingsManager {
30
- settingsPath;
30
+ export class FileSettingsStorage {
31
+ globalSettingsPath;
31
32
  projectSettingsPath;
33
+ constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
34
+ this.globalSettingsPath = join(agentDir, "settings.json");
35
+ this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
36
+ }
37
+ withLock(scope, fn) {
38
+ const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
39
+ const dir = dirname(path);
40
+ if (!existsSync(dir)) {
41
+ mkdirSync(dir, { recursive: true });
42
+ }
43
+ let release;
44
+ try {
45
+ release = lockfile.lockSync(path, { realpath: false });
46
+ const current = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
47
+ const next = fn(current);
48
+ if (next !== undefined) {
49
+ writeFileSync(path, next, "utf-8");
50
+ }
51
+ }
52
+ finally {
53
+ if (release) {
54
+ release();
55
+ }
56
+ }
57
+ }
58
+ }
59
+ export class InMemorySettingsStorage {
60
+ global;
61
+ project;
62
+ withLock(scope, fn) {
63
+ const current = scope === "global" ? this.global : this.project;
64
+ const next = fn(current);
65
+ if (next !== undefined) {
66
+ if (scope === "global") {
67
+ this.global = next;
68
+ }
69
+ else {
70
+ this.project = next;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ export class SettingsManager {
76
+ storage;
32
77
  globalSettings;
33
- inMemoryProjectSettings; // For in-memory mode
78
+ projectSettings;
34
79
  settings;
35
- persist;
36
- modifiedFields = new Set(); // Track fields modified during session
37
- modifiedNestedFields = new Map(); // Track nested field modifications
38
- globalSettingsLoadError = null; // Track if settings file had parse errors
39
- constructor(settingsPath, projectSettingsPath, initialSettings, persist, loadError = null) {
40
- this.settingsPath = settingsPath;
41
- this.projectSettingsPath = projectSettingsPath;
42
- this.persist = persist;
43
- this.globalSettings = initialSettings;
44
- this.inMemoryProjectSettings = {};
45
- this.globalSettingsLoadError = loadError;
46
- const projectSettings = this.loadProjectSettings();
47
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
80
+ modifiedFields = new Set(); // Track global fields modified during session
81
+ modifiedNestedFields = new Map(); // Track global nested field modifications
82
+ modifiedProjectFields = new Set(); // Track project fields modified during session
83
+ modifiedProjectNestedFields = new Map(); // Track project nested field modifications
84
+ globalSettingsLoadError = null; // Track if global settings file had parse errors
85
+ projectSettingsLoadError = null; // Track if project settings file had parse errors
86
+ writeQueue = Promise.resolve();
87
+ errors;
88
+ constructor(storage, initialGlobal, initialProject, globalLoadError = null, projectLoadError = null, initialErrors = []) {
89
+ this.storage = storage;
90
+ this.globalSettings = initialGlobal;
91
+ this.projectSettings = initialProject;
92
+ this.globalSettingsLoadError = globalLoadError;
93
+ this.projectSettingsLoadError = projectLoadError;
94
+ this.errors = [...initialErrors];
95
+ this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
48
96
  }
49
97
  /** Create a SettingsManager that loads from files */
50
98
  static create(cwd = process.cwd(), agentDir = getAgentDir()) {
51
- const settingsPath = join(agentDir, "settings.json");
52
- const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
53
- let globalSettings = {};
54
- let loadError = null;
55
- try {
56
- globalSettings = SettingsManager.loadFromFile(settingsPath);
99
+ const storage = new FileSettingsStorage(cwd, agentDir);
100
+ return SettingsManager.fromStorage(storage);
101
+ }
102
+ /** Create a SettingsManager from an arbitrary storage backend */
103
+ static fromStorage(storage) {
104
+ const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
105
+ const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project");
106
+ const initialErrors = [];
107
+ if (globalLoad.error) {
108
+ initialErrors.push({ scope: "global", error: globalLoad.error });
57
109
  }
58
- catch (error) {
59
- loadError = error;
60
- console.error(`Warning: Invalid JSON in ${settingsPath}: ${error}`);
61
- console.error(`Fix the syntax error to enable settings persistence.`);
110
+ if (projectLoad.error) {
111
+ initialErrors.push({ scope: "project", error: projectLoad.error });
62
112
  }
63
- return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true, loadError);
113
+ return new SettingsManager(storage, globalLoad.settings, projectLoad.settings, globalLoad.error, projectLoad.error, initialErrors);
64
114
  }
65
115
  /** Create an in-memory SettingsManager (no file I/O) */
66
116
  static inMemory(settings = {}) {
67
- return new SettingsManager(null, null, settings, false);
68
- }
69
- static loadFromFile(path) {
70
- if (!existsSync(path)) {
117
+ const storage = new InMemorySettingsStorage();
118
+ return new SettingsManager(storage, settings, {});
119
+ }
120
+ static loadFromStorage(storage, scope) {
121
+ let content;
122
+ storage.withLock(scope, (current) => {
123
+ content = current;
124
+ return undefined;
125
+ });
126
+ if (!content) {
71
127
  return {};
72
128
  }
73
- const content = readFileSync(path, "utf-8");
74
129
  const settings = JSON.parse(content);
75
130
  return SettingsManager.migrateSettings(settings);
76
131
  }
132
+ static tryLoadFromStorage(storage, scope) {
133
+ try {
134
+ return { settings: SettingsManager.loadFromStorage(storage, scope), error: null };
135
+ }
136
+ catch (error) {
137
+ return { settings: {}, error: error };
138
+ }
139
+ }
77
140
  /** Migrate old settings format to new format */
78
141
  static migrateSettings(settings) {
79
142
  // Migrate queueMode -> steeringMode
@@ -104,54 +167,42 @@ export class SettingsManager {
104
167
  }
105
168
  return settings;
106
169
  }
107
- loadProjectSettings() {
108
- // In-memory mode: return stored in-memory project settings
109
- if (!this.persist) {
110
- return structuredClone(this.inMemoryProjectSettings);
111
- }
112
- if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
113
- return {};
114
- }
115
- try {
116
- const content = readFileSync(this.projectSettingsPath, "utf-8");
117
- const settings = JSON.parse(content);
118
- return SettingsManager.migrateSettings(settings);
119
- }
120
- catch (error) {
121
- console.error(`Warning: Could not read project settings file: ${error}`);
122
- return {};
123
- }
124
- }
125
170
  getGlobalSettings() {
126
171
  return structuredClone(this.globalSettings);
127
172
  }
128
173
  getProjectSettings() {
129
- return this.loadProjectSettings();
174
+ return structuredClone(this.projectSettings);
130
175
  }
131
176
  reload() {
132
- let nextGlobalSettings = null;
133
- if (this.persist && this.settingsPath) {
134
- try {
135
- nextGlobalSettings = SettingsManager.loadFromFile(this.settingsPath);
136
- this.globalSettingsLoadError = null;
137
- }
138
- catch (error) {
139
- this.globalSettingsLoadError = error;
140
- }
177
+ const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
178
+ if (!globalLoad.error) {
179
+ this.globalSettings = globalLoad.settings;
180
+ this.globalSettingsLoadError = null;
141
181
  }
142
- if (nextGlobalSettings) {
143
- this.globalSettings = nextGlobalSettings;
182
+ else {
183
+ this.globalSettingsLoadError = globalLoad.error;
184
+ this.recordError("global", globalLoad.error);
144
185
  }
145
186
  this.modifiedFields.clear();
146
187
  this.modifiedNestedFields.clear();
147
- const projectSettings = this.loadProjectSettings();
148
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
188
+ this.modifiedProjectFields.clear();
189
+ this.modifiedProjectNestedFields.clear();
190
+ const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project");
191
+ if (!projectLoad.error) {
192
+ this.projectSettings = projectLoad.settings;
193
+ this.projectSettingsLoadError = null;
194
+ }
195
+ else {
196
+ this.projectSettingsLoadError = projectLoad.error;
197
+ this.recordError("project", projectLoad.error);
198
+ }
199
+ this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
149
200
  }
150
201
  /** Apply additional overrides on top of current settings */
151
202
  applyOverrides(overrides) {
152
203
  this.settings = deepMergeSettings(this.settings, overrides);
153
204
  }
154
- /** Mark a field as modified during this session */
205
+ /** Mark a global field as modified during this session */
155
206
  markModified(field, nestedKey) {
156
207
  this.modifiedFields.add(field);
157
208
  if (nestedKey) {
@@ -161,74 +212,103 @@ export class SettingsManager {
161
212
  this.modifiedNestedFields.get(field).add(nestedKey);
162
213
  }
163
214
  }
164
- save() {
165
- if (this.persist && this.settingsPath) {
166
- // Don't overwrite if the file had parse errors on initial load
167
- if (this.globalSettingsLoadError) {
168
- // Re-merge to update active settings even though we can't persist
169
- const projectSettings = this.loadProjectSettings();
170
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
171
- return;
172
- }
173
- try {
174
- const dir = dirname(this.settingsPath);
175
- if (!existsSync(dir)) {
176
- mkdirSync(dir, { recursive: true });
177
- }
178
- // Re-read current file to get latest external changes
179
- const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath);
180
- // Start with file settings as base - preserves external edits
181
- const mergedSettings = { ...currentFileSettings };
182
- // Only override with in-memory values for fields that were explicitly modified during this session
183
- for (const field of this.modifiedFields) {
184
- const value = this.globalSettings[field];
185
- // Handle nested objects specially - merge at nested level to preserve unmodified nested keys
186
- if (this.modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
187
- const nestedModified = this.modifiedNestedFields.get(field);
188
- const baseNested = currentFileSettings[field] ?? {};
189
- const inMemoryNested = value;
190
- const mergedNested = { ...baseNested };
191
- for (const nestedKey of nestedModified) {
192
- mergedNested[nestedKey] = inMemoryNested[nestedKey];
193
- }
194
- mergedSettings[field] = mergedNested;
195
- }
196
- else {
197
- // For top-level primitives and arrays, use the modified value directly
198
- mergedSettings[field] = value;
199
- }
200
- }
201
- this.globalSettings = mergedSettings;
202
- writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
203
- }
204
- catch (error) {
205
- // File may have been externally modified with invalid JSON - don't overwrite
206
- console.error(`Warning: Could not save settings file: ${error}`);
215
+ /** Mark a project field as modified during this session */
216
+ markProjectModified(field, nestedKey) {
217
+ this.modifiedProjectFields.add(field);
218
+ if (nestedKey) {
219
+ if (!this.modifiedProjectNestedFields.has(field)) {
220
+ this.modifiedProjectNestedFields.set(field, new Set());
207
221
  }
222
+ this.modifiedProjectNestedFields.get(field).add(nestedKey);
208
223
  }
209
- // Always re-merge to update active settings (needed for both file and inMemory modes)
210
- const projectSettings = this.loadProjectSettings();
211
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
212
224
  }
213
- saveProjectSettings(settings) {
214
- // In-memory mode: store in memory
215
- if (!this.persist) {
216
- this.inMemoryProjectSettings = structuredClone(settings);
225
+ recordError(scope, error) {
226
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
227
+ this.errors.push({ scope, error: normalizedError });
228
+ }
229
+ clearModifiedScope(scope) {
230
+ if (scope === "global") {
231
+ this.modifiedFields.clear();
232
+ this.modifiedNestedFields.clear();
217
233
  return;
218
234
  }
219
- if (!this.projectSettingsPath) {
220
- return;
235
+ this.modifiedProjectFields.clear();
236
+ this.modifiedProjectNestedFields.clear();
237
+ }
238
+ enqueueWrite(scope, task) {
239
+ this.writeQueue = this.writeQueue
240
+ .then(() => {
241
+ task();
242
+ this.clearModifiedScope(scope);
243
+ })
244
+ .catch((error) => {
245
+ this.recordError(scope, error);
246
+ });
247
+ }
248
+ cloneModifiedNestedFields(source) {
249
+ const snapshot = new Map();
250
+ for (const [key, value] of source.entries()) {
251
+ snapshot.set(key, new Set(value));
221
252
  }
222
- try {
223
- const dir = dirname(this.projectSettingsPath);
224
- if (!existsSync(dir)) {
225
- mkdirSync(dir, { recursive: true });
253
+ return snapshot;
254
+ }
255
+ persistScopedSettings(scope, snapshotSettings, modifiedFields, modifiedNestedFields) {
256
+ this.storage.withLock(scope, (current) => {
257
+ const currentFileSettings = current
258
+ ? SettingsManager.migrateSettings(JSON.parse(current))
259
+ : {};
260
+ const mergedSettings = { ...currentFileSettings };
261
+ for (const field of modifiedFields) {
262
+ const value = snapshotSettings[field];
263
+ if (modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
264
+ const nestedModified = modifiedNestedFields.get(field);
265
+ const baseNested = currentFileSettings[field] ?? {};
266
+ const inMemoryNested = value;
267
+ const mergedNested = { ...baseNested };
268
+ for (const nestedKey of nestedModified) {
269
+ mergedNested[nestedKey] = inMemoryNested[nestedKey];
270
+ }
271
+ mergedSettings[field] = mergedNested;
272
+ }
273
+ else {
274
+ mergedSettings[field] = value;
275
+ }
226
276
  }
227
- writeFileSync(this.projectSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
277
+ return JSON.stringify(mergedSettings, null, 2);
278
+ });
279
+ }
280
+ save() {
281
+ this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
282
+ if (this.globalSettingsLoadError) {
283
+ return;
228
284
  }
229
- catch (error) {
230
- console.error(`Warning: Could not save project settings file: ${error}`);
285
+ const snapshotGlobalSettings = structuredClone(this.globalSettings);
286
+ const modifiedFields = new Set(this.modifiedFields);
287
+ const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedNestedFields);
288
+ this.enqueueWrite("global", () => {
289
+ this.persistScopedSettings("global", snapshotGlobalSettings, modifiedFields, modifiedNestedFields);
290
+ });
291
+ }
292
+ saveProjectSettings(settings) {
293
+ this.projectSettings = structuredClone(settings);
294
+ this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
295
+ if (this.projectSettingsLoadError) {
296
+ return;
231
297
  }
298
+ const snapshotProjectSettings = structuredClone(this.projectSettings);
299
+ const modifiedFields = new Set(this.modifiedProjectFields);
300
+ const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectNestedFields);
301
+ this.enqueueWrite("project", () => {
302
+ this.persistScopedSettings("project", snapshotProjectSettings, modifiedFields, modifiedNestedFields);
303
+ });
304
+ }
305
+ async flush() {
306
+ await this.writeQueue;
307
+ }
308
+ drainErrors() {
309
+ const drained = [...this.errors];
310
+ this.errors = [];
311
+ return drained;
232
312
  }
233
313
  getLastChangelogVersion() {
234
314
  return this.settings.lastChangelogVersion;
@@ -398,10 +478,10 @@ export class SettingsManager {
398
478
  this.save();
399
479
  }
400
480
  setProjectPackages(packages) {
401
- const projectSettings = this.loadProjectSettings();
481
+ const projectSettings = structuredClone(this.projectSettings);
402
482
  projectSettings.packages = packages;
483
+ this.markProjectModified("packages");
403
484
  this.saveProjectSettings(projectSettings);
404
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
405
485
  }
406
486
  getExtensionPaths() {
407
487
  return [...(this.settings.extensions ?? [])];
@@ -412,10 +492,10 @@ export class SettingsManager {
412
492
  this.save();
413
493
  }
414
494
  setProjectExtensionPaths(paths) {
415
- const projectSettings = this.loadProjectSettings();
495
+ const projectSettings = structuredClone(this.projectSettings);
416
496
  projectSettings.extensions = paths;
497
+ this.markProjectModified("extensions");
417
498
  this.saveProjectSettings(projectSettings);
418
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
419
499
  }
420
500
  getSkillPaths() {
421
501
  return [...(this.settings.skills ?? [])];
@@ -426,10 +506,10 @@ export class SettingsManager {
426
506
  this.save();
427
507
  }
428
508
  setProjectSkillPaths(paths) {
429
- const projectSettings = this.loadProjectSettings();
509
+ const projectSettings = structuredClone(this.projectSettings);
430
510
  projectSettings.skills = paths;
511
+ this.markProjectModified("skills");
431
512
  this.saveProjectSettings(projectSettings);
432
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
433
513
  }
434
514
  getPromptTemplatePaths() {
435
515
  return [...(this.settings.prompts ?? [])];
@@ -440,10 +520,10 @@ export class SettingsManager {
440
520
  this.save();
441
521
  }
442
522
  setProjectPromptTemplatePaths(paths) {
443
- const projectSettings = this.loadProjectSettings();
523
+ const projectSettings = structuredClone(this.projectSettings);
444
524
  projectSettings.prompts = paths;
525
+ this.markProjectModified("prompts");
445
526
  this.saveProjectSettings(projectSettings);
446
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
447
527
  }
448
528
  getThemePaths() {
449
529
  return [...(this.settings.themes ?? [])];
@@ -454,10 +534,10 @@ export class SettingsManager {
454
534
  this.save();
455
535
  }
456
536
  setProjectThemePaths(paths) {
457
- const projectSettings = this.loadProjectSettings();
537
+ const projectSettings = structuredClone(this.projectSettings);
458
538
  projectSettings.themes = paths;
539
+ this.markProjectModified("themes");
459
540
  this.saveProjectSettings(projectSettings);
460
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
461
541
  }
462
542
  getEnableSkillCommands() {
463
543
  return this.settings.enableSkillCommands ?? true;