bermudis-pi-goodies 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ extensions. One entry point, ten independent features.
11
11
  | `zed` | `/z` | Open Zed editor on the current working directory. |
12
12
  | `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
13
13
  | `model-thinking` | hook + `/model-thinking` | Apply provider/model thinking defaults and explicitly save per-model defaults. |
14
- | `fixed-defaults` | hook + `/fixed-defaults` | Keep the global startup provider, model, and thinking defaults fixed while allowing in-session model changes; `/fixed-defaults set` pins the current model and thinking level. |
14
+ | `fixed-defaults` | hook + `/fixed-defaults` | Keep the global startup provider and model fixed while allowing in-session model changes; `/fixed-defaults set` pins the current model. |
15
15
  | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
16
16
  | `provider-balance` | footer (no command) | Show remaining Kilo or OpenRouter credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
17
17
  | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
@@ -21,7 +21,7 @@ extensions. One entry point, ten independent features.
21
21
  After publishing the package to npm:
22
22
 
23
23
  ```bash
24
- pi install npm:bermudis-pi-goodies@0.3.0
24
+ pi install npm:bermudis-pi-goodies@0.4.0
25
25
  ```
26
26
 
27
27
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -53,35 +53,47 @@ config and overwrites an existing entry. Manual thinking-level changes in Pi do
53
53
  not modify this file. All current Pi levels are accepted:
54
54
  `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
55
55
  `/model-thinking` shows the active resolution and config path;
56
- `/model-thinking reset` deletes the whole config.
56
+ `/model-thinking reset` deletes the whole config. A malformed config is reported
57
+ and ignored; `/model-thinking set` refuses to overwrite it until you repair the
58
+ file or reset it.
57
59
 
58
- ## Fixed startup defaults
60
+ ## Fixed startup model
59
61
 
60
62
  Pi normally saves the last model and thinking level selected in the global
61
- settings file. The bundle pins those cross-session defaults to:
63
+ settings file. `fixed-defaults` pins only the cross-session provider and model;
64
+ `model-thinking` is the sole owner of model-specific thinking levels.
65
+
66
+ Create `~/.pi/agent/fixed-defaults.json` to pin a startup model:
62
67
 
63
68
  ```json
64
69
  {
65
- "defaultProvider": "openai-codex",
66
- "defaultModel": "gpt-5.6-luna",
67
- "defaultThinkingLevel": "max"
70
+ "provider": "openai-codex",
71
+ "model": "gpt-5.6-luna"
68
72
  }
69
73
  ```
70
74
 
71
75
  Selecting a different model still changes the active session and its transcript;
72
- `fixed-defaults` only restores the startup values after Pi persists a selection.
73
-
74
- The pinned values are the built-ins above, optionally overridden per field by
75
- `~/.pi/agent/fixed-defaults.json` — omit a field to keep the built-in value for
76
- it, and hand edits are picked up without a reload. Manage it from Pi:
77
-
78
- - `/fixed-defaults set` — pin the currently active model and thinking level as
79
- the new startup defaults (written to the override file and applied to
80
- `settings.json` immediately).
81
- - `/fixed-defaults` — show the effective pin, the active model, and the
82
- override file path.
83
- - `/fixed-defaults reset` — delete the override file and fall back to the
84
- built-in values.
76
+ `fixed-defaults` restores the startup model after Pi persists a selection. Pi
77
+ chooses the initial model before extensions receive `session_start`, so if you
78
+ manually create or edit a pin for B while settings still name A, the current
79
+ session remains on A and B starts with the next fresh session. Resuming an
80
+ existing session restores that session's model instead.
81
+
82
+ Older config files may contain `thinkingLevel`; that field is accepted for
83
+ compatibility but ignored and should be managed in `model-thinking.json` instead.
84
+ `fixed-defaults` logs a warning and shows the migration in its status when it
85
+ finds the legacy field. The `/fixed-defaults set` command rewrites the file in
86
+ the provider/model-only format.
87
+
88
+ Manage the pin from Pi:
89
+
90
+ - `/fixed-defaults set` — pin the currently active model as the startup model
91
+ (written to the override file and applied to `settings.json` immediately).
92
+ - `/fixed-defaults` — show the active model, pinned startup model, and override
93
+ path.
94
+ - `/fixed-defaults reset` — save the currently active model as Pi's last
95
+ selection, then delete the override file and stop pinning. With no active
96
+ model, it refuses to remove the pin.
85
97
 
86
98
  ## Provider and balance details
87
99
 
package/fixed-defaults.ts CHANGED
@@ -4,38 +4,27 @@ import {
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
7
9
  import {
8
- mkdirSync,
9
- readFileSync,
10
- renameSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
14
- import { dirname, join } from "node:path";
10
+ describeError,
11
+ unlinkIfPresent,
12
+ writeJsonFileAtomic,
13
+ } from "./json-file.ts";
15
14
 
16
15
  const CONFIG_FILENAME = "fixed-defaults.json";
17
- const ALL_LEVELS = [
18
- "off",
19
- "minimal",
20
- "low",
21
- "medium",
22
- "high",
23
- "xhigh",
24
- "max",
25
- ] as const;
26
-
27
- type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
28
-
29
16
  /**
30
- * Values read from the override file. `thinkingLevel` may layer independently;
31
- * `provider` and `model` are a coupled pair — a model id is meaningless without
32
- * its provider, so both must be present if either is. Missing fields fall back
33
- * to the built-in defaults.
17
+ * Values read from the override file. `provider` and `model` are a coupled
18
+ * pair — a model id is meaningless without its provider, so both must be
19
+ * present if either is.
20
+ *
21
+ * `thinkingLevel` was accepted by older versions. It is deliberately ignored
22
+ * for compatibility; model-thinking.ts is now the sole owner of thinking
23
+ * policy.
34
24
  */
35
25
  interface FixedDefaultsOverride {
36
26
  provider?: string;
37
27
  model?: string;
38
- thinkingLevel?: ThinkingLevel;
39
28
  }
40
29
 
41
30
  interface FixedDefaultsOptions {
@@ -45,17 +34,6 @@ interface FixedDefaultsOptions {
45
34
  configPath?: string;
46
35
  }
47
36
 
48
- function describeError(error: unknown): string {
49
- return error instanceof Error ? error.message : String(error);
50
- }
51
-
52
- function isThinkingLevel(value: unknown): value is ThinkingLevel {
53
- return (
54
- typeof value === "string" &&
55
- (ALL_LEVELS as readonly string[]).includes(value)
56
- );
57
- }
58
-
59
37
  function parseOverride(value: unknown): FixedDefaultsOverride {
60
38
  if (!value || typeof value !== "object" || Array.isArray(value)) {
61
39
  throw new Error("the top level must be an object");
@@ -82,18 +60,11 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
82
60
  }
83
61
  override.model = input.model;
84
62
  }
85
- if (input.thinkingLevel !== undefined) {
86
- if (!isThinkingLevel(input.thinkingLevel)) {
87
- throw new Error(
88
- `\`thinkingLevel\` must be one of: ${ALL_LEVELS.join(", ")}`,
89
- );
90
- }
91
- override.thinkingLevel = input.thinkingLevel;
92
- }
93
63
 
94
64
  // provider and model are a coupled pair — a model id is meaningless without
95
65
  // its provider. Require both if either is present so a hand-edited partial
96
- // file can't pin a broken default. thinkingLevel may still layer alone.
66
+ // file can't pin a broken default. The legacy thinkingLevel field is
67
+ // intentionally ignored; model-thinking.ts owns thinking policy.
97
68
  if (
98
69
  (override.provider !== undefined && override.model === undefined) ||
99
70
  (override.model !== undefined && override.provider === undefined)
@@ -106,6 +77,15 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
106
77
  return override;
107
78
  }
108
79
 
80
+ function hasLegacyThinkingLevel(value: unknown): boolean {
81
+ return (
82
+ value !== null &&
83
+ typeof value === "object" &&
84
+ !Array.isArray(value) &&
85
+ Object.prototype.hasOwnProperty.call(value, "thinkingLevel")
86
+ );
87
+ }
88
+
109
89
  /**
110
90
  * Read/write the override file. Reads are uncached (the file is tiny and the
111
91
  * events are rare), so hand edits are picked up without a reload. Writes are
@@ -113,27 +93,43 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
113
93
  */
114
94
  class DefaultsStore {
115
95
  readonly path: string;
96
+ private warnedLegacyThinkingLevel = false;
116
97
 
117
98
  constructor(path: string) {
118
99
  this.path = path;
119
100
  }
120
101
 
121
- /** Load the override; returns { override: {}, error: null } when missing. */
102
+ /** Load the override; an absent file is a valid, inactive configuration. */
122
103
  load(): LoadResult {
123
104
  let raw: string;
124
105
  try {
125
106
  raw = readFileSync(this.path, "utf8");
126
107
  } catch (error) {
127
108
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
128
- return { override: {}, error: null };
109
+ return {
110
+ present: false,
111
+ override: {},
112
+ error: null,
113
+ legacyThinkingLevel: false,
114
+ };
129
115
  }
130
116
  throw error;
131
117
  }
132
118
 
133
119
  try {
120
+ const value = JSON.parse(raw) as unknown;
121
+ const legacyThinkingLevel = hasLegacyThinkingLevel(value);
122
+ if (legacyThinkingLevel && !this.warnedLegacyThinkingLevel) {
123
+ console.warn(
124
+ `[fixed-defaults] legacy thinkingLevel found in ${this.path}; it is ignored. Add the policy to model-thinking.json instead.`,
125
+ );
126
+ this.warnedLegacyThinkingLevel = true;
127
+ }
134
128
  return {
135
- override: parseOverride(JSON.parse(raw) as unknown),
129
+ present: true,
130
+ override: parseOverride(value),
136
131
  error: null,
132
+ legacyThinkingLevel,
137
133
  };
138
134
  } catch (error) {
139
135
  const message = describeError(error);
@@ -141,61 +137,51 @@ class DefaultsStore {
141
137
  `[fixed-defaults] invalid config at ${this.path}:`,
142
138
  message,
143
139
  );
144
- return { override: {}, error: message };
140
+ return {
141
+ present: true,
142
+ override: {},
143
+ error: message,
144
+ legacyThinkingLevel: false,
145
+ };
145
146
  }
146
147
  }
147
148
 
148
149
  save(override: FixedDefaultsOverride): void {
149
- mkdirSync(dirname(this.path), { recursive: true });
150
- const temporaryPath = `${this.path}.${process.pid}.${Date.now()}.tmp`;
151
-
152
- try {
153
- writeFileSync(temporaryPath, `${JSON.stringify(override, null, 2)}\n`, {
154
- encoding: "utf8",
155
- mode: 0o600,
156
- });
157
- renameSync(temporaryPath, this.path);
158
- } catch (error) {
159
- try {
160
- unlinkSync(temporaryPath);
161
- } catch {
162
- // The temporary file usually does not exist when the initial write failed.
163
- }
164
- throw error;
165
- }
150
+ writeJsonFileAtomic(this.path, override);
151
+ this.warnedLegacyThinkingLevel = false;
166
152
  }
167
153
 
168
- reset(): boolean {
169
- try {
170
- unlinkSync(this.path);
171
- return true;
172
- } catch (error) {
173
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
174
- throw error;
175
- }
154
+ reset(): void {
155
+ // Deletion is intentionally idempotent: if another process removes the
156
+ // pin after it was loaded, reset has still reached the requested end state.
157
+ unlinkIfPresent(this.path);
158
+ this.warnedLegacyThinkingLevel = false;
176
159
  }
177
160
  }
178
161
 
179
162
  /** Result of reading the override file: the parsed values plus any error. */
180
163
  interface LoadResult {
164
+ /** Whether the override file existed when it was read. */
165
+ present: boolean;
181
166
  override: FixedDefaultsOverride;
182
167
  /** Non-null when the file existed but failed to parse or validate. */
183
168
  error: string | null;
169
+ /** True when an old, ignored thinkingLevel field should be migrated. */
170
+ legacyThinkingLevel: boolean;
184
171
  }
185
172
 
186
173
  /**
187
- * Keep Pi's cross-session defaults stable while still allowing model changes in
188
- * the current session. Pi intentionally saves the last selected model and
189
- * thinking level, so this runs after those notifications and restores the
190
- * configured startup defaults in the global settings file.
174
+ * Keep Pi's cross-session provider/model defaults stable while still allowing
175
+ * model changes in the current session. Pi intentionally saves the last
176
+ * selected model, so this runs after model-selection notifications and restores
177
+ * the configured startup model in the global settings file.
191
178
  *
192
179
  * The pin lives in `<agentDir>/fixed-defaults.json` and is the sole source of
193
180
  * truth — there are no built-in defaults, so with no override file the
194
181
  * extension is dormant and Pi's native last-selection behavior is preserved.
195
- * `thinkingLevel` may layer independently; `provider` and `model` are a
196
- * coupled pair. `/fixed-defaults set` pins the currently active model and
197
- * thinking level; `/fixed-defaults reset` stops pinning; `/fixed-defaults`
198
- * shows the active pin.
182
+ * `provider` and `model` are a coupled pair. Thinking policy belongs to
183
+ * model-thinking.ts. `/fixed-defaults set` pins the currently active model;
184
+ * `/fixed-defaults reset` stops pinning; `/fixed-defaults` shows the active pin.
199
185
  */
200
186
  export default function fixedDefaults(
201
187
  pi: ExtensionAPI,
@@ -207,25 +193,15 @@ export default function fixedDefaults(
207
193
  );
208
194
  let pending = Promise.resolve();
209
195
 
210
- async function restore(ctx: ExtensionContext): Promise<void> {
211
- const { override, error } = store.load();
212
- // A broken or absent override means no pin: leave settings untouched so
213
- // Pi's native last-selection behavior is preserved rather than guessed at.
214
- if (error) return;
215
- const hasModelPin =
216
- override.provider !== undefined && override.model !== undefined;
217
- const hasThinkingPin = override.thinkingLevel !== undefined;
218
- if (!hasModelPin && !hasThinkingPin) return;
219
-
196
+ async function persistModel(
197
+ ctx: ExtensionContext,
198
+ provider: string,
199
+ modelId: string,
200
+ ): Promise<void> {
220
201
  const settings = SettingsManager.create(ctx.cwd, agentDir, {
221
202
  projectTrusted: ctx.isProjectTrusted(),
222
203
  });
223
- if (hasModelPin) {
224
- settings.setDefaultModelAndProvider(override.provider!, override.model!);
225
- }
226
- if (hasThinkingPin) {
227
- settings.setDefaultThinkingLevel(override.thinkingLevel!);
228
- }
204
+ settings.setDefaultModelAndProvider(provider, modelId);
229
205
  await settings.flush();
230
206
 
231
207
  const errors = settings.drainErrors();
@@ -237,23 +213,47 @@ export default function fixedDefaults(
237
213
  }
238
214
  }
239
215
 
240
- function schedule(ctx: ExtensionContext): Promise<void> {
241
- const operation = pending.then(() => restore(ctx));
242
- // Keep later notifications serviceable after one failed write. The
243
- // original operation is still returned so Pi can report that failure.
244
- pending = operation.catch((error: unknown) => {
245
- console.error("[fixed-defaults] failed to restore defaults:", error);
246
- });
247
- return operation;
216
+ async function restore(ctx: ExtensionContext): Promise<void> {
217
+ const { override, error } = store.load();
218
+ // A broken or absent override means no pin: leave settings untouched so
219
+ // Pi's native last-selection behavior is preserved rather than guessed at.
220
+ if (error) return;
221
+ const hasModelPin =
222
+ override.provider !== undefined && override.model !== undefined;
223
+ if (!hasModelPin) return;
224
+
225
+ await persistModel(ctx, override.provider!, override.model!);
226
+ }
227
+
228
+ function enqueue<T>(
229
+ operation: () => Promise<T>,
230
+ failureMessage: string,
231
+ ): Promise<T> {
232
+ const queued = pending.then(operation);
233
+ // Log once at the queue boundary, then recover only the internal tail so a
234
+ // failed write does not prevent later operations. The returned promise
235
+ // still rejects, allowing commands to notify the user.
236
+ pending = queued.then(
237
+ () => undefined,
238
+ (error: unknown) => {
239
+ console.error(failureMessage, error);
240
+ },
241
+ );
242
+ return queued;
243
+ }
244
+
245
+ function schedule(
246
+ ctx: ExtensionContext,
247
+ failureMessage = "[fixed-defaults] failed to restore defaults:",
248
+ ): Promise<void> {
249
+ return enqueue(() => restore(ctx), failureMessage);
248
250
  }
249
251
 
250
252
  pi.on("session_start", (_event, ctx) => schedule(ctx));
251
253
  pi.on("model_select", (_event, ctx) => schedule(ctx));
252
- pi.on("thinking_level_select", (_event, ctx) => schedule(ctx));
253
254
 
254
255
  pi.registerCommand("fixed-defaults", {
255
- description:
256
- "Show, set, or reset the pinned startup model/thinking defaults",
256
+ description: "Show, set, or reset the pinned startup model",
257
257
  handler: async (args, ctx) => {
258
258
  const command = args.trim();
259
259
 
@@ -267,11 +267,9 @@ export default function fixedDefaults(
267
267
  return;
268
268
  }
269
269
 
270
- const level = pi.getThinkingLevel();
271
270
  const override: FixedDefaultsOverride = {
272
271
  provider: model.provider,
273
272
  model: model.id,
274
- thinkingLevel: level,
275
273
  };
276
274
  try {
277
275
  store.save(override);
@@ -283,10 +281,9 @@ export default function fixedDefaults(
283
281
 
284
282
  try {
285
283
  // Bring settings.json in line with the new pin right away instead
286
- // of waiting for the next model/thinking/session event.
287
- await schedule(ctx);
288
- } catch (error) {
289
- console.error("[fixed-defaults] failed to apply override:", error);
284
+ // of waiting for the next model/session event.
285
+ await schedule(ctx, "[fixed-defaults] failed to apply override:");
286
+ } catch {
290
287
  ctx.ui.notify(
291
288
  "Defaults saved, but applying them to settings.json failed.",
292
289
  "warning",
@@ -295,31 +292,74 @@ export default function fixedDefaults(
295
292
  }
296
293
 
297
294
  ctx.ui.notify(
298
- `Pinned ${model.provider}/${model.id} · thinking ${level} as startup defaults`,
295
+ `Pinned ${model.provider}/${model.id} as the startup model`,
299
296
  "info",
300
297
  );
301
298
  return;
302
299
  }
303
300
 
304
301
  if (command === "reset") {
305
- let removed: boolean;
302
+ type ResetResult =
303
+ | { status: "missing" }
304
+ | { status: "removed-inactive" }
305
+ | { status: "no-active-model" }
306
+ | {
307
+ status: "removed";
308
+ model: NonNullable<ExtensionContext["model"]>;
309
+ };
310
+
306
311
  try {
307
- removed = store.reset();
308
- } catch (error) {
309
- console.error("[fixed-defaults] failed to reset override:", error);
310
- ctx.ui.notify("Failed to reset fixed defaults.", "error");
311
- return;
312
+ const result = await enqueue<ResetResult>(async () => {
313
+ // Load and validate before touching settings. Invalid, legacy-only,
314
+ // and partial files are inactive, so remove them without replacing
315
+ // Pi's last selection with the currently active model.
316
+ const loaded = store.load();
317
+ if (!loaded.present) return { status: "missing" };
318
+
319
+ const hasModelPin =
320
+ loaded.error === null &&
321
+ loaded.override.provider !== undefined &&
322
+ loaded.override.model !== undefined;
323
+ if (!hasModelPin) {
324
+ store.reset();
325
+ return { status: "removed-inactive" };
326
+ }
327
+
328
+ const model = ctx.model;
329
+ if (!model) return { status: "no-active-model" };
330
+
331
+ // Save the active model before removing the valid pin. Otherwise
332
+ // the old pinned model remains in settings.json and wins at the
333
+ // next fresh start.
334
+ await persistModel(ctx, model.provider, model.id);
335
+ store.reset();
336
+ return { status: "removed", model };
337
+ }, "[fixed-defaults] failed to reset override:");
338
+
339
+ if (result.status === "missing") {
340
+ ctx.ui.notify("No fixed-defaults override file to reset.", "info");
341
+ } else if (result.status === "removed-inactive") {
342
+ ctx.ui.notify(
343
+ "Inactive or invalid fixed-defaults override removed; settings were left unchanged.",
344
+ "info",
345
+ );
346
+ } else if (result.status === "no-active-model") {
347
+ ctx.ui.notify(
348
+ "Cannot reset fixed defaults without an active model; the pin was left in place.",
349
+ "warning",
350
+ );
351
+ } else {
352
+ ctx.ui.notify(
353
+ `Fixed-defaults pin removed. Pi will use ${result.model.provider}/${result.model.id} as its last selection.`,
354
+ "info",
355
+ );
356
+ }
357
+ } catch {
358
+ ctx.ui.notify(
359
+ "Failed to reset fixed defaults. Pin removal could not be confirmed; settings may already reflect the active model.",
360
+ "error",
361
+ );
312
362
  }
313
-
314
- // Removing the override makes future events no-ops; there is nothing
315
- // to apply to settings.json now. Existing defaults are left as-is so
316
- // reset means "stop pinning," not "revert to an earlier state."
317
- ctx.ui.notify(
318
- removed
319
- ? "Fixed-defaults pin removed. Pi will use your last selection."
320
- : "No fixed-defaults override file to reset.",
321
- "info",
322
- );
323
363
  return;
324
364
  }
325
365
 
@@ -328,11 +368,10 @@ export default function fixedDefaults(
328
368
  return;
329
369
  }
330
370
 
331
- const { override, error } = store.load();
371
+ const { override, error, legacyThinkingLevel } = store.load();
332
372
  const model = ctx.model;
333
373
  const lines = [
334
374
  `model: ${model ? `${model.provider}/${model.id}` : "none"}`,
335
- `thinking: ${pi.getThinkingLevel()}`,
336
375
  "",
337
376
  ];
338
377
  if (error) {
@@ -344,14 +383,17 @@ export default function fixedDefaults(
344
383
  lines.push(`pinned provider: ${override.provider}`);
345
384
  lines.push(`pinned model: ${override.model}`);
346
385
  }
347
- if (override.thinkingLevel !== undefined) {
348
- lines.push(`pinned thinking: ${override.thinkingLevel}`);
349
- }
386
+ }
387
+ if (legacyThinkingLevel) {
388
+ lines.push(
389
+ "",
390
+ "⚠ legacy thinkingLevel is present but ignored; manage it with /model-thinking",
391
+ );
350
392
  }
351
393
  lines.push("", `override file: ${store.path}`);
352
394
  lines.push(
353
395
  "",
354
- "run `/fixed-defaults set` to pin the current model and thinking level; `/fixed-defaults reset` to stop pinning",
396
+ "run `/fixed-defaults set` to pin the current model; `/fixed-defaults reset` to stop pinning",
355
397
  );
356
398
  const message = lines.join("\n");
357
399
 
package/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * - zed /z open Zed on cwd
10
10
  * - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
11
11
  * - model-thinking hook + command remember thinking levels by provider/model
12
- * - fixed-defaults hook + command keep startup defaults stable; /fixed-defaults set pins the current model
12
+ * - fixed-defaults hook + command keep startup provider/model stable; /fixed-defaults set pins the current model
13
13
  * - kilo provider access Kilo Gateway models
14
14
  * - provider-balance footer show Kilo credits or Codex quota in the footer
15
15
  * - tps hook notify tokens/sec and usage at each agent turn end
package/json-file.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ export function describeError(error: unknown): string {
6
+ return error instanceof Error ? error.message : String(error);
7
+ }
8
+
9
+ /** Write JSON atomically without exposing a partially written config file. */
10
+ export function writeJsonFileAtomic(path: string, value: unknown): void {
11
+ mkdirSync(dirname(path), { recursive: true });
12
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
13
+
14
+ try {
15
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
16
+ encoding: "utf8",
17
+ mode: 0o600,
18
+ });
19
+ renameSync(temporaryPath, path);
20
+ } catch (writeError) {
21
+ try {
22
+ unlinkSync(temporaryPath);
23
+ } catch (cleanupError) {
24
+ if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") {
25
+ throw new AggregateError(
26
+ [writeError, cleanupError],
27
+ `failed to write ${path} and clean up ${temporaryPath}`,
28
+ );
29
+ }
30
+ }
31
+ throw writeError;
32
+ }
33
+ }
34
+
35
+ /** Delete a file if present; surface every error except an absent file. */
36
+ export function unlinkIfPresent(path: string): boolean {
37
+ try {
38
+ unlinkSync(path);
39
+ return true;
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
42
+ throw error;
43
+ }
44
+ }
package/model-thinking.ts CHANGED
@@ -3,15 +3,13 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
6
8
  import {
7
- mkdirSync,
8
- readFileSync,
9
- renameSync,
10
- statSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
14
- import { dirname, join } from "node:path";
9
+ describeError,
10
+ unlinkIfPresent,
11
+ writeJsonFileAtomic,
12
+ } from "./json-file.ts";
15
13
 
16
14
  const CONFIG_FILENAME = "model-thinking.json";
17
15
  const ALL_LEVELS = [
@@ -99,86 +97,81 @@ function fileStamp(path: string): string | undefined {
99
97
  }
100
98
  }
101
99
 
100
+ interface ConfigLoadResult {
101
+ config: ModelThinkingConfig;
102
+ /** Non-null when the file exists but could not be read or validated. */
103
+ error: string | null;
104
+ }
105
+
102
106
  class ConfigStore {
103
107
  readonly path: string;
104
108
  private initialized = false;
105
109
  private cachedStamp: string | undefined;
106
- private cachedConfig: ModelThinkingConfig = {};
110
+ private cachedResult: ConfigLoadResult = { config: {}, error: null };
107
111
 
108
112
  constructor(path: string) {
109
113
  this.path = path;
110
114
  }
111
115
 
112
- load(): ModelThinkingConfig {
116
+ load(): ConfigLoadResult {
113
117
  let stamp: string | undefined;
114
118
  try {
115
119
  stamp = fileStamp(this.path);
116
120
  } catch (error) {
117
- console.error(`[model-thinking] failed to stat ${this.path}:`, error);
118
- return {};
121
+ const message = describeError(error);
122
+ console.error(`[model-thinking] failed to stat ${this.path}:`, message);
123
+ return { config: {}, error: message };
119
124
  }
120
125
 
121
- if (this.initialized && stamp === this.cachedStamp) {
122
- return this.cachedConfig;
126
+ // Keep retrying a file that previously failed to load. A repair can leave
127
+ // its size and mtime unchanged, so an error result must not be cached by
128
+ // the same stamp as a successful read.
129
+ if (
130
+ this.initialized &&
131
+ stamp === this.cachedStamp &&
132
+ this.cachedResult.error === null
133
+ ) {
134
+ return this.cachedResult;
123
135
  }
124
136
 
125
137
  this.initialized = true;
126
138
  this.cachedStamp = stamp;
127
139
  if (stamp === undefined) {
128
- this.cachedConfig = {};
129
- return this.cachedConfig;
140
+ this.cachedResult = { config: {}, error: null };
141
+ return this.cachedResult;
130
142
  }
131
143
 
132
144
  try {
133
- this.cachedConfig = parseConfig(
134
- JSON.parse(readFileSync(this.path, "utf8")) as unknown,
135
- );
145
+ this.cachedResult = {
146
+ config: parseConfig(
147
+ JSON.parse(readFileSync(this.path, "utf8")) as unknown,
148
+ ),
149
+ error: null,
150
+ };
136
151
  } catch (error) {
152
+ const message = describeError(error);
137
153
  console.error(
138
154
  `[model-thinking] invalid config at ${this.path}:`,
139
- error instanceof Error ? error.message : error,
155
+ message,
140
156
  );
141
- this.cachedConfig = {};
157
+ this.cachedResult = { config: {}, error: message };
142
158
  }
143
- return this.cachedConfig;
159
+ return this.cachedResult;
144
160
  }
145
161
 
146
162
  save(config: ModelThinkingConfig): void {
147
- mkdirSync(dirname(this.path), { recursive: true });
148
- const temporaryPath = `${this.path}.${process.pid}.${Date.now()}.tmp`;
149
-
150
- try {
151
- writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, {
152
- encoding: "utf8",
153
- mode: 0o600,
154
- });
155
- renameSync(temporaryPath, this.path);
156
- } catch (error) {
157
- try {
158
- unlinkSync(temporaryPath);
159
- } catch {
160
- // The temporary file usually does not exist when the initial write failed.
161
- }
162
- throw error;
163
- }
164
-
165
- this.cachedConfig = config;
163
+ writeJsonFileAtomic(this.path, config);
164
+ this.cachedResult = { config, error: null };
166
165
  this.cachedStamp = fileStamp(this.path);
167
166
  this.initialized = true;
168
167
  }
169
168
 
170
169
  reset(): boolean {
171
- try {
172
- unlinkSync(this.path);
173
- } catch (error) {
174
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
175
- throw error;
176
- }
177
-
170
+ const removed = unlinkIfPresent(this.path);
178
171
  this.initialized = true;
179
172
  this.cachedStamp = undefined;
180
- this.cachedConfig = {};
181
- return true;
173
+ this.cachedResult = { config: {}, error: null };
174
+ return removed;
182
175
  }
183
176
  }
184
177
 
@@ -207,8 +200,13 @@ export default function modelThinking(
207
200
  );
208
201
  function apply(ctx: ExtensionContext, silent: boolean): void {
209
202
  const model = ctx.model;
210
- const level = resolveThinkingLevel(store.load(), model);
211
- if (!model || level === undefined) return;
203
+ const loaded = store.load();
204
+ // Never treat a broken file as an empty policy: doing so would hide the
205
+ // error and make a later `/model-thinking set` overwrite it.
206
+ if (loaded.error || !model) return;
207
+
208
+ const level = resolveThinkingLevel(loaded.config, model);
209
+ if (level === undefined) return;
212
210
 
213
211
  const before = pi.getThinkingLevel();
214
212
  pi.setThinkingLevel(level);
@@ -246,10 +244,18 @@ export default function modelThinking(
246
244
  const key = modelKey(model);
247
245
  const level = pi.getThinkingLevel();
248
246
  try {
249
- const config = store.load();
247
+ const loaded = store.load();
248
+ if (loaded.error) {
249
+ ctx.ui.notify(
250
+ `Cannot save model-thinking config: ${loaded.error}. Repair ${store.path} or run /model-thinking reset first.`,
251
+ "error",
252
+ );
253
+ return;
254
+ }
255
+
250
256
  store.save({
251
- ...config,
252
- models: { ...config.models, [key]: level },
257
+ ...loaded.config,
258
+ models: { ...loaded.config.models, [key]: level },
253
259
  });
254
260
  ctx.ui.notify(`Saved ${key}: ${level}`, "info");
255
261
  } catch (error) {
@@ -284,14 +290,19 @@ export default function modelThinking(
284
290
  }
285
291
 
286
292
  const model = ctx.model;
287
- const config = store.load();
288
- const resolved = resolveThinkingLevel(config, model);
293
+ const loaded = store.load();
294
+ const resolved = loaded.error
295
+ ? undefined
296
+ : resolveThinkingLevel(loaded.config, model);
289
297
  const lines = [
290
298
  `model: ${model ? modelKey(model) : "none"}`,
291
299
  `managed: ${resolved === undefined ? "no" : "yes"}`,
292
300
  `file: ${store.path}`,
293
301
  `saved: ${resolved ?? "none — pi handles this model natively"}`,
294
302
  `current: ${pi.getThinkingLevel()}`,
303
+ ...(loaded.error
304
+ ? [`⚠ config invalid — no policy applied: ${loaded.error}`]
305
+ : []),
295
306
  "",
296
307
  "run `/model-thinking set` to save this model and level; `/model-thinking reset` to clear all configured levels",
297
308
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "A bundle of small, frequently-used Pi extensions.",
6
6
  "keywords": ["pi-package"],