@evandu/super-pi 0.1.104 → 0.1.106

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/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +8 -7
  3. package/dist/client/assets/index-DBR_t9lF.js +79 -0
  4. package/dist/client/assets/index-mo5Dvh_v.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/application/catalog-service.d.ts +9 -0
  7. package/dist/server/application/catalog-service.js +11 -0
  8. package/dist/server/application/provider-service.d.ts +77 -0
  9. package/dist/server/application/provider-service.js +165 -0
  10. package/dist/server/application/run-service.d.ts +19 -0
  11. package/dist/server/application/run-service.js +78 -0
  12. package/dist/server/command-handler.d.ts +16 -1
  13. package/dist/server/command-handler.js +40 -1
  14. package/dist/server/content-search.d.ts +32 -0
  15. package/dist/server/content-search.js +155 -0
  16. package/dist/server/file-changes.d.ts +119 -0
  17. package/dist/server/file-changes.js +278 -0
  18. package/dist/server/file-diff.d.ts +38 -0
  19. package/dist/server/file-diff.js +97 -0
  20. package/dist/server/file-index.d.ts +56 -0
  21. package/dist/server/file-index.js +150 -0
  22. package/dist/server/files.d.ts +8 -0
  23. package/dist/server/files.js +3 -1
  24. package/dist/server/git.d.ts +17 -0
  25. package/dist/server/git.js +39 -0
  26. package/dist/server/http-server.js +121 -1
  27. package/dist/server/index.js +28 -1
  28. package/dist/server/pi-session-events.d.ts +9 -0
  29. package/dist/server/pi-session-events.js +12 -0
  30. package/dist/server/pi-session-pool.d.ts +19 -1
  31. package/dist/server/pi-session-pool.js +13 -1
  32. package/dist/server/pi-session.d.ts +10 -0
  33. package/dist/server/pi-session.js +11 -0
  34. package/dist/server/providers.d.ts +160 -0
  35. package/dist/server/providers.js +396 -0
  36. package/dist/server/runtime.d.ts +67 -0
  37. package/dist/server/runtime.js +54 -2
  38. package/dist/server/server-context.d.ts +15 -0
  39. package/dist/server/types.d.ts +143 -0
  40. package/package.json +14 -1
  41. package/dist/client/assets/index-DDkbPYLz.css +0 -1
  42. package/dist/client/assets/index-mAu39Bra.js +0 -74
@@ -0,0 +1,160 @@
1
+ import type { PublicProvider } from "./types.js";
2
+ /**
3
+ * THE PROVIDERS A PERSON ADDED, and the two files that make Pi know about them.
4
+ *
5
+ * WHAT THIS REPLACES. Pi's own answer to "I want to use a model that is not in your list" is a
6
+ * hand-written `~/.pi/agent/models.json` (the provider and model definitions) plus a key in
7
+ * `~/.pi/agent/auth.json`. There is no command for it and no UI for it - the files are the interface.
8
+ * This module is that interface, in a form: the settings window collects the same fields and writes
9
+ * the same files.
10
+ *
11
+ * WHY THE FILES AND NOT A RUNTIME REGISTRATION. Pi's SDK can register a provider in memory
12
+ * (`ModelRuntime.registerProvider`) and hold a key in memory (`setRuntimeApiKey`), and both are
13
+ * tempting - they touch nothing of the user's. They do not work here, for two reasons that are
14
+ * facts about this codebase rather than opinions:
15
+ *
16
+ * 1. `ModelRuntime` is created PER SESSION here (`pi-session.ts:498`), so a provider registered on
17
+ * one instance is invisible to the next conversation.
18
+ * 2. A spawned Pi (`pi --mode rpc`, the one-shot mode) has no provider-registration command at all
19
+ * - its RPC surface is `set_model` and `get_available_models` and nothing that defines a
20
+ * provider. A provider that exists only in this process's memory cannot reach it.
21
+ *
22
+ * The files, by contrast, are read by BOTH paths: the SDK's `ModelRuntime.create()` loads
23
+ * `models.json` and `auth.json` from Pi's agent dir, and a spawned Pi does the same. So writing them
24
+ * is the only way one setting works everywhere - including in the terminal, where `pi` sees the same
25
+ * provider the person just added here.
26
+ *
27
+ * THE TRUTH SOURCE IS OURS, THE FILES ARE DERIVED. `<dataDir>/providers.json` (0600, holds the key)
28
+ * says which providers Super Pi manages. Pi's two files are rewritten from it after every change, by
29
+ * MERGING: only the provider ids we manage are touched, everything else in those files is left alone.
30
+ * That is what makes "delete the provider I added" safe, and it is why a person who hand-wrote their
31
+ * own provider keeps it.
32
+ *
33
+ * THE KEY NEVER REACHES THE BROWSER. It lives in two files with restrictive permissions - ours and
34
+ * Pi's `auth.json` - and in neither the state snapshot nor any event. What the browser gets is
35
+ * `hasKey: true`.
36
+ */
37
+ /** One model of a provider, as the settings window collects it. */
38
+ export type ProviderModelDefinition = {
39
+ id: string;
40
+ name?: string;
41
+ reasoning?: boolean;
42
+ imageInput?: boolean;
43
+ contextWindow?: number;
44
+ maxTokens?: number;
45
+ /**
46
+ * The two compatibility switches an OpenAI-compatible server sometimes needs.
47
+ *
48
+ * `supportsDeveloperRole: false` makes Pi send the system prompt as a `system` message instead of
49
+ * a `developer` one, which is what relays and local servers that predate the `developer` role need.
50
+ * `supportsReasoningEffort: false` stops Pi from sending `reasoning_effort` to a server that does
51
+ * not know the field. Both are per-provider in Pi's schema and per-model here, because a server can
52
+ * be one and not the other.
53
+ */
54
+ supportsDeveloperRole?: boolean;
55
+ supportsReasoningEffort?: boolean;
56
+ };
57
+ /** One provider, as the settings window collects it. */
58
+ export type ProviderDefinition = {
59
+ id: string;
60
+ name?: string;
61
+ baseUrl: string;
62
+ api: string;
63
+ /** The API key. Stored, never broadcast. */
64
+ apiKey?: string;
65
+ models: ProviderModelDefinition[];
66
+ };
67
+ /** One model, as the browser sees it. No key, no nothing secret - there is nothing secret here. */
68
+ export type { PublicProvider, PublicProviderModel } from "./types.js";
69
+ /**
70
+ * The API adapters Pi ships, as the ids a provider may name.
71
+ *
72
+ * Taken from Pi's own `KnownApi` union (`pi-ai/dist/types.d.ts`) rather than invented: the id ends
73
+ * up in `models.json` and Pi looks the adapter up by exactly this string, and an unknown one fails
74
+ * at REQUEST time ("No API provider registered for api: ...") rather than at load time. Offering the
75
+ * list in the form is the difference between a person finding the right value and guessing.
76
+ */
77
+ export declare const KNOWN_APIS: readonly ["openai-completions", "anthropic-messages", "openai-responses", "azure-openai-responses", "openai-codex-responses", "mistral-conversations", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "pi-messages"];
78
+ /**
79
+ * Pi's global configuration directory - where `models.json` and `auth.json` live.
80
+ *
81
+ * `PI_CODING_AGENT_DIR` wins, because that is Pi's own rule (`core/config.js:getAgentDir`) and a
82
+ * person who set it expects Pi to read its config from there. The fallback is Pi's own default,
83
+ * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`
84
+ * - on Windows the two disagree often enough to matter.
85
+ */
86
+ export declare function piAgentDir(): string;
87
+ /** Pi's provider and model definitions. */
88
+ export declare function piModelsPath(agentDir?: string): string;
89
+ /** Pi's credential store. */
90
+ export declare function piAuthPath(agentDir?: string): string;
91
+ /**
92
+ * Strips JSONC comments, the way Pi's own parser does.
93
+ *
94
+ * Pi reads `models.json` with comments allowed, so a person may well have written comments in it -
95
+ * and Super Pi has to be able to READ that file to merge into it. A naive strip would corrupt a
96
+ * string that contains `//` (a base URL with a path, an api key with a slash), so this walks the
97
+ * text and tracks whether it is inside a string.
98
+ */
99
+ export declare function stripJsonComments(text: string): string;
100
+ /**
101
+ * The providers Super Pi manages, and the writes that make Pi know about them.
102
+ *
103
+ * The class owns three files: its own registry, Pi's `models.json` and Pi's `auth.json`. Only the
104
+ * first is a truth source; the other two are rewritten from it.
105
+ */
106
+ export declare class ProviderRegistry {
107
+ private readonly dataDir;
108
+ private readonly piDir;
109
+ /**
110
+ * `piDir` is Pi's own configuration directory, and it is a PARAMETER rather than a lookup at
111
+ * write time for one reason: a test that forgets to set `PI_CODING_AGENT_DIR` would then write to
112
+ * the developer's real `~/.pi/agent`. With the directory in hand, a test cannot reach it by
113
+ * accident - it has to be given one.
114
+ */
115
+ constructor(dataDir: string, piDir?: string);
116
+ /** Super Pi's own registry: the providers it manages, with their keys. */
117
+ private get registryPath();
118
+ /**
119
+ * Reads the registry.
120
+ *
121
+ * A missing file is an empty registry, not an error: nobody has added a provider yet. A file that
122
+ * cannot be parsed IS an error, because reading it as empty would let the next write replace
123
+ * somebody's keys with nothing.
124
+ */
125
+ load(): Promise<ProviderDefinition[]>;
126
+ /** Writes the registry. The key lives here, so the file is 0600. */
127
+ save(providers: ProviderDefinition[]): Promise<void>;
128
+ /**
129
+ * Makes Pi's files match the registry.
130
+ *
131
+ * `previousIds` are the providers Super Pi managed before this change: they are the only ones
132
+ * whose entries may be REMOVED. An id that is in Pi's `models.json` and not in `previousIds` belongs
133
+ * to the person who wrote that file, and touching it would be Super Pi editing a provider it does
134
+ * not own - so the write is refused with a message naming the id.
135
+ */
136
+ applyToPi(previous: ProviderDefinition[], next: ProviderDefinition[]): Promise<void>;
137
+ /**
138
+ * Writes the keys into Pi's `auth.json`.
139
+ *
140
+ * A credential is REMOVED only when it is the one Super Pi wrote: the same provider id and the
141
+ * same key. A person who replaced our key with their own - or who ran Pi's `/login` and put an
142
+ * OAuth token there - keeps it. Deleting somebody else's credential because they removed our
143
+ * provider would be the worst version of this feature.
144
+ *
145
+ * TWO CASES, and the second is the one that is easy to miss: the provider can be gone ENTIRELY
146
+ * (it was removed), or it can still be there with its key GONE (the person asked for the key to be
147
+ * cleared). Both have to take the credential with them, or "clear the key" would leave a working
148
+ * key sitting in Pi's auth file.
149
+ */
150
+ private applyKeysToPi;
151
+ }
152
+ /** The provider list as the browser sees it: the key becomes a fact. */
153
+ export declare function publicProviders(providers: ProviderDefinition[]): PublicProvider[];
154
+ /**
155
+ * Checks what the form collected and returns it in the shape the registry stores.
156
+ *
157
+ * EVERY RULE HERE SAYS WHY, because a message a person reads is the only validation they get: the
158
+ * server cannot ask a follow-up question, so a refusal has to name the field and the reason.
159
+ */
160
+ export declare function validateProvider(input: unknown): ProviderDefinition;
@@ -0,0 +1,396 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * The API adapters Pi ships, as the ids a provider may name.
6
+ *
7
+ * Taken from Pi's own `KnownApi` union (`pi-ai/dist/types.d.ts`) rather than invented: the id ends
8
+ * up in `models.json` and Pi looks the adapter up by exactly this string, and an unknown one fails
9
+ * at REQUEST time ("No API provider registered for api: ...") rather than at load time. Offering the
10
+ * list in the form is the difference between a person finding the right value and guessing.
11
+ */
12
+ export const KNOWN_APIS = [
13
+ "openai-completions",
14
+ "anthropic-messages",
15
+ "openai-responses",
16
+ "azure-openai-responses",
17
+ "openai-codex-responses",
18
+ "mistral-conversations",
19
+ "google-generative-ai",
20
+ "google-vertex",
21
+ "bedrock-converse-stream",
22
+ "pi-messages",
23
+ ];
24
+ /** The context window a model gets when the form does not say. Pi's own default. */
25
+ const DEFAULT_CONTEXT_WINDOW = 128000;
26
+ /** The output budget a model gets when the form does not say. Pi's own default. */
27
+ const DEFAULT_MAX_TOKENS = 16384;
28
+ /**
29
+ * Pi's global configuration directory - where `models.json` and `auth.json` live.
30
+ *
31
+ * `PI_CODING_AGENT_DIR` wins, because that is Pi's own rule (`core/config.js:getAgentDir`) and a
32
+ * person who set it expects Pi to read its config from there. The fallback is Pi's own default,
33
+ * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`
34
+ * - on Windows the two disagree often enough to matter.
35
+ */
36
+ export function piAgentDir() {
37
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
38
+ if (configured)
39
+ return configured;
40
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
41
+ return home ? join(home, ".pi", "agent") : join(".pi", "agent");
42
+ }
43
+ /** Pi's provider and model definitions. */
44
+ export function piModelsPath(agentDir = piAgentDir()) {
45
+ return join(agentDir, "models.json");
46
+ }
47
+ /** Pi's credential store. */
48
+ export function piAuthPath(agentDir = piAgentDir()) {
49
+ return join(agentDir, "auth.json");
50
+ }
51
+ /**
52
+ * Strips JSONC comments, the way Pi's own parser does.
53
+ *
54
+ * Pi reads `models.json` with comments allowed, so a person may well have written comments in it -
55
+ * and Super Pi has to be able to READ that file to merge into it. A naive strip would corrupt a
56
+ * string that contains `//` (a base URL with a path, an api key with a slash), so this walks the
57
+ * text and tracks whether it is inside a string.
58
+ */
59
+ export function stripJsonComments(text) {
60
+ let out = "";
61
+ let inString = false;
62
+ let inLineComment = false;
63
+ let inBlockComment = false;
64
+ for (let index = 0; index < text.length; index++) {
65
+ const char = text[index];
66
+ const next = text[index + 1];
67
+ if (inLineComment) {
68
+ if (char === "\n") {
69
+ inLineComment = false;
70
+ out += char;
71
+ }
72
+ else
73
+ out += " ";
74
+ continue;
75
+ }
76
+ if (inBlockComment) {
77
+ if (char === "*" && next === "/") {
78
+ inBlockComment = false;
79
+ out += " ";
80
+ index++;
81
+ }
82
+ else
83
+ out += char === "\n" ? "\n" : " ";
84
+ continue;
85
+ }
86
+ if (inString) {
87
+ out += char;
88
+ if (char === "\\" && next !== undefined) {
89
+ out += next;
90
+ index++;
91
+ }
92
+ else if (char === "\"")
93
+ inString = false;
94
+ continue;
95
+ }
96
+ if (char === "\"") {
97
+ inString = true;
98
+ out += char;
99
+ continue;
100
+ }
101
+ if (char === "/" && next === "/") {
102
+ inLineComment = true;
103
+ out += " ";
104
+ index++;
105
+ continue;
106
+ }
107
+ if (char === "/" && next === "*") {
108
+ inBlockComment = true;
109
+ out += " ";
110
+ index++;
111
+ continue;
112
+ }
113
+ out += char;
114
+ }
115
+ return out;
116
+ }
117
+ /** Reads a JSON file, returning undefined when it does not exist. */
118
+ async function readJsonFile(path) {
119
+ let text;
120
+ try {
121
+ text = await readFile(path, "utf8");
122
+ }
123
+ catch (error) {
124
+ if (error.code === "ENOENT")
125
+ return undefined;
126
+ throw error;
127
+ }
128
+ if (!text.trim())
129
+ return undefined;
130
+ try {
131
+ return JSON.parse(stripJsonComments(text));
132
+ }
133
+ catch (error) {
134
+ // A file Super Pi cannot parse is a file Super Pi must NOT overwrite: the person who wrote it
135
+ // put something in there this server does not understand, and replacing it with a parse of
136
+ // nothing would destroy their work.
137
+ throw new Error(`${path} 解析失败,为避免覆盖你的文件已停止写入:${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ }
140
+ /** The permissions a file already has, or 0600 when it is new. */
141
+ async function fileMode(path) {
142
+ try {
143
+ const info = await stat(path);
144
+ return info.mode & 0o777;
145
+ }
146
+ catch {
147
+ return 0o600;
148
+ }
149
+ }
150
+ /**
151
+ * Writes a file atomically: a temporary file next to it, then a rename over the target.
152
+ *
153
+ * A rename is atomic on every platform Super Pi runs on, so a reader - Pi, or a person with an
154
+ * editor open - sees either the old file or the new one, never half of each. Writing in place would
155
+ * leave a window in which Pi reads a truncated `models.json` and loses every provider in it.
156
+ */
157
+ async function writeJsonAtomic(path, value) {
158
+ await mkdir(dirname(path), { recursive: true });
159
+ const mode = await fileMode(path);
160
+ const temporary = `${path}.${randomUUID()}.tmp`;
161
+ try {
162
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
163
+ await rename(temporary, path);
164
+ }
165
+ catch (error) {
166
+ await writeFile(temporary, "{}", { mode }).catch(() => { });
167
+ throw error;
168
+ }
169
+ }
170
+ /**
171
+ * The provider entry Super Pi writes into Pi's `models.json`.
172
+ *
173
+ * NO `apiKey` HERE, and that is a decision with a reason behind it: a value in `models.json` goes
174
+ * through Pi's config-value syntax, where `$NAME` means "read this environment variable" and
175
+ * `!command` means "run this and take its output". A key that happens to start with a dollar sign
176
+ * would be silently turned into something else. `auth.json` stores the key verbatim, and it is
177
+ * where Pi's own `/login` puts it, so that is where this goes.
178
+ */
179
+ function toPiProvider(provider) {
180
+ const models = provider.models.map(model => ({
181
+ id: model.id,
182
+ name: model.name ?? model.id,
183
+ reasoning: model.reasoning ?? false,
184
+ input: model.imageInput ? ["text", "image"] : ["text"],
185
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
186
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
187
+ ...(model.supportsDeveloperRole === undefined && model.supportsReasoningEffort === undefined
188
+ ? {}
189
+ : {
190
+ compat: {
191
+ ...(model.supportsDeveloperRole === undefined ? {} : { supportsDeveloperRole: model.supportsDeveloperRole }),
192
+ ...(model.supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort: model.supportsReasoningEffort }),
193
+ },
194
+ }),
195
+ }));
196
+ return {
197
+ ...(provider.name ? { name: provider.name } : {}),
198
+ baseUrl: provider.baseUrl,
199
+ api: provider.api,
200
+ models,
201
+ };
202
+ }
203
+ /**
204
+ * The providers Super Pi manages, and the writes that make Pi know about them.
205
+ *
206
+ * The class owns three files: its own registry, Pi's `models.json` and Pi's `auth.json`. Only the
207
+ * first is a truth source; the other two are rewritten from it.
208
+ */
209
+ export class ProviderRegistry {
210
+ dataDir;
211
+ piDir;
212
+ /**
213
+ * `piDir` is Pi's own configuration directory, and it is a PARAMETER rather than a lookup at
214
+ * write time for one reason: a test that forgets to set `PI_CODING_AGENT_DIR` would then write to
215
+ * the developer's real `~/.pi/agent`. With the directory in hand, a test cannot reach it by
216
+ * accident - it has to be given one.
217
+ */
218
+ constructor(dataDir, piDir = piAgentDir()) {
219
+ this.dataDir = dataDir;
220
+ this.piDir = piDir;
221
+ }
222
+ /** Super Pi's own registry: the providers it manages, with their keys. */
223
+ get registryPath() {
224
+ return join(this.dataDir, "providers.json");
225
+ }
226
+ /**
227
+ * Reads the registry.
228
+ *
229
+ * A missing file is an empty registry, not an error: nobody has added a provider yet. A file that
230
+ * cannot be parsed IS an error, because reading it as empty would let the next write replace
231
+ * somebody's keys with nothing.
232
+ */
233
+ async load() {
234
+ const raw = await readJsonFile(this.registryPath);
235
+ if (!raw || typeof raw !== "object")
236
+ return [];
237
+ const providers = raw.providers;
238
+ if (!Array.isArray(providers))
239
+ return [];
240
+ return providers.filter((provider) => Boolean(provider) && typeof provider === "object");
241
+ }
242
+ /** Writes the registry. The key lives here, so the file is 0600. */
243
+ async save(providers) {
244
+ await writeJsonAtomic(this.registryPath, { providers });
245
+ }
246
+ /**
247
+ * Makes Pi's files match the registry.
248
+ *
249
+ * `previousIds` are the providers Super Pi managed before this change: they are the only ones
250
+ * whose entries may be REMOVED. An id that is in Pi's `models.json` and not in `previousIds` belongs
251
+ * to the person who wrote that file, and touching it would be Super Pi editing a provider it does
252
+ * not own - so the write is refused with a message naming the id.
253
+ */
254
+ async applyToPi(previous, next) {
255
+ const previousIds = new Set(previous.map((provider) => provider.id));
256
+ const nextIds = new Set(next.map((provider) => provider.id));
257
+ const modelsPath = piModelsPath(this.piDir);
258
+ const existing = await readJsonFile(modelsPath) ?? {};
259
+ const existingProviders = existing.providers ?? {};
260
+ for (const provider of next) {
261
+ if (existingProviders[provider.id] !== undefined && !previousIds.has(provider.id)) {
262
+ throw new Error(`Pi 的 models.json 里已经有供应商「${provider.id}」,而它不是 Super Pi 添加的,所以没有动它。请换一个供应商 id,或者先在 Pi 的模型配置文件里删掉它。`);
263
+ }
264
+ }
265
+ const mergedProviders = { ...existingProviders };
266
+ for (const provider of next)
267
+ mergedProviders[provider.id] = toPiProvider(provider);
268
+ // The ones we managed and no longer do.
269
+ for (const id of previousIds)
270
+ if (!nextIds.has(id))
271
+ delete mergedProviders[id];
272
+ await writeJsonAtomic(modelsPath, { ...existing, providers: mergedProviders });
273
+ await this.applyKeysToPi(previous, next);
274
+ }
275
+ /**
276
+ * Writes the keys into Pi's `auth.json`.
277
+ *
278
+ * A credential is REMOVED only when it is the one Super Pi wrote: the same provider id and the
279
+ * same key. A person who replaced our key with their own - or who ran Pi's `/login` and put an
280
+ * OAuth token there - keeps it. Deleting somebody else's credential because they removed our
281
+ * provider would be the worst version of this feature.
282
+ *
283
+ * TWO CASES, and the second is the one that is easy to miss: the provider can be gone ENTIRELY
284
+ * (it was removed), or it can still be there with its key GONE (the person asked for the key to be
285
+ * cleared). Both have to take the credential with them, or "clear the key" would leave a working
286
+ * key sitting in Pi's auth file.
287
+ */
288
+ async applyKeysToPi(previous, next) {
289
+ const authPath = piAuthPath(this.piDir);
290
+ const auth = await readJsonFile(authPath) ?? {};
291
+ const nextById = new Map(next.map((provider) => [provider.id, provider]));
292
+ for (const provider of next) {
293
+ if (provider.apiKey)
294
+ auth[provider.id] = { type: "api_key", key: provider.apiKey };
295
+ }
296
+ for (const provider of previous) {
297
+ const updated = nextById.get(provider.id);
298
+ // The key is still wanted (the provider is unchanged, or it has a new one) - nothing to do.
299
+ if (updated && updated.apiKey)
300
+ continue;
301
+ if (!updated && !provider.apiKey)
302
+ continue;
303
+ const stored = auth[provider.id];
304
+ if (stored?.type === "api_key" && provider.apiKey && stored.key === provider.apiKey)
305
+ delete auth[provider.id];
306
+ }
307
+ await writeJsonAtomic(authPath, auth);
308
+ }
309
+ }
310
+ /** The provider list as the browser sees it: the key becomes a fact. */
311
+ export function publicProviders(providers) {
312
+ return providers.map(provider => ({
313
+ id: provider.id,
314
+ name: provider.name ?? provider.id,
315
+ baseUrl: provider.baseUrl,
316
+ api: provider.api,
317
+ hasKey: Boolean(provider.apiKey),
318
+ models: provider.models.map(model => ({
319
+ id: model.id,
320
+ name: model.name ?? model.id,
321
+ reasoning: model.reasoning ?? false,
322
+ imageInput: model.imageInput ?? false,
323
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
324
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
325
+ })),
326
+ }));
327
+ }
328
+ /**
329
+ * Checks what the form collected and returns it in the shape the registry stores.
330
+ *
331
+ * EVERY RULE HERE SAYS WHY, because a message a person reads is the only validation they get: the
332
+ * server cannot ask a follow-up question, so a refusal has to name the field and the reason.
333
+ */
334
+ export function validateProvider(input) {
335
+ const raw = (input ?? {});
336
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
337
+ if (!id)
338
+ throw new Error("供应商 id 不能为空");
339
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(id))
340
+ throw new Error("供应商 id 只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头");
341
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
342
+ const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
343
+ if (!baseUrl)
344
+ throw new Error("API 地址不能为空");
345
+ let parsed;
346
+ try {
347
+ parsed = new URL(baseUrl);
348
+ }
349
+ catch {
350
+ throw new Error(`API 地址不是合法的 URL:${baseUrl}`);
351
+ }
352
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
353
+ throw new Error("API 地址必须是 http:// 或 https://");
354
+ const api = typeof raw.api === "string" ? raw.api.trim() : "";
355
+ if (!api)
356
+ throw new Error("API 类型不能为空");
357
+ if (!KNOWN_APIS.includes(api))
358
+ throw new Error(`API 类型必须是下面之一:${KNOWN_APIS.join("、")}`);
359
+ const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
360
+ const models = Array.isArray(raw.models) ? raw.models : [];
361
+ if (!models.length)
362
+ throw new Error("至少要添加一个模型");
363
+ const checked = models.map((entry) => {
364
+ const model = (entry ?? {});
365
+ const modelId = typeof model.id === "string" ? model.id.trim() : "";
366
+ if (!modelId)
367
+ throw new Error("模型 id 不能为空");
368
+ if (modelId.length > 200)
369
+ throw new Error("模型 id 太长了");
370
+ const modelName = typeof model.name === "string" ? model.name.trim() : "";
371
+ const contextWindow = typeof model.contextWindow === "number" ? Math.round(model.contextWindow) : undefined;
372
+ if (contextWindow !== undefined && (!Number.isFinite(contextWindow) || contextWindow <= 0))
373
+ throw new Error("上下文窗口必须是一个正数");
374
+ const maxTokens = typeof model.maxTokens === "number" ? Math.round(model.maxTokens) : undefined;
375
+ if (maxTokens !== undefined && (!Number.isFinite(maxTokens) || maxTokens <= 0))
376
+ throw new Error("最大输出 token 必须是一个正数");
377
+ return {
378
+ id: modelId,
379
+ ...(modelName ? { name: modelName } : {}),
380
+ ...(model.reasoning === true ? { reasoning: true } : {}),
381
+ ...(model.imageInput === true ? { imageInput: true } : {}),
382
+ ...(contextWindow === undefined ? {} : { contextWindow }),
383
+ ...(maxTokens === undefined ? {} : { maxTokens }),
384
+ ...(model.supportsDeveloperRole === false ? { supportsDeveloperRole: false } : {}),
385
+ ...(model.supportsReasoningEffort === false ? { supportsReasoningEffort: false } : {}),
386
+ };
387
+ });
388
+ return {
389
+ id,
390
+ ...(name ? { name } : {}),
391
+ baseUrl,
392
+ api,
393
+ ...(apiKey ? { apiKey } : {}),
394
+ models: checked,
395
+ };
396
+ }
@@ -115,6 +115,28 @@ export interface AnswerInput {
115
115
  * on a card but by its working directory on a call block would be worse than having neither.
116
116
  */
117
117
  onToolCall?(call: ToolCallNotice): void;
118
+ /**
119
+ * Called when a tool that WRITES A FILE is announced, with the path it is about to write.
120
+ *
121
+ * WHY A SEPARATE CALLBACK RATHER THAN A FIELD ON `onToolCall`. Two reasons, and the second is the
122
+ * load-bearing one.
123
+ *
124
+ * The first: this is a fact about the FILESYSTEM, not about the transcript. The transcript's row for
125
+ * an `edit` is a card; this is the server learning that a file on disk is about to change, and the
126
+ * listener for it has to act BEFORE the change - which is the only moment the old content is still on
127
+ * the disk. A card that arrives after the fact is too late to be a baseline.
128
+ *
129
+ * The second: the listener's job is to capture the file's previous content, which means reading the
130
+ * disk, and that must not happen on the path that draws a card. `onToolCall` is called synchronously
131
+ * in the drain loop, between yields, and a disk read there would put an I/O stall in the middle of
132
+ * the stream - the latency the streaming lane exists to avoid. So this callback is fired and FORGOTTEN:
133
+ * the listener does its work on its own time and the drain loop moves on.
134
+ *
135
+ * The path is the tool's own spelling, resolved against the workspace by the listener - the runtime
136
+ * does not know which workspace a call belongs to, and a path it cannot check is a path it does not
137
+ * vouch for.
138
+ */
139
+ onFileWrite?(path: string, toolName: string, toolCallId?: string): void;
118
140
  /**
119
141
  * Called on `tool_execution_end` for the same `id`, carrying how the call ended.
120
142
  *
@@ -328,6 +350,20 @@ export interface AgentRuntime {
328
350
  * selected model silently clamps away.
329
351
  */
330
352
  catalog(selection?: ModelSelection): Promise<ModelCatalog>;
353
+ /**
354
+ * Throws away every cached answer about the models Pi offers.
355
+ *
356
+ * Optional, because a runtime with no Pi behind it has no cache to throw away - and because the
357
+ * only thing that changes the model list from outside is a person adding a provider, which is a
358
+ * thing a runtime with no Pi cannot have.
359
+ */
360
+ invalidateCatalog?(): void;
361
+ /**
362
+ * Makes the live sessions stale, so the next turn builds one that knows the current models.
363
+ *
364
+ * Optional for the same reason: only a runtime with sessions behind it has any.
365
+ */
366
+ bumpModelEpoch?(): void;
331
367
  /**
332
368
  * Reverts the conversation to the state right after `boundaryEntryId`, in Pi's own transcript.
333
369
  *
@@ -580,6 +616,17 @@ export declare class PiRpcRuntime implements AgentRuntime {
580
616
  * otherwise be re-imported on every picker open.
581
617
  */
582
618
  private sdkCatalogPromise?;
619
+ /**
620
+ * Bumped whenever the set of models Pi knows about changes.
621
+ *
622
+ * A live session holds its own `ModelRuntime`, built when the session was built, and that runtime
623
+ * does not know about a provider added afterwards. Pi's refusal to switch to a model its runtime
624
+ * does not have is swallowed by the session pool into a warning, so the turn would go on running
625
+ * the OLD model while the picker said the new one - a control that lies. The sessions are made
626
+ * STALE by this counter instead: each one is rebuilt at its next turn, which is a moment when
627
+ * throwing a session away costs nothing, and the new one is built with a runtime that knows.
628
+ */
629
+ private modelEpoch;
583
630
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
584
631
  private warnedMissingExtension;
585
632
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
@@ -1031,6 +1078,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
1031
1078
  followUp: string[];
1032
1079
  }>;
1033
1080
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
1081
+ /**
1082
+ * Throws away every cached answer about the models Pi offers.
1083
+ *
1084
+ * Called when the model list itself changes - which today means a person added a provider
1085
+ * through the settings window. The caches are three deep and all three have to go: the TTL map
1086
+ * (ten minutes), the in-flight map (a read that is already running would answer with the old
1087
+ * list), and the SDK catalog, which is remembered for the life of the process because it holds
1088
+ * objects that read the auth store and the model store from disk - and those files are exactly
1089
+ * what just changed.
1090
+ */
1091
+ invalidateCatalog(): void;
1092
+ /**
1093
+ * Makes every live session stale, so the next turn builds one that knows the current models.
1094
+ *
1095
+ * WHY A COUNTER AND NOT A REBUILD. Rebuilding a session means throwing it away and reading its
1096
+ * transcript back, and a turn may be RUNNING right now. The counter is read by the session pool
1097
+ * as part of the fingerprint, so each session notices at its next acquire - between turns, where
1098
+ * a rebuild is free.
1099
+ */
1100
+ bumpModelEpoch(): void;
1034
1101
  /**
1035
1102
  * The model/thinking catalog, from Pi, cached.
1036
1103
  *