@mariozechner/pi-coding-agent 0.52.11 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +3 -3
  3. package/dist/core/auth-storage.d.ts +34 -7
  4. package/dist/core/auth-storage.d.ts.map +1 -1
  5. package/dist/core/auth-storage.js +189 -93
  6. package/dist/core/auth-storage.js.map +1 -1
  7. package/dist/core/sdk.d.ts +1 -1
  8. package/dist/core/sdk.d.ts.map +1 -1
  9. package/dist/core/sdk.js +2 -1
  10. package/dist/core/sdk.js.map +1 -1
  11. package/dist/core/settings-manager.d.ts +45 -7
  12. package/dist/core/settings-manager.d.ts.map +1 -1
  13. package/dist/core/settings-manager.js +227 -134
  14. package/dist/core/settings-manager.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/main.d.ts.map +1 -1
  20. package/dist/main.js +13 -1
  21. package/dist/main.js.map +1 -1
  22. package/dist/modes/interactive/components/settings-selector.d.ts +3 -0
  23. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  24. package/dist/modes/interactive/components/settings-selector.js +10 -0
  25. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  26. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/dist/modes/interactive/interactive-mode.js +5 -0
  28. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/docs/sdk.md +12 -5
  30. package/docs/settings.md +3 -1
  31. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  32. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  33. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  34. package/examples/extensions/custom-provider-qwen-cli/package.json +1 -1
  35. package/examples/extensions/with-deps/package-lock.json +2 -2
  36. package/examples/extensions/with-deps/package.json +1 -1
  37. package/examples/sdk/02-custom-model.ts +1 -1
  38. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  39. package/examples/sdk/10-settings.ts +13 -0
  40. package/examples/sdk/12-full-control.ts +1 -1
  41. package/examples/sdk/README.md +3 -3
  42. package/package.json +4 -4
@@ -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
@@ -81,6 +144,11 @@ export class SettingsManager {
81
144
  settings.steeringMode = settings.queueMode;
82
145
  delete settings.queueMode;
83
146
  }
147
+ // Migrate legacy websockets boolean -> transport enum
148
+ if (!("transport" in settings) && typeof settings.websockets === "boolean") {
149
+ settings.transport = settings.websockets ? "websocket" : "sse";
150
+ delete settings.websockets;
151
+ }
84
152
  // Migrate old skills object format to new array format
85
153
  if ("skills" in settings &&
86
154
  typeof settings.skills === "object" &&
@@ -99,54 +167,42 @@ export class SettingsManager {
99
167
  }
100
168
  return settings;
101
169
  }
102
- loadProjectSettings() {
103
- // In-memory mode: return stored in-memory project settings
104
- if (!this.persist) {
105
- return structuredClone(this.inMemoryProjectSettings);
106
- }
107
- if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
108
- return {};
109
- }
110
- try {
111
- const content = readFileSync(this.projectSettingsPath, "utf-8");
112
- const settings = JSON.parse(content);
113
- return SettingsManager.migrateSettings(settings);
114
- }
115
- catch (error) {
116
- console.error(`Warning: Could not read project settings file: ${error}`);
117
- return {};
118
- }
119
- }
120
170
  getGlobalSettings() {
121
171
  return structuredClone(this.globalSettings);
122
172
  }
123
173
  getProjectSettings() {
124
- return this.loadProjectSettings();
174
+ return structuredClone(this.projectSettings);
125
175
  }
126
176
  reload() {
127
- let nextGlobalSettings = null;
128
- if (this.persist && this.settingsPath) {
129
- try {
130
- nextGlobalSettings = SettingsManager.loadFromFile(this.settingsPath);
131
- this.globalSettingsLoadError = null;
132
- }
133
- catch (error) {
134
- this.globalSettingsLoadError = error;
135
- }
177
+ const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
178
+ if (!globalLoad.error) {
179
+ this.globalSettings = globalLoad.settings;
180
+ this.globalSettingsLoadError = null;
136
181
  }
137
- if (nextGlobalSettings) {
138
- this.globalSettings = nextGlobalSettings;
182
+ else {
183
+ this.globalSettingsLoadError = globalLoad.error;
184
+ this.recordError("global", globalLoad.error);
139
185
  }
140
186
  this.modifiedFields.clear();
141
187
  this.modifiedNestedFields.clear();
142
- const projectSettings = this.loadProjectSettings();
143
- 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);
144
200
  }
145
201
  /** Apply additional overrides on top of current settings */
146
202
  applyOverrides(overrides) {
147
203
  this.settings = deepMergeSettings(this.settings, overrides);
148
204
  }
149
- /** Mark a field as modified during this session */
205
+ /** Mark a global field as modified during this session */
150
206
  markModified(field, nestedKey) {
151
207
  this.modifiedFields.add(field);
152
208
  if (nestedKey) {
@@ -156,74 +212,103 @@ export class SettingsManager {
156
212
  this.modifiedNestedFields.get(field).add(nestedKey);
157
213
  }
158
214
  }
159
- save() {
160
- if (this.persist && this.settingsPath) {
161
- // Don't overwrite if the file had parse errors on initial load
162
- if (this.globalSettingsLoadError) {
163
- // Re-merge to update active settings even though we can't persist
164
- const projectSettings = this.loadProjectSettings();
165
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
166
- return;
167
- }
168
- try {
169
- const dir = dirname(this.settingsPath);
170
- if (!existsSync(dir)) {
171
- mkdirSync(dir, { recursive: true });
172
- }
173
- // Re-read current file to get latest external changes
174
- const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath);
175
- // Start with file settings as base - preserves external edits
176
- const mergedSettings = { ...currentFileSettings };
177
- // Only override with in-memory values for fields that were explicitly modified during this session
178
- for (const field of this.modifiedFields) {
179
- const value = this.globalSettings[field];
180
- // Handle nested objects specially - merge at nested level to preserve unmodified nested keys
181
- if (this.modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
182
- const nestedModified = this.modifiedNestedFields.get(field);
183
- const baseNested = currentFileSettings[field] ?? {};
184
- const inMemoryNested = value;
185
- const mergedNested = { ...baseNested };
186
- for (const nestedKey of nestedModified) {
187
- mergedNested[nestedKey] = inMemoryNested[nestedKey];
188
- }
189
- mergedSettings[field] = mergedNested;
190
- }
191
- else {
192
- // For top-level primitives and arrays, use the modified value directly
193
- mergedSettings[field] = value;
194
- }
195
- }
196
- this.globalSettings = mergedSettings;
197
- writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
198
- }
199
- catch (error) {
200
- // File may have been externally modified with invalid JSON - don't overwrite
201
- 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());
202
221
  }
222
+ this.modifiedProjectNestedFields.get(field).add(nestedKey);
203
223
  }
204
- // Always re-merge to update active settings (needed for both file and inMemory modes)
205
- const projectSettings = this.loadProjectSettings();
206
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
207
224
  }
208
- saveProjectSettings(settings) {
209
- // In-memory mode: store in memory
210
- if (!this.persist) {
211
- 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();
212
233
  return;
213
234
  }
214
- if (!this.projectSettingsPath) {
215
- 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));
216
252
  }
217
- try {
218
- const dir = dirname(this.projectSettingsPath);
219
- if (!existsSync(dir)) {
220
- 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
+ }
221
276
  }
222
- 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;
223
284
  }
224
- catch (error) {
225
- 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;
226
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;
227
312
  }
228
313
  getLastChangelogVersion() {
229
314
  return this.settings.lastChangelogVersion;
@@ -288,6 +373,14 @@ export class SettingsManager {
288
373
  this.markModified("defaultThinkingLevel");
289
374
  this.save();
290
375
  }
376
+ getTransport() {
377
+ return this.settings.transport ?? "sse";
378
+ }
379
+ setTransport(transport) {
380
+ this.globalSettings.transport = transport;
381
+ this.markModified("transport");
382
+ this.save();
383
+ }
291
384
  getCompactionEnabled() {
292
385
  return this.settings.compaction?.enabled ?? true;
293
386
  }
@@ -385,10 +478,10 @@ export class SettingsManager {
385
478
  this.save();
386
479
  }
387
480
  setProjectPackages(packages) {
388
- const projectSettings = this.loadProjectSettings();
481
+ const projectSettings = structuredClone(this.projectSettings);
389
482
  projectSettings.packages = packages;
483
+ this.markProjectModified("packages");
390
484
  this.saveProjectSettings(projectSettings);
391
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
392
485
  }
393
486
  getExtensionPaths() {
394
487
  return [...(this.settings.extensions ?? [])];
@@ -399,10 +492,10 @@ export class SettingsManager {
399
492
  this.save();
400
493
  }
401
494
  setProjectExtensionPaths(paths) {
402
- const projectSettings = this.loadProjectSettings();
495
+ const projectSettings = structuredClone(this.projectSettings);
403
496
  projectSettings.extensions = paths;
497
+ this.markProjectModified("extensions");
404
498
  this.saveProjectSettings(projectSettings);
405
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
406
499
  }
407
500
  getSkillPaths() {
408
501
  return [...(this.settings.skills ?? [])];
@@ -413,10 +506,10 @@ export class SettingsManager {
413
506
  this.save();
414
507
  }
415
508
  setProjectSkillPaths(paths) {
416
- const projectSettings = this.loadProjectSettings();
509
+ const projectSettings = structuredClone(this.projectSettings);
417
510
  projectSettings.skills = paths;
511
+ this.markProjectModified("skills");
418
512
  this.saveProjectSettings(projectSettings);
419
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
420
513
  }
421
514
  getPromptTemplatePaths() {
422
515
  return [...(this.settings.prompts ?? [])];
@@ -427,10 +520,10 @@ export class SettingsManager {
427
520
  this.save();
428
521
  }
429
522
  setProjectPromptTemplatePaths(paths) {
430
- const projectSettings = this.loadProjectSettings();
523
+ const projectSettings = structuredClone(this.projectSettings);
431
524
  projectSettings.prompts = paths;
525
+ this.markProjectModified("prompts");
432
526
  this.saveProjectSettings(projectSettings);
433
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
434
527
  }
435
528
  getThemePaths() {
436
529
  return [...(this.settings.themes ?? [])];
@@ -441,10 +534,10 @@ export class SettingsManager {
441
534
  this.save();
442
535
  }
443
536
  setProjectThemePaths(paths) {
444
- const projectSettings = this.loadProjectSettings();
537
+ const projectSettings = structuredClone(this.projectSettings);
445
538
  projectSettings.themes = paths;
539
+ this.markProjectModified("themes");
446
540
  this.saveProjectSettings(projectSettings);
447
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
448
541
  }
449
542
  getEnableSkillCommands() {
450
543
  return this.settings.enableSkillCommands ?? true;