@evandu/super-pi 0.1.123 → 0.1.125

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 (46) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +7 -7
  3. package/dist/client/assets/{CodeMirrorEditor-B2AXs2YB.js → CodeMirrorEditor-CK5tZxIo.js} +2 -2
  4. package/dist/client/assets/{dist-CRpnXCSn.js → dist-5O-klFrP.js} +1 -1
  5. package/dist/client/assets/{dist-D11ifrNu.js → dist-B8X48E9G.js} +1 -1
  6. package/dist/client/assets/{dist-Wq0CGa1P.js → dist-BC3_yB3K.js} +1 -1
  7. package/dist/client/assets/{dist-CrYrGCRr.js → dist-BDAJ6COK.js} +1 -1
  8. package/dist/client/assets/{dist-DOQGcum7.js → dist-BDULvJIt.js} +1 -1
  9. package/dist/client/assets/{dist-DOuFuHYr.js → dist-BR5dccfj.js} +1 -1
  10. package/dist/client/assets/{dist-CvUxw4HU.js → dist-BbK-QOz1.js} +1 -1
  11. package/dist/client/assets/{dist-CoqrRbfm.js → dist-Ck1wVFJ1.js} +1 -1
  12. package/dist/client/assets/{dist-B0G3TOfE.js → dist-CrnIVt5K.js} +1 -1
  13. package/dist/client/assets/{dist-DC4KdKrw.js → dist-D87R6Wol.js} +1 -1
  14. package/dist/client/assets/{dist-D899ixlt.js → dist-D90mwiSm.js} +1 -1
  15. package/dist/client/assets/{dist-4hqZVn6B.js → dist-D9IZ5pHN.js} +1 -1
  16. package/dist/client/assets/{dist-Ip0mRDF1.js → dist-DIa1uLNH.js} +1 -1
  17. package/dist/client/assets/{dist-CWcEOVNV.js → dist-DR8q7BI-.js} +1 -1
  18. package/dist/client/assets/{dist-GQ99vtKx.js → dist-Did2mkbm.js} +1 -1
  19. package/dist/client/assets/{dist-CjyJqaHj.js → dist-DsObObmI.js} +1 -1
  20. package/dist/client/assets/{dist-BqlvAjyT.js → dist-DsY1TPps.js} +1 -1
  21. package/dist/client/assets/{dist--hp7cqEH.js → dist-MYmiV3U3.js} +1 -1
  22. package/dist/client/assets/{dist-Dp9eqvWE.js → dist-Om17wCc8.js} +1 -1
  23. package/dist/client/assets/{dist-Cxo9Qf8Y.js → dist-fEUHAgwX.js} +1 -1
  24. package/dist/client/assets/{dist-CcAEnfzB.js → dist-nSf0tcKa.js} +1 -1
  25. package/dist/client/assets/{dist-CAFUK38E.js → dist-r_bnlg1k.js} +1 -1
  26. package/dist/client/assets/{index-CCnIvjP6.css → index-CBPWeTvl.css} +1 -1
  27. package/dist/client/assets/index-qTPZ6eno.js +99 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/application/provider-service.d.ts +51 -2
  30. package/dist/server/application/provider-service.js +166 -11
  31. package/dist/server/application/run-service.js +14 -7
  32. package/dist/server/command-handler.js +21 -0
  33. package/dist/server/http-server.js +8 -0
  34. package/dist/server/index.js +2 -0
  35. package/dist/server/pi-session-events.d.ts +6 -1
  36. package/dist/server/providers.d.ts +106 -0
  37. package/dist/server/providers.js +247 -35
  38. package/dist/server/runtime.d.ts +1 -1
  39. package/dist/server/runtime.js +42 -20
  40. package/dist/server/store.d.ts +15 -0
  41. package/dist/server/store.js +34 -0
  42. package/dist/server/terminal.d.ts +51 -0
  43. package/dist/server/terminal.js +81 -12
  44. package/dist/server/types.d.ts +43 -0
  45. package/package.json +3 -1
  46. package/dist/client/assets/index-B0TMoUBp.js +0 -99
@@ -168,35 +168,54 @@ async function writeJsonAtomic(path, value) {
168
168
  }
169
169
  }
170
170
  /**
171
- * The provider entry Super Pi writes into Pi's `models.json`.
171
+ * The provider entry Super Pi writes into Pi's `models.json`, MERGED into what was already there.
172
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.
173
+ * NO `apiKey` IS WRITTEN HERE, and that is a decision with a reason behind it: a value in
174
+ * `models.json` goes through Pi's config-value syntax, where `$NAME` means "read this environment
175
+ * variable" and `!command` means "run this and take its output". A key that happens to start with a
176
+ * dollar sign would be silently turned into something else. `auth.json` stores the key verbatim, and
177
+ * it is where Pi's own `/login` puts it, so that is where this goes. A key the PERSON wrote there is
178
+ * left alone, because this function never touches fields it does not own.
179
+ *
180
+ * WHY IT MERGES RATHER THAN REPLACES. Pi's provider schema holds more than this form collects -
181
+ * `cost`, `headers`, `samplingParams`, `thinkingLevelMap`, `modelOverrides`, `oauth`, `authHeader`,
182
+ * an inline `apiKey` - and a settings panel that calls itself a view of Pi's configuration cannot
183
+ * delete the parts of it it has no field for. An earlier version replaced the whole entry, so
184
+ * editing ONE provider silently rewrote every OTHER entry in the file in this app's shape.
178
185
  */
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
- }));
186
+ function toPiProvider(provider, existing) {
187
+ const base = existing && typeof existing === "object" ? { ...existing } : {};
188
+ const previousModels = Array.isArray(base.models) ? base.models : [];
189
+ const models = provider.models.map(model => {
190
+ const prior = previousModels.find((entry) => entry?.id === model.id);
191
+ const priorModel = prior && typeof prior === "object" ? prior : {};
192
+ const priorCompat = priorModel.compat && typeof priorModel.compat === "object"
193
+ ? priorModel.compat
194
+ : {};
195
+ const compat = {
196
+ ...priorCompat,
197
+ ...(model.supportsDeveloperRole === undefined ? {} : { supportsDeveloperRole: model.supportsDeveloperRole }),
198
+ ...(model.supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort: model.supportsReasoningEffort }),
199
+ };
200
+ return {
201
+ ...priorModel,
202
+ id: model.id,
203
+ name: model.name ?? model.id,
204
+ reasoning: model.reasoning ?? false,
205
+ input: model.imageInput ? ["text", "image"] : ["text"],
206
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
207
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
208
+ ...(Object.keys(compat).length ? { compat } : { compat: undefined }),
209
+ };
210
+ });
196
211
  return {
212
+ ...base,
197
213
  ...(provider.name ? { name: provider.name } : {}),
198
- baseUrl: provider.baseUrl,
199
- api: provider.api,
214
+ // AN EMPTY ADDRESS IS OMITTED, not written as "". Pi's schema refuses a baseUrl shorter than one
215
+ // character, so writing "" for an entry that had none - a built-in override, for instance -
216
+ // would make Pi reject the whole file at its next start.
217
+ ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
218
+ ...(provider.api ? { api: provider.api } : {}),
200
219
  models,
201
220
  };
202
221
  }
@@ -223,25 +242,53 @@ export class ProviderRegistry {
223
242
  get registryPath() {
224
243
  return join(this.dataDir, "providers.json");
225
244
  }
245
+ /** The registry file as one object: the managed providers AND the ones turned off. */
246
+ async readRegistry() {
247
+ const raw = await readJsonFile(this.registryPath);
248
+ if (!raw || typeof raw !== "object")
249
+ return { providers: [], disabled: [] };
250
+ const record = raw;
251
+ const providerList = Array.isArray(record.providers)
252
+ ? record.providers.filter((p) => Boolean(p) && typeof p === "object")
253
+ : [];
254
+ const disabledList = Array.isArray(record.disabled)
255
+ ? record.disabled.filter((p) => Boolean(p) && typeof p === "object" && typeof p.id === "string")
256
+ : [];
257
+ return { providers: providerList, disabled: disabledList };
258
+ }
259
+ /** Writes one half of the registry file without losing the other. */
260
+ async writeRegistry(value) {
261
+ const current = await this.readRegistry();
262
+ await writeJsonAtomic(this.registryPath, {
263
+ providers: value.providers ?? current.providers,
264
+ disabled: value.disabled ?? current.disabled,
265
+ });
266
+ }
226
267
  /**
227
268
  * Reads all configured providers directly from Pi's models.json and auth.json.
228
269
  * Super Pi acts as the direct visual management plane for Pi's model configurations.
270
+ *
271
+ * PROVIDERS THAT ARE TURNED OFF ARE NOT IN HERE. They have been taken out of Pi's files - that is
272
+ * what "off" means - so this list is exactly what Pi would offer, which is what the write path
273
+ * (`set`, `remove`, `applyToPi`) has to reason about. `loadDisabled()` is the other half, for the
274
+ * panel that has to be able to switch them back on.
229
275
  */
230
276
  async load() {
231
277
  const modelsPath = piModelsPath(this.piDir);
232
278
  const authPath = piAuthPath(this.piDir);
233
279
  const piModels = (await readJsonFile(modelsPath))?.providers ?? {};
234
280
  const piAuth = (await readJsonFile(authPath)) ?? {};
235
- // Backup registry from Super Pi data dir if needed
236
- const rawBackup = await readJsonFile(this.registryPath);
237
- const backupList = rawBackup && typeof rawBackup === "object" && Array.isArray(rawBackup.providers)
238
- ? rawBackup.providers.filter((p) => Boolean(p) && typeof p === "object")
239
- : [];
281
+ const registry = await this.readRegistry();
282
+ const backupList = registry.providers;
240
283
  const backupMap = new Map(backupList.map((p) => [p.id, p]));
284
+ // A provider that is OFF must not come back through the backup: the backup is where our keys
285
+ // live, and a key for a provider the person switched off would silently switch it back on at
286
+ // the next `load()` - before any UI could show the switch.
287
+ const offIds = new Set(registry.disabled.map((entry) => entry.id));
241
288
  const providers = [];
242
289
  const seen = new Set();
243
290
  for (const [pId, pData] of Object.entries(piModels)) {
244
- if (!pData || typeof pData !== "object")
291
+ if (!pData || typeof pData !== "object" || offIds.has(pId))
245
292
  continue;
246
293
  seen.add(pId);
247
294
  const rec = pData;
@@ -257,6 +304,10 @@ export class ProviderRegistry {
257
304
  imageInput: Array.isArray(m?.input) ? m.input.includes("image") : false,
258
305
  contextWindow: typeof m?.contextWindow === "number" ? m.contextWindow : undefined,
259
306
  maxTokens: typeof m?.maxTokens === "number" ? m.maxTokens : undefined,
307
+ // THE TWO COMPATIBILITY SWITCHES ARE READ BACK, or the form would show its own defaults for
308
+ // a setting the file explicitly turned off, and saving the form would silently turn it on.
309
+ ...(typeof m?.compat?.supportsDeveloperRole === "boolean" ? { supportsDeveloperRole: m.compat.supportsDeveloperRole } : {}),
310
+ ...(typeof m?.compat?.supportsReasoningEffort === "boolean" ? { supportsReasoningEffort: m.compat.supportsReasoningEffort } : {}),
260
311
  }));
261
312
  const apiKey = backup?.apiKey;
262
313
  providers.push({
@@ -265,20 +316,178 @@ export class ProviderRegistry {
265
316
  baseUrl,
266
317
  api,
267
318
  ...(apiKey ? { apiKey } : {}),
268
- hasAuthKey: Boolean(piAuth[pId]?.key || backup?.apiKey),
319
+ // ANY credential counts, not only a `key`. An OAuth provider keeps `access`/`refresh` in
320
+ // this file, and reporting "no key" for a provider Pi is happily using is a lie the panel
321
+ // would then show next to a working model.
322
+ hasAuthKey: Boolean(piAuth[pId] || backup?.apiKey),
323
+ // A KEY WRITTEN IN models.json IS A CREDENTIAL TOO, and it is the one Pi's own docs lead
324
+ // with (`"apiKey": "$MY_KEY"`). Without this, such a provider is reported as having no
325
+ // credential - and the panel then says "Pi will not offer it", which is wrong exactly when
326
+ // the environment variable is set and Pi is offering it right now.
327
+ ...(typeof rec.apiKey === "string" && rec.apiKey ? { keyInModelsJson: true } : {}),
269
328
  models,
270
329
  });
271
330
  }
272
331
  for (const b of backupList) {
273
- if (!seen.has(b.id)) {
332
+ if (!seen.has(b.id) && !offIds.has(b.id)) {
274
333
  providers.push(b);
275
334
  }
276
335
  }
277
336
  return providers;
278
337
  }
338
+ /**
339
+ * Pi's OWN providers: the ones that exist because they have a credential in `auth.json` and no
340
+ * entry in `models.json`.
341
+ *
342
+ * WHY THIS IS A SEPARATE READ. A built-in such as `kimi-coding` is defined inside Pi, not in a
343
+ * file - the only thing a person has configured for it is the credential. Those providers are
344
+ * invisible to `load()`, which reads `models.json`, and that is correct for the WRITE path:
345
+ * writing an empty `models.json` entry for a built-in would shadow Pi's own definition with a
346
+ * broken one. But they are exactly what a person wants to switch off ("I stopped paying for this
347
+ * plan"), so the panel has to see them. This returns the ids and the fact of a credential; the
348
+ * models come from Pi's catalogue, which this file layer cannot see.
349
+ */
350
+ async loadCredentialOnly() {
351
+ const modelsPath = piModelsPath(this.piDir);
352
+ const authPath = piAuthPath(this.piDir);
353
+ const piModels = (await readJsonFile(modelsPath))?.providers ?? {};
354
+ const piAuth = (await readJsonFile(authPath)) ?? {};
355
+ const registry = await this.readRegistry();
356
+ const offIds = new Set(registry.disabled.map((entry) => entry.id));
357
+ return Object.keys(piAuth)
358
+ .filter((id) => !piModels[id] && !offIds.has(id))
359
+ .map((id) => ({ id, name: id, baseUrl: "", api: "", hasAuthKey: true, builtin: true, models: [] }));
360
+ }
361
+ /** The providers that are switched off, as the panel needs to see them. */
362
+ async loadDisabled() {
363
+ return (await this.readRegistry()).disabled;
364
+ }
365
+ /**
366
+ * Takes a provider out of Pi's configuration and remembers exactly what was taken out.
367
+ *
368
+ * TWO FILES, and both are needed. `models.json` is where a custom provider's models are defined;
369
+ * `auth.json` is where its credential is. Pi offers a provider's models only when its auth is
370
+ * configured (`ModelRuntime.getAvailable()` filters on exactly that), so removing the credential
371
+ * is what makes a BUILT-IN stop being offered - it has no `models.json` entry to remove - while
372
+ * removing both is what makes a custom one disappear completely.
373
+ *
374
+ * NOTHING IS THROWN AWAY. Both pieces are written into Super Pi's own registry, which is how
375
+ * `enable` can put them back byte for byte. A person switching off a lapsed subscription provider
376
+ * is not asking to forget their endpoint and their models.
377
+ */
378
+ async disable(providerId, models = []) {
379
+ const modelsPath = piModelsPath(this.piDir);
380
+ const authPath = piAuthPath(this.piDir);
381
+ const piModels = (await readJsonFile(modelsPath));
382
+ const piAuth = (await readJsonFile(authPath)) ?? {};
383
+ const currentProviders = { ...(piModels?.providers ?? {}) };
384
+ const modelsEntry = currentProviders[providerId];
385
+ const credential = piAuth[providerId];
386
+ if (!modelsEntry && !credential) {
387
+ throw new Error(`Pi 的配置里没有供应商「${providerId}」,无法停用`);
388
+ }
389
+ // Write Pi's files FIRST, and only then remember the switch. A registry that says "off" while
390
+ // Pi still offers the provider is the one state this must never leave behind: the panel would
391
+ // report a provider as gone while it is still in the picker.
392
+ if (modelsEntry) {
393
+ delete currentProviders[providerId];
394
+ await writeJsonAtomic(modelsPath, { ...(piModels ?? {}), providers: currentProviders });
395
+ }
396
+ if (credential) {
397
+ const nextAuth = { ...piAuth };
398
+ delete nextAuth[providerId];
399
+ await writeJsonAtomic(authPath, nextAuth);
400
+ }
401
+ const registry = await this.readRegistry();
402
+ const record = {
403
+ id: providerId,
404
+ ...(typeof modelsEntry === "object" && modelsEntry && typeof modelsEntry.name === "string"
405
+ ? { name: modelsEntry.name }
406
+ : {}),
407
+ ...(modelsEntry ? { modelsEntry: modelsEntry } : {}),
408
+ ...(credential ? { credential: credential } : {}),
409
+ models,
410
+ };
411
+ try {
412
+ await this.writeRegistry({
413
+ providers: registry.providers.filter((provider) => provider.id !== providerId),
414
+ disabled: [...registry.disabled.filter((entry) => entry.id !== providerId), record],
415
+ });
416
+ }
417
+ catch (error) {
418
+ // The record is what makes the switch reversible, so a failure to write it means the provider
419
+ // has been taken out of Pi's files with nothing left to restore it from. Pi's files are put
420
+ // back before the error is reported - the same rule `set()` follows, for the same reason.
421
+ if (modelsEntry) {
422
+ await writeJsonAtomic(modelsPath, { ...(piModels ?? {}), providers: { ...currentProviders, [providerId]: modelsEntry } }).catch(() => undefined);
423
+ }
424
+ if (credential) {
425
+ await writeJsonAtomic(authPath, { ...piAuth, [providerId]: credential }).catch(() => undefined);
426
+ }
427
+ throw error;
428
+ }
429
+ }
430
+ /**
431
+ * Puts a provider back exactly where `disable` took it from.
432
+ *
433
+ * AN EXISTING ENTRY IS NOT OVERWRITTEN. The person may have re-created the provider (or written a
434
+ * new `models.json` entry for the same id) while it was off; the newer entry is theirs and wins,
435
+ * and only the missing piece is restored. Refusing would be worse: the switch says "back on", and
436
+ * the credential is what makes it usable.
437
+ */
438
+ async enable(providerId) {
439
+ const registry = await this.readRegistry();
440
+ const record = registry.disabled.find((entry) => entry.id === providerId);
441
+ if (!record)
442
+ throw new Error(`供应商「${providerId}」没有被停用`);
443
+ if (record.modelsEntry) {
444
+ const modelsPath = piModelsPath(this.piDir);
445
+ const piModels = (await readJsonFile(modelsPath)) ?? {};
446
+ const providers = { ...(piModels.providers ?? {}) };
447
+ if (!providers[providerId]) {
448
+ providers[providerId] = record.modelsEntry;
449
+ await writeJsonAtomic(modelsPath, { ...piModels, providers });
450
+ }
451
+ }
452
+ if (record.credential) {
453
+ const authPath = piAuthPath(this.piDir);
454
+ const auth = (await readJsonFile(authPath)) ?? {};
455
+ if (!auth[providerId]) {
456
+ await writeJsonAtomic(authPath, { ...auth, [providerId]: record.credential });
457
+ }
458
+ }
459
+ await this.writeRegistry({
460
+ disabled: registry.disabled.filter((entry) => entry.id !== providerId),
461
+ });
462
+ }
463
+ /**
464
+ * Deletes a BUILT-IN provider's credential from Pi's `auth.json`.
465
+ *
466
+ * THE ONLY THING THERE IS TO DELETE. A built-in's models live in Pi itself; what a person has
467
+ * configured for it is the credential, and removing that is what makes Pi stop offering it. This
468
+ * is the PERMANENT version: unlike `disable`, nothing is remembered, so switching it back on means
469
+ * logging in to that provider again (which is what re-subscribing to the plan involves anyway).
470
+ */
471
+ async removeCredential(providerId) {
472
+ const authPath = piAuthPath(this.piDir);
473
+ const auth = (await readJsonFile(authPath));
474
+ if (!auth || !auth[providerId])
475
+ return;
476
+ const nextAuth = { ...auth };
477
+ delete nextAuth[providerId];
478
+ await writeJsonAtomic(authPath, nextAuth);
479
+ }
480
+ /** Forgets a disabled provider without restoring anything: the permanent form of "off". */
481
+ async forgetDisabled(providerId) {
482
+ const registry = await this.readRegistry();
483
+ const next = registry.disabled.filter((entry) => entry.id !== providerId);
484
+ if (next.length === registry.disabled.length)
485
+ throw new Error(`供应商「${providerId}」没有被停用`);
486
+ await this.writeRegistry({ disabled: next });
487
+ }
279
488
  /** Writes the registry. The key lives here, so the file is 0600. */
280
489
  async save(providers) {
281
- await writeJsonAtomic(this.registryPath, { providers });
490
+ await this.writeRegistry({ providers });
282
491
  }
283
492
  /**
284
493
  * Directly updates Pi's models.json and auth.json to match the new set of providers.
@@ -292,7 +501,7 @@ export class ProviderRegistry {
292
501
  const existingProviders = existing.providers ?? {};
293
502
  const mergedProviders = { ...existingProviders };
294
503
  for (const provider of next)
295
- mergedProviders[provider.id] = toPiProvider(provider);
504
+ mergedProviders[provider.id] = toPiProvider(provider, existingProviders[provider.id]);
296
505
  for (const id of previousIds)
297
506
  if (!nextIds.has(id))
298
507
  delete mergedProviders[id];
@@ -331,6 +540,9 @@ export function publicProviders(providers) {
331
540
  baseUrl: provider.baseUrl,
332
541
  api: provider.api,
333
542
  hasKey: Boolean(provider.apiKey || provider.hasAuthKey),
543
+ ...(provider.keyInModelsJson ? { keyInModelsJson: true } : {}),
544
+ ...(provider.builtin ? { builtin: true } : {}),
545
+ ...(provider.disabled ? { disabled: true } : {}),
334
546
  models: provider.models.map(model => ({
335
547
  id: model.id,
336
548
  name: model.name ?? model.id,
@@ -77,7 +77,7 @@ export interface AnswerInput {
77
77
  * Only fires for a steer this turn delivered, never for the prompt that started the turn: the prompt
78
78
  * is not re-announced, and a caller's own state decides whether there was anything waiting at all.
79
79
  */
80
- onSteerDelivered?(text: string): void;
80
+ onSteerDelivered?(text: string): void | Promise<void>;
81
81
  /**
82
82
  * Called ONCE per turn with the Pi entry id of the user message that started it, or never.
83
83
  *
@@ -729,15 +729,17 @@ export class PiRpcRuntime {
729
729
  }
730
730
  }
731
731
  const queue = new TurnQueue();
732
+ const deferred = (handler) => (...args) => { if (handler)
733
+ queue.push({ kind: "callback", run: () => handler(...args) }); };
732
734
  const handlers = {
733
- onToolCall: input.onToolCall,
735
+ onToolCall: deferred(input.onToolCall),
734
736
  onFileWrite: input.onFileWrite,
735
- onToolUpdate: input.onToolUpdate,
736
- onToolPreparing: input.onToolPreparing,
737
- onToolEnd: input.onToolEnd,
738
- onThinking: input.onThinking,
739
- onThinkingEnd: input.onThinkingEnd,
740
- onSteerDelivered: input.onSteerDelivered,
737
+ onToolUpdate: deferred(input.onToolUpdate),
738
+ onToolPreparing: deferred(input.onToolPreparing),
739
+ onToolEnd: deferred(input.onToolEnd),
740
+ onThinking: deferred(input.onThinking),
741
+ onThinkingEnd: deferred(input.onThinkingEnd),
742
+ onSteerDelivered: deferred(input.onSteerDelivered),
741
743
  onPiQueue: input.onPiQueue,
742
744
  };
743
745
  const announced = new Set();
@@ -853,6 +855,10 @@ export class PiRpcRuntime {
853
855
  yield item.delta;
854
856
  continue;
855
857
  }
858
+ if (item.kind === "callback") {
859
+ await item.run();
860
+ continue;
861
+ }
856
862
  if (item.kind === "error")
857
863
  throw item.error;
858
864
  break;
@@ -1329,7 +1335,7 @@ export class PiRpcRuntime {
1329
1335
  const steeredText = describeDeliveredUserMessage(record);
1330
1336
  if (steeredText !== undefined) {
1331
1337
  try {
1332
- onSteerDelivered?.(steeredText);
1338
+ await onSteerDelivered?.(steeredText);
1333
1339
  }
1334
1340
  catch (error) {
1335
1341
  console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -2186,9 +2192,10 @@ export class PiRpcRuntime {
2186
2192
  */
2187
2193
  async clearQueues(conversationId) {
2188
2194
  const empty = { steering: [], followUp: [] };
2189
- if (this.activeTurns.has(conversationId)) {
2190
- const session = this.sessionPool.get(conversationId);
2191
- if (!session?.clearQueue)
2195
+ // A session and its queues outlive the active turn, including the abort hand-off window.
2196
+ const session = this.sessionPool.get(conversationId);
2197
+ if (session) {
2198
+ if (!session.clearQueue)
2192
2199
  return empty;
2193
2200
  try {
2194
2201
  const cleared = session.clearQueue();
@@ -2417,14 +2424,22 @@ export class PiRpcRuntime {
2417
2424
  // The levels belong to the SELECTED model, so they are asked of the model in question rather
2418
2425
  // than of whatever Pi would have defaulted to.
2419
2426
  //
2420
- // AND WHEN THE SDK DOES NOT KNOW THAT MODEL, THE WHOLE READ IS HANDED OVER rather than answered
2421
- // with another model's levels. The model in force may belong to a Pi the SDK is not (a Pi the
2422
- // person named explicitly), and a list of levels for a DIFFERENT model is worse than no answer:
2423
- // the picker would offer levels the model in force does not have.
2427
+ // A MODEL THIS PI DOES NOT KNOW IS NOT A REASON TO THROW THE WHOLE READ AWAY. The MODEL LIST
2428
+ // does not depend on the selection at all - it is the same list for every conversation - so
2429
+ // abandoning it because one model's levels are unknown turns "the model this session used was
2430
+ // removed" into "the picker is empty", which is the exact opposite of what the person needs
2431
+ // at that moment. Measured before this change: removing the model in use produced a catalog
2432
+ // with `source: "unavailable"` and ZERO models, and the spawned fallback that produced it had
2433
+ // only reached its own `set_model` rejection ("Model not found").
2434
+ //
2435
+ // The levels then come from Pi's default model, because that is the model a turn on a
2436
+ // nonexistent model actually runs on (the session cannot resolve it and falls back), so they
2437
+ // describe the model in force rather than a different one.
2424
2438
  const forModel = selection ?? reader.defaultModel;
2425
- const levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
2426
- if (forModel && !levels)
2427
- return undefined;
2439
+ let levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
2440
+ if (forModel && !levels && reader.defaultModel) {
2441
+ levels = await reader.thinkingLevelsFor(reader.defaultModel);
2442
+ }
2428
2443
  const levelNames = levels ?? [];
2429
2444
  const value = {
2430
2445
  source: "pi",
@@ -2476,8 +2491,15 @@ export class PiRpcRuntime {
2476
2491
  const channel = new RpcChannel(child);
2477
2492
  if (selection?.provider && selection.modelId) {
2478
2493
  const applied = await channel.request({ id: "catalog_set_model", type: "set_model", provider: selection.provider, modelId: selection.modelId }, CATALOG_COMMAND_TIMEOUT_MS);
2479
- if (applied.success === false)
2480
- throw new Error(applied.error ?? "Pi rejected set_model");
2494
+ // A MODEL PI NO LONGER OFFERS IS NOT A REASON TO FAIL THE READ. `set_model` only scopes the
2495
+ // level list to the model in force; the MODEL LIST itself is what the picker needs, and it
2496
+ // does not depend on the selection. Throwing here made a removed model - the model a person
2497
+ // is most likely to have just deleted - produce an empty picker with "Model not found" as
2498
+ // the only explanation. Pi keeps its current model when `set_model` is rejected, so the
2499
+ // levels below are the levels of the model that actually runs.
2500
+ if (applied.success === false) {
2501
+ console.warn(`PiRpcRuntime: catalog for ${key} could not select ${selection.provider}/${selection.modelId} (${applied.error ?? "unknown error"}); listing the models without it`);
2502
+ }
2481
2503
  }
2482
2504
  channel.write({ id: "catalog_models", type: "get_available_models" });
2483
2505
  channel.write({ id: "catalog_levels", type: "get_available_thinking_levels" });
@@ -90,6 +90,7 @@ export declare class Store {
90
90
  * fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
91
91
  */
92
92
  setDefaultModel(model?: ModelSelection): Promise<void>;
93
+ setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
93
94
  snapshot(): AppState;
94
95
  /**
95
96
  * Whether a conversation already holds a message with this id.
@@ -278,6 +279,20 @@ export declare class Store {
278
279
  * no longer change until it settles.
279
280
  */
280
281
  setConversationModel(conversationId: string, model: ModelSelection): Promise<Conversation>;
282
+ /**
283
+ * Forgets the model a conversation runs on, so it follows Pi's own default again.
284
+ *
285
+ * FOR A MODEL THAT NO LONGER EXISTS. A conversation's model is copied at birth and never re-read
286
+ * from the workspace - but the copied model can be removed from Pi, and leaving the field pointing
287
+ * at it is worse than clearing it: Pi does not fail such a turn, it runs it on its own default, so
288
+ * the picker names one model while another one answers. `undefined` DELETES the key rather than
289
+ * storing "Pi's default" as this app's state, the same as `setConversationThinking` above.
290
+ *
291
+ * THE THINKING LEVEL GOES WITH IT. A level is a property of a model (`max` on a model with one
292
+ * level means something different from `max` on another), so a level left behind by a model that
293
+ * is gone describes nothing.
294
+ */
295
+ clearConversationModel(conversationId: string): Promise<Conversation>;
281
296
  /** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
282
297
  setConversationThinking(conversationId: string, level: string | undefined): Promise<Conversation>;
283
298
  /**
@@ -683,6 +683,20 @@ export class Store {
683
683
  throw error;
684
684
  }
685
685
  }
686
+ async setTerminalShell(shell) {
687
+ const previous = this.state.terminalShell;
688
+ if (shell === undefined)
689
+ delete this.state.terminalShell;
690
+ else
691
+ this.state.terminalShell = { file: shell.file, args: [...shell.args] };
692
+ try {
693
+ await this.persist();
694
+ }
695
+ catch (error) {
696
+ this.state.terminalShell = previous;
697
+ throw error;
698
+ }
699
+ }
686
700
  snapshot() { return structuredClone(this.state); }
687
701
  /**
688
702
  * Whether a conversation already holds a message with this id.
@@ -1104,6 +1118,26 @@ export class Store {
1104
1118
  await this.persistMetadata(conversationId);
1105
1119
  return structuredClone(conversation);
1106
1120
  }
1121
+ /**
1122
+ * Forgets the model a conversation runs on, so it follows Pi's own default again.
1123
+ *
1124
+ * FOR A MODEL THAT NO LONGER EXISTS. A conversation's model is copied at birth and never re-read
1125
+ * from the workspace - but the copied model can be removed from Pi, and leaving the field pointing
1126
+ * at it is worse than clearing it: Pi does not fail such a turn, it runs it on its own default, so
1127
+ * the picker names one model while another one answers. `undefined` DELETES the key rather than
1128
+ * storing "Pi's default" as this app's state, the same as `setConversationThinking` above.
1129
+ *
1130
+ * THE THINKING LEVEL GOES WITH IT. A level is a property of a model (`max` on a model with one
1131
+ * level means something different from `max` on another), so a level left behind by a model that
1132
+ * is gone describes nothing.
1133
+ */
1134
+ async clearConversationModel(conversationId) {
1135
+ const conversation = this.requireConversation(conversationId);
1136
+ delete conversation.model;
1137
+ delete conversation.thinkingLevel;
1138
+ await this.persistMetadata(conversationId);
1139
+ return structuredClone(conversation);
1140
+ }
1107
1141
  /** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
1108
1142
  async setConversationThinking(conversationId, level) {
1109
1143
  const conversation = this.requireConversation(conversationId);
@@ -49,6 +49,46 @@
49
49
  * only way to get them. The cost is a native module, and it is paid only by operators who set
50
50
  */
51
51
  import type { ServerEvent, Workspace } from "./types.js";
52
+ /**
53
+ * WHICH SHELL, and why it is not `COMSPEC`.
54
+ *
55
+ * `process.env.COMSPEC` on Windows IS `cmd.exe`, which is what this function used to spawn - while the
56
+ * comment beside it claimed PowerShell. The mismatch was visible in practice: `cmd.exe` is a DOS-era
57
+ * console with a legacy code page, so a Chinese filename printed by `dir` arrives as mojibake and the
58
+ * prompt echoes the active code page. Super Pi's whole interface is Chinese, so a terminal that cannot
59
+ * print Chinese is not a working terminal for the people who would use it.
60
+ *
61
+ * PowerShell is therefore the default, chosen over `pwsh` by name because `powershell.exe` ships with
62
+ * every supported Windows version while `pwsh` is a separate install. `SUPER_PI_TERMINAL_SHELL` overrides
63
+ * it for anyone who wants `cmd.exe`, `pwsh`, or Git Bash.
64
+ *
65
+ * On Unix the user's own `$SHELL` is right and a login shell is requested, because the terminal should
66
+ * see the same PATH and aliases as the terminal they use elsewhere.
67
+ *
68
+ * The ARGS differ by family and are not an arbitrary detail: `-l` is a bash/sh login flag, and passing it
69
+ * to PowerShell would make the shell refuse to start.
70
+ */
71
+ export declare function parseShellCommand(command: string): {
72
+ file: string;
73
+ args: string[];
74
+ } | undefined;
75
+ export declare function defaultShell(env?: NodeJS.ProcessEnv, configured?: {
76
+ file: string;
77
+ args: string[];
78
+ }): {
79
+ file: string;
80
+ args: string[];
81
+ };
82
+ export declare function shellSource(env?: NodeJS.ProcessEnv, configured?: {
83
+ file: string;
84
+ args: string[];
85
+ }): "设置" | "环境变量" | "平台默认";
86
+ export declare function validateShellCommand(command: string, env?: NodeJS.ProcessEnv): string | undefined;
87
+ export declare function terminalShellCandidates(env?: NodeJS.ProcessEnv): {
88
+ file: string;
89
+ args: string[];
90
+ label: string;
91
+ }[];
52
92
  /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
53
93
  export declare const MAX_SESSIONS = 8;
54
94
  /**
@@ -107,12 +147,23 @@ export declare class TerminalService {
107
147
  private readonly workspaceFor;
108
148
  /** Whether a command from this peer is allowed to touch a shell at all. */
109
149
  private readonly isTrustedPeer;
150
+ private readonly configuredShell;
151
+ private readonly saveConfiguredShell;
110
152
  constructor(options: {
111
153
  workspaceFor: (workspaceId: string) => Workspace | undefined;
112
154
  isTrustedPeer: (remoteAddress: string | undefined) => boolean;
155
+ configuredShell?: () => {
156
+ file: string;
157
+ args: string[];
158
+ } | undefined;
159
+ saveConfiguredShell?: (shell: {
160
+ file: string;
161
+ args: string[];
162
+ } | undefined) => Promise<void>;
113
163
  enabled?: boolean;
114
164
  });
115
165
  readonly enabled: boolean;
166
+ setShell(command: string | null, remoteAddress: string | undefined): Promise<boolean>;
116
167
  /**
117
168
  * WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
118
169
  *