bermudis-pi-goodies 0.2.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
@@ -1,16 +1,17 @@
1
1
  # bermudis-pi-goodies
2
2
 
3
3
  A bundle of small, frequently-used [Pi](https://github.com/earendil-works/pi)
4
- extensions. One entry point, nine independent features.
4
+ extensions. One entry point, ten independent features.
5
5
 
6
6
  | Feature | Command / hook | What it does |
7
7
  |---------|----------------|--------------|
8
8
  | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
+ | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
9
10
  | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
10
11
  | `zed` | `/z` | Open Zed editor on the current working directory. |
11
12
  | `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
12
13
  | `model-thinking` | hook + `/model-thinking` | Apply provider/model thinking defaults and explicitly save per-model defaults. |
13
- | `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. |
14
15
  | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
15
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. |
16
17
  | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
@@ -20,7 +21,7 @@ extensions. One entry point, nine independent features.
20
21
  After publishing the package to npm:
21
22
 
22
23
  ```bash
23
- pi install npm:bermudis-pi-goodies@0.2.0
24
+ pi install npm:bermudis-pi-goodies@0.4.0
24
25
  ```
25
26
 
26
27
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -52,35 +53,47 @@ config and overwrites an existing entry. Manual thinking-level changes in Pi do
52
53
  not modify this file. All current Pi levels are accepted:
53
54
  `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
54
55
  `/model-thinking` shows the active resolution and config path;
55
- `/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.
56
59
 
57
- ## Fixed startup defaults
60
+ ## Fixed startup model
58
61
 
59
62
  Pi normally saves the last model and thinking level selected in the global
60
- 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:
61
67
 
62
68
  ```json
63
69
  {
64
- "defaultProvider": "openai-codex",
65
- "defaultModel": "gpt-5.6-luna",
66
- "defaultThinkingLevel": "max"
70
+ "provider": "openai-codex",
71
+ "model": "gpt-5.6-luna"
67
72
  }
68
73
  ```
69
74
 
70
75
  Selecting a different model still changes the active session and its transcript;
71
- `fixed-defaults` only restores the startup values after Pi persists a selection.
72
-
73
- The pinned values are the built-ins above, optionally overridden per field by
74
- `~/.pi/agent/fixed-defaults.json` omit a field to keep the built-in value for
75
- it, and hand edits are picked up without a reload. Manage it from Pi:
76
-
77
- - `/fixed-defaults set` pin the currently active model and thinking level as
78
- the new startup defaults (written to the override file and applied to
79
- `settings.json` immediately).
80
- - `/fixed-defaults` show the effective pin, the active model, and the
81
- override file path.
82
- - `/fixed-defaults reset` — delete the override file and fall back to the
83
- 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.
84
97
 
85
98
  ## Provider and balance details
86
99
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * copy-trajectory — /copy-trajectory
3
+ *
4
+ * Copies the current session's trajectory to the system clipboard as plain
5
+ * text, keeping only the human-readable conversation: user messages and
6
+ * assistant text. Tool calls, tool results, and session metadata are stripped.
7
+ *
8
+ * /copy-trajectory copy user + assistant text
9
+ * /copy-trajectory thinking also include assistant thinking blocks
10
+ *
11
+ * Uses ctx.sessionManager.getBranch() for the active (compaction-aware) branch
12
+ * and @earendil-works/pi-coding-agent's copyToClipboard for the clipboard write.
13
+ */
14
+
15
+ import {
16
+ copyToClipboard,
17
+ type ExtensionAPI,
18
+ type SessionEntry,
19
+ } from "@earendil-works/pi-coding-agent";
20
+
21
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
22
+ typeof v === "object" && v !== null;
23
+
24
+ /** Extract `text` content blocks from a message body (string or block array). */
25
+ const extractTextParts = (content: unknown): string[] => {
26
+ if (typeof content === "string") return [content];
27
+ if (!Array.isArray(content)) return [];
28
+ const parts: string[] = [];
29
+ for (const block of content) {
30
+ if (!isRecord(block)) continue;
31
+ if (block.type === "text" && typeof block.text === "string") {
32
+ parts.push(block.text);
33
+ }
34
+ }
35
+ return parts;
36
+ };
37
+
38
+ /** Extract `thinking` content blocks from an assistant message body. */
39
+ const extractThinkingParts = (content: unknown): string[] => {
40
+ if (!Array.isArray(content)) return [];
41
+ const parts: string[] = [];
42
+ for (const block of content) {
43
+ if (!isRecord(block)) continue;
44
+ if (block.type === "thinking" && typeof block.thinking === "string") {
45
+ parts.push(block.thinking);
46
+ }
47
+ }
48
+ return parts;
49
+ };
50
+
51
+ type Turn = {
52
+ role: "User" | "Assistant";
53
+ /** For assistant turns: the model that actually served the reply (or the
54
+ * requested model when the provider doesn't report the resolved one). */
55
+ model?: string;
56
+ body: string;
57
+ };
58
+
59
+ /** Build the readable turns from a branch of session entries. */
60
+ function buildTrajectory(
61
+ entries: readonly SessionEntry[],
62
+ includeThinking: boolean,
63
+ ): Turn[] {
64
+ const turns: Turn[] = [];
65
+
66
+ for (const entry of entries) {
67
+ // getBranch() returns the full SessionEntry union; the `message` member
68
+ // is discriminated by `type: "message"`.
69
+ if (entry.type !== "message") continue;
70
+ const message = entry.message;
71
+ if (message.role !== "user" && message.role !== "assistant") continue;
72
+
73
+ const chunks: string[] = [...extractTextParts(message.content)];
74
+
75
+ let model: string | undefined;
76
+ if (message.role === "assistant") {
77
+ // AssistantMessage carries both the requested `model` and, when the
78
+ // provider echoes it back, `responseModel` (the model that actually ran).
79
+ model = message.responseModel ?? message.model;
80
+ if (includeThinking) {
81
+ for (const t of extractThinkingParts(message.content)) {
82
+ chunks.push(
83
+ t
84
+ .split("\n")
85
+ .map((line) => `> ${line}`)
86
+ .join("\n"),
87
+ );
88
+ }
89
+ }
90
+ }
91
+
92
+ const body = chunks.join("\n").trim();
93
+ if (!body) continue; // skip empty / tool-only turns
94
+
95
+ turns.push({
96
+ role: message.role === "user" ? "User" : "Assistant",
97
+ model,
98
+ body,
99
+ });
100
+ }
101
+
102
+ return turns;
103
+ }
104
+
105
+ const renderTrajectory = (turns: readonly Turn[]): string =>
106
+ turns
107
+ .map((t) => {
108
+ const header =
109
+ t.role === "Assistant" && t.model
110
+ ? `## Assistant (${t.model})`
111
+ : `## ${t.role}`;
112
+ return `${header}\n\n${t.body}`;
113
+ })
114
+ .join("\n\n");
115
+
116
+ export default function (pi: ExtensionAPI) {
117
+ pi.registerCommand("copy-trajectory", {
118
+ description:
119
+ "Copy the conversation (user + assistant text, no tool calls) to the clipboard",
120
+ getArgumentCompletions: (prefix) => {
121
+ const opts = ["thinking"].filter((o) => o.startsWith(prefix));
122
+ return opts.length > 0 ? opts.map((o) => ({ value: o, label: o })) : null;
123
+ },
124
+ handler: async (args, ctx) => {
125
+ // Don't snapshot a half-streamed message.
126
+ await ctx.waitForIdle();
127
+
128
+ const arg = args.trim();
129
+ if (arg !== "" && arg !== "thinking") {
130
+ ctx.ui.notify(
131
+ `Unknown argument ${JSON.stringify(arg)}. Usage: /copy-trajectory [thinking]`,
132
+ "warning",
133
+ );
134
+ return;
135
+ }
136
+ const includeThinking = arg === "thinking";
137
+
138
+ const turns = buildTrajectory(
139
+ ctx.sessionManager.getBranch(),
140
+ includeThinking,
141
+ );
142
+
143
+ if (turns.length === 0) {
144
+ ctx.ui.notify("No messages to copy yet", "warning");
145
+ return;
146
+ }
147
+
148
+ const text = renderTrajectory(turns);
149
+
150
+ try {
151
+ await copyToClipboard(text);
152
+ } catch (err) {
153
+ ctx.ui.notify(
154
+ `Failed to copy: ${err instanceof Error ? err.message : String(err)}`,
155
+ "error",
156
+ );
157
+ return;
158
+ }
159
+
160
+ ctx.ui.notify(
161
+ `Copied ${turns.length} message${turns.length === 1 ? "" : "s"} (${text.length.toLocaleString()} chars) to clipboard`,
162
+ "info",
163
+ );
164
+ },
165
+ });
166
+ }
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
@@ -4,16 +4,18 @@
4
4
  * Composes independent modules, each registering its own commands/hooks
5
5
  * against the shared ExtensionAPI:
6
6
  * - copy-with-model /copy-with-model copy last reply tagged with the model
7
+ * - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
7
8
  * - name-with-ai /name-with-ai generate a session name via the model
8
9
  * - zed /z open Zed on cwd
9
10
  * - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
10
11
  * - model-thinking hook + command remember thinking levels by provider/model
11
- * - 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
12
13
  * - kilo provider access Kilo Gateway models
13
14
  * - provider-balance footer show Kilo credits or Codex quota in the footer
14
15
  * - tps hook notify tokens/sec and usage at each agent turn end
15
16
  */
16
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import copyTrajectory from "./copy-trajectory.ts";
17
19
  import copyWithModel from "./copy-with-model.ts";
18
20
  import fixedDefaults from "./fixed-defaults.ts";
19
21
  import kilo from "./kilo.ts";
@@ -26,6 +28,7 @@ import preferTools from "./prefer-tools.ts";
26
28
 
27
29
  export default function bermudisPiGoodies(pi: ExtensionAPI): void {
28
30
  copyWithModel(pi);
31
+ copyTrajectory(pi);
29
32
  nameWithAi(pi);
30
33
  zed(pi);
31
34
  preferTools(pi);
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.2.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"],
@@ -5,6 +5,8 @@ import type {
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { readFileSync, renameSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
8
10
 
9
11
  const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
10
12
  const KILO_BALANCE_ENDPOINT = `${KILO_API_BASE}/api/profile/balance`;
@@ -23,6 +25,23 @@ const BALANCE_FETCH_TIMEOUT_MS = 5_000;
23
25
  /** Refresh the footer balance every Nth turn end during a run. See turn_end handler. */
24
26
  const REFRESH_EVERY_N_TURNS = 5;
25
27
 
28
+ /**
29
+ * Balance cache shared across every pi process on the machine, keyed by
30
+ * provider. Two motivations:
31
+ *
32
+ * 1. The user runs several pi instances against the same metered account
33
+ * (quota/credits are per-account, not per-session), so every session may
34
+ * show the freshest reading any instance fetched.
35
+ * 2. On session switch pi tears this runtime down and reloads the extension
36
+ * for the new session (session_shutdown reason "resume" -> session_start
37
+ * reason "resume"), wiping in-memory state. Without a shared cache the new
38
+ * session's footer is blank/stale until its own first fetch lands, which
39
+ * can be agent_settled or the 5th turn_end.
40
+ */
41
+ const BALANCE_CACHE_FILE = `${process.env.HOME ?? ""}/.pi/agent/cache/provider-balance.json`;
42
+ /** Ignore cache entries older than this; stale balances mislead. */
43
+ const BALANCE_CACHE_TTL_MS = 30 * 60 * 1000;
44
+
26
45
  interface BalanceAdapter {
27
46
  fetch(token: string, signal: AbortSignal): Promise<Balance>;
28
47
  requiresOAuth?: boolean;
@@ -538,6 +557,100 @@ const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
538
557
  "openai-codex": { fetch: fetchCodexQuota, requiresOAuth: true },
539
558
  };
540
559
 
560
+ // --- Shared balance cache ---------------------------------------------------
561
+
562
+ interface BalanceCacheEntry {
563
+ fetchedAt: number;
564
+ balance: Balance;
565
+ }
566
+
567
+ type BalanceCache = Record<string, BalanceCacheEntry>;
568
+
569
+ function parseCachedBalance(value: unknown): Balance | null {
570
+ if (!Array.isArray(value)) return null;
571
+ const segments: BalanceSegment[] = [];
572
+ for (const candidate of value) {
573
+ const segment = asRecord(candidate);
574
+ if (!segment) return null;
575
+ const credits = numericProperty(segment, "credits");
576
+ const quotaRecord = asRecord(segment.quota);
577
+ const remainingPercent = numericProperty(quotaRecord, "remainingPercent");
578
+ if (
579
+ credits === null &&
580
+ (quotaRecord === null || remainingPercent === null)
581
+ ) {
582
+ return null;
583
+ }
584
+ const label = stringProperty(segment, "label") ?? undefined;
585
+ const windowSeconds = numericProperty(quotaRecord, "windowSeconds");
586
+ const resetAt = numericProperty(quotaRecord, "resetAt");
587
+ segments.push({
588
+ ...(label !== undefined ? { label } : {}),
589
+ ...(credits !== null ? { credits } : {}),
590
+ ...(quotaRecord !== null && remainingPercent !== null
591
+ ? {
592
+ quota: {
593
+ remainingPercent,
594
+ ...(windowSeconds !== null ? { windowSeconds } : {}),
595
+ ...(resetAt !== null ? { resetAt } : {}),
596
+ },
597
+ }
598
+ : {}),
599
+ });
600
+ }
601
+ return segments;
602
+ }
603
+
604
+ /** Read the freshest non-expired cached balance for a provider. */
605
+ export function readCachedBalance(
606
+ provider: string,
607
+ nowMs = Date.now(),
608
+ ): Balance | null {
609
+ let raw: string;
610
+ try {
611
+ raw = readFileSync(BALANCE_CACHE_FILE, "utf8");
612
+ } catch {
613
+ return null; // Missing/unreadable cache is a normal cold start.
614
+ }
615
+
616
+ try {
617
+ const entry = asRecord(asRecord(JSON.parse(raw))?.[provider]);
618
+ const fetchedAt = numericProperty(entry, "fetchedAt");
619
+ if (entry === null || fetchedAt === null) return null;
620
+ if (nowMs - fetchedAt >= BALANCE_CACHE_TTL_MS) return null;
621
+ return parseCachedBalance(entry.balance);
622
+ } catch {
623
+ return null; // Corrupt cache must never break the footer.
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Persist a fresh reading so other live sessions and future freshly-loaded
629
+ * sessions render it instantly. Write is atomic (tmp + rename) because
630
+ * several pi processes can fetch concurrently.
631
+ */
632
+ function writeCachedBalance(provider: string, balance: Balance): void {
633
+ let cache: BalanceCache = {};
634
+ try {
635
+ const existing = asRecord(
636
+ JSON.parse(readFileSync(BALANCE_CACHE_FILE, "utf8")),
637
+ );
638
+ if (existing) cache = existing as BalanceCache;
639
+ } catch {
640
+ // Missing or corrupt cache: start fresh rather than failing the write.
641
+ }
642
+ cache[provider] = { fetchedAt: Date.now(), balance };
643
+
644
+ try {
645
+ mkdirSync(dirname(BALANCE_CACHE_FILE), { recursive: true });
646
+ const tempFile = `${BALANCE_CACHE_FILE}.${process.pid}.tmp`;
647
+ writeFileSync(tempFile, JSON.stringify(cache));
648
+ renameSync(tempFile, BALANCE_CACHE_FILE);
649
+ } catch {
650
+ // The cache is an accelerator only; the footer works without it.
651
+ }
652
+ }
653
+
541
654
  type FooterSession = ConstructorParameters<typeof FooterComponent>[0];
542
655
  type FooterFactory = NonNullable<
543
656
  Parameters<ExtensionContext["ui"]["setFooter"]>[0]
@@ -726,6 +839,16 @@ export default function providerBalance(pi: ExtensionAPI): void {
726
839
  const providerId = provider;
727
840
  const adapter = providerId ? BALANCE_ADAPTERS[providerId] : undefined;
728
841
  if (!adapter || !providerId) return;
842
+
843
+ // Paint the freshest known value for this account immediately: another
844
+ // live pi instance may have fetched seconds ago, and on session switch
845
+ // this is what keeps the new session's footer warm instead of blank until
846
+ // its own first fetch lands.
847
+ const cached = readCachedBalance(providerId);
848
+ if (cached) {
849
+ balance = cached;
850
+ requestRender?.();
851
+ }
729
852
  if (
730
853
  adapter.requiresOAuth &&
731
854
  (!model ||
@@ -739,7 +862,10 @@ export default function providerBalance(pi: ExtensionAPI): void {
739
862
  const token = await ctx.modelRegistry.getApiKeyForProvider(providerId);
740
863
  if (!token || generation !== refreshGeneration) return;
741
864
  balance = await adapter.fetch(token, controller.signal);
742
- if (generation === refreshGeneration) requestRender?.();
865
+ if (generation === refreshGeneration) {
866
+ writeCachedBalance(providerId, balance);
867
+ requestRender?.();
868
+ }
743
869
  } catch (error) {
744
870
  if (generation !== refreshGeneration || controller.signal.aborted) return;
745
871
  // This is a best-effort background refresh. Writing to stdout/stderr while
@@ -793,6 +919,12 @@ export default function providerBalance(pi: ExtensionAPI): void {
793
919
  });
794
920
  }
795
921
 
922
+ // Fires for startup, reload, and every session switch/new/fork: pi tears
923
+ // the old runtime down (session_shutdown) and starts a fresh one, so this
924
+ // is both our initializer and our "user switched sessions" signal.
925
+ // refreshBalance seeds from the shared cache first, so a session resumed
926
+ // mid-run elsewhere shows the other instance's last reading immediately
927
+ // instead of going stale until agent_settled or the 5th turn_end.
796
928
  pi.on("session_start", (_event, ctx) => {
797
929
  activeContext = ctx;
798
930
  activeThinkingLevel = restoredThinkingLevel(ctx);