@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
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-B0TMoUBp.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CCnIvjP6.css">
11
+ <script type="module" crossorigin src="/assets/index-qTPZ6eno.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CBPWeTvl.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -33,8 +33,29 @@ export declare class ProviderService {
33
33
  private readonly catalogService;
34
34
  private readonly store;
35
35
  constructor(registry: ProviderRegistry, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, store: Store);
36
- /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
36
+ /**
37
+ * Every provider Pi knows about, as the browser may see it: no keys, only the fact of one.
38
+ *
39
+ * THREE LISTS, ONE VIEW, because this panel is a view of Pi's model configuration and Pi's
40
+ * configuration is in three places:
41
+ * - `models.json` entries (custom providers, whether this app wrote them or the person did);
42
+ * - credentials in `auth.json` with no `models.json` entry (Pi's BUILT-IN providers);
43
+ * - the ones turned off, which are in neither of Pi's files any more but are kept here.
44
+ * A panel built on the first list alone could never answer "turn off my lapsed coding plan",
45
+ * because that provider is a built-in whose only configuration is a credential.
46
+ */
37
47
  list(): Promise<PublicProvider[]>;
48
+ /**
49
+ * Turns a provider off, then makes every LIVE conversation stop pointing at the models it removed.
50
+ *
51
+ * WHY THE CONVERSATIONS ARE REPAIRED. A conversation's model is copied at birth and never re-read
52
+ * from the workspace - that is the rule. But the model it copied can CEASE TO EXIST, and a
53
+ * conversation holding one does not fail cleanly: Pi runs the turn on its own default, so the
54
+ * picker names one model while another one answers. Measured before this change: removing the
55
+ * model in use left the picker empty (`source=unavailable, models=0`).
56
+ */
57
+ disable(providerId: string): Promise<PublicProvider[]>;
58
+ enable(providerId: string): Promise<PublicProvider[]>;
38
59
  /**
39
60
  * Adds a provider, or adds a model to one, or updates one.
40
61
  *
@@ -49,7 +70,17 @@ export declare class ProviderService {
49
70
  * asks for it to go.
50
71
  */
51
72
  set(input: unknown): Promise<PublicProvider[]>;
52
- /** Removes a provider from Pi's models.json and auth.json. */
73
+ /**
74
+ * Removes a provider for good.
75
+ *
76
+ * THREE KINDS, ONE VERB, because the panel is a view of Pi's model configuration and deleting a
77
+ * provider means the same thing in all three cases:
78
+ * - a provider in `models.json`: its entry and its key go (the original behaviour);
79
+ * - a BUILT-IN with only a credential: its models are Pi's, so the credential is the only thing
80
+ * there is to delete - and deleting it is exactly what makes Pi stop offering it;
81
+ * - one that is already switched off: the remembered copy goes, which is what makes "off"
82
+ * permanent instead of something that would come back months later.
83
+ */
53
84
  remove(providerId: string): Promise<PublicProvider[]>;
54
85
  /**
55
86
  * Removes ONE model from a provider, leaving the provider and its other models alone.
@@ -74,6 +105,24 @@ export declare class ProviderService {
74
105
  * those rules forbid.
75
106
  */
76
107
  private afterChange;
108
+ /**
109
+ * Drops a conversation's model when the change removed it.
110
+ *
111
+ * WHY THIS EXISTS AT ALL. A conversation whose model was just deleted is not merely stale - it is
112
+ * WRONG in a way that never surfaces: Pi cannot resolve the model and runs the turn on its own
113
+ * default, so the picker says one thing while another model answers. Clearing the field is the
114
+ * only honest state: the conversation follows Pi's default again, which is what the next turn
115
+ * does anyway.
116
+ *
117
+ * THE LIST IS PASSED IN, NOT DERIVED FROM A CATALOG READ. The caller knows exactly which models it
118
+ * just took away (one model, or a whole provider), so asking Pi what it offers would be a slower
119
+ * and less precise way to learn the same thing - and on a machine where Pi is slow or broken, the
120
+ * broadcast every open tab is waiting for would wait with it.
121
+ *
122
+ * ARCHIVED CONVERSATIONS ARE LEFT ALONE: they are read-only by design, and rewriting a frozen
123
+ * record's stored model would make the archive describe a past that did not happen.
124
+ */
125
+ private repairConversationModels;
77
126
  /**
78
127
  * Discovers available models from the remote provider endpoint.
79
128
  * If apiKey is omitted but providerId is given, uses the stored key.
@@ -34,9 +34,106 @@ export class ProviderService {
34
34
  this.catalogService = catalogService;
35
35
  this.store = store;
36
36
  }
37
- /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
37
+ /**
38
+ * Every provider Pi knows about, as the browser may see it: no keys, only the fact of one.
39
+ *
40
+ * THREE LISTS, ONE VIEW, because this panel is a view of Pi's model configuration and Pi's
41
+ * configuration is in three places:
42
+ * - `models.json` entries (custom providers, whether this app wrote them or the person did);
43
+ * - credentials in `auth.json` with no `models.json` entry (Pi's BUILT-IN providers);
44
+ * - the ones turned off, which are in neither of Pi's files any more but are kept here.
45
+ * A panel built on the first list alone could never answer "turn off my lapsed coding plan",
46
+ * because that provider is a built-in whose only configuration is a credential.
47
+ */
38
48
  async list() {
39
- return publicProviders(await this.registry.load());
49
+ const [configured, credentialOnly, disabled] = await Promise.all([
50
+ this.registry.load(),
51
+ this.registry.loadCredentialOnly(),
52
+ this.registry.loadDisabled(),
53
+ ]);
54
+ // A built-in's models live in Pi's own catalogue, so the file layer cannot know them. They are
55
+ // filled in from the catalog the panel already receives, which is the only thing that knows what
56
+ // a built-in actually offers - and a provider card with an empty model list would misdescribe a
57
+ // provider that is working perfectly.
58
+ if (credentialOnly.length) {
59
+ try {
60
+ const catalog = await this.runtime.catalog();
61
+ const byProvider = new Map();
62
+ for (const model of catalog.models) {
63
+ const group = byProvider.get(model.provider) ?? [];
64
+ group.push(model);
65
+ byProvider.set(model.provider, group);
66
+ }
67
+ for (const provider of credentialOnly) {
68
+ provider.models = (byProvider.get(provider.id) ?? []).map((model) => ({
69
+ id: model.modelId,
70
+ name: model.label,
71
+ reasoning: model.reasoning,
72
+ imageInput: model.imageInput,
73
+ contextWindow: model.contextWindow,
74
+ }));
75
+ }
76
+ }
77
+ catch {
78
+ // A catalog that cannot be read leaves a built-in's models unknown, which is the truth - the
79
+ // provider itself is still listed and can still be switched off.
80
+ }
81
+ }
82
+ const offList = disabled.map((entry) => ({
83
+ id: entry.id,
84
+ ...(entry.name ? { name: entry.name } : {}),
85
+ baseUrl: typeof entry.modelsEntry?.baseUrl === "string" ? entry.modelsEntry.baseUrl : "",
86
+ api: typeof entry.modelsEntry?.api === "string" ? entry.modelsEntry.api : "",
87
+ hasAuthKey: Boolean(entry.credential),
88
+ ...(entry.modelsEntry ? {} : { builtin: true }),
89
+ disabled: true,
90
+ models: entry.models,
91
+ }));
92
+ return publicProviders([...configured, ...credentialOnly, ...offList]);
93
+ }
94
+ /**
95
+ * Turns a provider off, then makes every LIVE conversation stop pointing at the models it removed.
96
+ *
97
+ * WHY THE CONVERSATIONS ARE REPAIRED. A conversation's model is copied at birth and never re-read
98
+ * from the workspace - that is the rule. But the model it copied can CEASE TO EXIST, and a
99
+ * conversation holding one does not fail cleanly: Pi runs the turn on its own default, so the
100
+ * picker names one model while another one answers. Measured before this change: removing the
101
+ * model in use left the picker empty (`source=unavailable, models=0`).
102
+ */
103
+ async disable(providerId) {
104
+ const active = await this.registry.load();
105
+ const known = active.find((provider) => provider.id === providerId);
106
+ let models = known?.models ?? [];
107
+ if (!known) {
108
+ // A built-in has no `models.json` entry, so its list comes from Pi's catalogue - and it has to
109
+ // be captured NOW: once the credential is gone the catalogue no longer offers it, and the
110
+ // "off" card would have nothing to say about what switching it back on brings.
111
+ try {
112
+ const catalog = await this.runtime.catalog();
113
+ if (catalog.source === "pi") {
114
+ models = catalog.models
115
+ .filter((model) => model.provider === providerId)
116
+ .map((model) => ({
117
+ id: model.modelId,
118
+ name: model.label,
119
+ reasoning: model.reasoning,
120
+ imageInput: model.imageInput,
121
+ contextWindow: model.contextWindow,
122
+ }));
123
+ }
124
+ }
125
+ catch {
126
+ // Still switchable off with an unknown model list; the record just says less about it.
127
+ }
128
+ }
129
+ await this.registry.disable(providerId, models);
130
+ await this.afterChange([{ provider: providerId }]);
131
+ return this.list();
132
+ }
133
+ async enable(providerId) {
134
+ await this.registry.enable(providerId);
135
+ await this.afterChange();
136
+ return this.list();
40
137
  }
41
138
  /**
42
139
  * Adds a provider, or adds a model to one, or updates one.
@@ -99,19 +196,42 @@ export class ProviderService {
99
196
  throw error;
100
197
  }
101
198
  await this.afterChange();
102
- return publicProviders(next);
199
+ return this.list();
103
200
  }
104
- /** Removes a provider from Pi's models.json and auth.json. */
201
+ /**
202
+ * Removes a provider for good.
203
+ *
204
+ * THREE KINDS, ONE VERB, because the panel is a view of Pi's model configuration and deleting a
205
+ * provider means the same thing in all three cases:
206
+ * - a provider in `models.json`: its entry and its key go (the original behaviour);
207
+ * - a BUILT-IN with only a credential: its models are Pi's, so the credential is the only thing
208
+ * there is to delete - and deleting it is exactly what makes Pi stop offering it;
209
+ * - one that is already switched off: the remembered copy goes, which is what makes "off"
210
+ * permanent instead of something that would come back months later.
211
+ */
105
212
  async remove(providerId) {
106
213
  const previous = await this.registry.load();
107
214
  const existing = previous.find((provider) => provider.id === providerId);
108
- if (!existing)
109
- throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
215
+ if (!existing) {
216
+ const disabled = (await this.registry.loadDisabled()).some((entry) => entry.id === providerId);
217
+ if (disabled) {
218
+ await this.registry.forgetDisabled(providerId);
219
+ await this.afterChange();
220
+ return this.list();
221
+ }
222
+ const builtin = (await this.registry.loadCredentialOnly()).some((provider) => provider.id === providerId);
223
+ if (builtin) {
224
+ await this.registry.removeCredential(providerId);
225
+ await this.afterChange([{ provider: providerId }]);
226
+ return this.list();
227
+ }
228
+ throw new Error(`Pi 的配置里没有供应商「${providerId}」`);
229
+ }
110
230
  const next = previous.filter((provider) => provider.id !== providerId);
111
231
  await this.registry.applyToPi(previous, next);
112
232
  await this.registry.save(next);
113
- await this.afterChange();
114
- return publicProviders(next);
233
+ await this.afterChange([{ provider: providerId }]);
234
+ return this.list();
115
235
  }
116
236
  /**
117
237
  * Removes ONE model from a provider, leaving the provider and its other models alone.
@@ -139,8 +259,8 @@ export class ProviderService {
139
259
  const next = previous.map((provider) => (provider.id === providerId ? { ...provider, models: remaining } : provider));
140
260
  await this.registry.applyToPi(previous, next);
141
261
  await this.registry.save(next);
142
- await this.afterChange();
143
- return publicProviders(next);
262
+ await this.afterChange([{ provider: providerId, modelId }]);
263
+ return this.list();
144
264
  }
145
265
  /**
146
266
  * What has to happen after the files are written, for the change to be REAL rather than pending.
@@ -150,9 +270,10 @@ export class ProviderService {
150
270
  * UNFILTERED one, and handing that to a conversation that has model rules would offer it models
151
271
  * those rules forbid.
152
272
  */
153
- async afterChange() {
273
+ async afterChange(removed = []) {
154
274
  this.runtime.invalidateCatalog?.();
155
275
  this.runtime.bumpModelEpoch?.();
276
+ await this.repairConversationModels(removed);
156
277
  for (const conversation of this.store.snapshot().conversations) {
157
278
  if (conversation.archivedAt)
158
279
  continue;
@@ -169,6 +290,40 @@ export class ProviderService {
169
290
  await this.catalogService.broadcastAllCatalog?.();
170
291
  this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
171
292
  }
293
+ /**
294
+ * Drops a conversation's model when the change removed it.
295
+ *
296
+ * WHY THIS EXISTS AT ALL. A conversation whose model was just deleted is not merely stale - it is
297
+ * WRONG in a way that never surfaces: Pi cannot resolve the model and runs the turn on its own
298
+ * default, so the picker says one thing while another model answers. Clearing the field is the
299
+ * only honest state: the conversation follows Pi's default again, which is what the next turn
300
+ * does anyway.
301
+ *
302
+ * THE LIST IS PASSED IN, NOT DERIVED FROM A CATALOG READ. The caller knows exactly which models it
303
+ * just took away (one model, or a whole provider), so asking Pi what it offers would be a slower
304
+ * and less precise way to learn the same thing - and on a machine where Pi is slow or broken, the
305
+ * broadcast every open tab is waiting for would wait with it.
306
+ *
307
+ * ARCHIVED CONVERSATIONS ARE LEFT ALONE: they are read-only by design, and rewriting a frozen
308
+ * record's stored model would make the archive describe a past that did not happen.
309
+ */
310
+ async repairConversationModels(removed) {
311
+ if (!removed.length)
312
+ return;
313
+ const gone = new Set(removed.map((item) => `${item.provider}/${item.modelId ?? "*"}`));
314
+ let changed = false;
315
+ for (const conversation of this.store.snapshot().conversations) {
316
+ const model = conversation.model;
317
+ if (conversation.archivedAt || !model)
318
+ continue;
319
+ if (!gone.has(`${model.provider}/${model.modelId}`) && !gone.has(`${model.provider}/*`))
320
+ continue;
321
+ await this.store.clearConversationModel(conversation.id);
322
+ changed = true;
323
+ }
324
+ if (changed)
325
+ this.broadcaster.broadcastState();
326
+ }
172
327
  /**
173
328
  * Discovers available models from the remote provider endpoint.
174
329
  * If apiKey is omitted but providerId is given, uses the stored key.
@@ -1018,6 +1018,9 @@ export class RunService {
1018
1018
  */
1019
1019
  const onSteerDelivered = (text) => {
1020
1020
  enqueueTools("a queued message being delivered", async () => {
1021
+ // A delayed SDK delivery during abort still belongs to the stopped producer.
1022
+ if (controller.signal.aborted)
1023
+ return toolCalls;
1021
1024
  const previous = assistant;
1022
1025
  // Checkpoint first: the message being left behind is complete - Pi drains its queues at a turn
1023
1026
  // boundary - and this is the last chance to write it durably with the lane it belongs to.
@@ -1039,7 +1042,7 @@ export class RunService {
1039
1042
  // server never handed it - the prompt that opened the turn is the obvious case - and inventing an
1040
1043
  // assistant message for one of those would open an empty bubble before every single turn.
1041
1044
  if (!delivered)
1042
- return [];
1045
+ return toolCalls;
1043
1046
  const next = delivered.assistant;
1044
1047
  this.broadcaster.broadcastState();
1045
1048
  assistant = next;
@@ -1053,6 +1056,7 @@ export class RunService {
1053
1056
  toolCalls = [];
1054
1057
  return [];
1055
1058
  });
1059
+ return pendingTools;
1056
1060
  };
1057
1061
  try {
1058
1062
  for await (const delta of this.runtime.answer({
@@ -1351,10 +1355,10 @@ export class RunService {
1351
1355
  if (error?.__superPiPreflight) {
1352
1356
  throw error;
1353
1357
  }
1354
- await this.store.setStatus(conversation.id, "failed");
1355
- if (!controller.signal.aborted) {
1356
- await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
1357
- }
1358
+ await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "failed");
1359
+ if (controller.signal.aborted)
1360
+ return;
1361
+ await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
1358
1362
  throw error;
1359
1363
  }
1360
1364
  finally {
@@ -1424,6 +1428,8 @@ export class RunService {
1424
1428
  throw new Error("这条消息已不在等待队列中,无法打断并回答");
1425
1429
  this.interruptTargets.set(conversationId, messageId);
1426
1430
  try {
1431
+ // Clear BEFORE abort: Pi can drain follow-ups while the old turn is winding down.
1432
+ await this.clearPiQueues(conversationId);
1427
1433
  if (this.activeRuns.has(conversationId))
1428
1434
  await this.stopRun(conversationId);
1429
1435
  // PI'S QUEUES ARE EMPTIED BEFORE THE SELECTED MESSAGE IS ANSWERED ON ITS OWN. The message may be
@@ -1483,8 +1489,9 @@ export class RunService {
1483
1489
  }
1484
1490
  catch (error) {
1485
1491
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
1486
- if (!controller.signal.aborted)
1487
- await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1492
+ if (controller.signal.aborted)
1493
+ return;
1494
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1488
1495
  throw error;
1489
1496
  }
1490
1497
  finally {
@@ -65,6 +65,11 @@ export class CommandHandler {
65
65
  this.conversationService.releaseRevert(conversationId);
66
66
  }
67
67
  async handleCommand(command, sendToSocket, remoteAddress) {
68
+ if (command.type === "terminal.shell.set") {
69
+ if (!this.terminalService || !(await this.terminalService.setShell(command.command, remoteAddress)))
70
+ return;
71
+ return;
72
+ }
68
73
  if (command.type === "terminal.open" || command.type === "terminal.input" || command.type === "terminal.resize" || command.type === "terminal.close") {
69
74
  if (!this.terminalService)
70
75
  return;
@@ -101,6 +106,22 @@ export class CommandHandler {
101
106
  await this.providerService.removeModel(command.providerId, command.modelId);
102
107
  return;
103
108
  }
109
+ // OFF AND ON. Separate from `providers.remove` because removing is FOREVER - it drops the key -
110
+ // while switching a provider off keeps everything needed to switch it back on. The lapsed
111
+ // subscription is the case this exists for: the provider has to leave the picker, and the person
112
+ // may well renew the plan next month.
113
+ if (command.type === "providers.disable") {
114
+ if (!this.providerService)
115
+ throw new Error("供应商功能不可用");
116
+ await this.providerService.disable(command.providerId);
117
+ return;
118
+ }
119
+ if (command.type === "providers.enable") {
120
+ if (!this.providerService)
121
+ throw new Error("供应商功能不可用");
122
+ await this.providerService.enable(command.providerId);
123
+ return;
124
+ }
104
125
  if (command.type === "providers.fetch") {
105
126
  if (!this.providerService)
106
127
  throw new Error("供应商功能不可用");
@@ -786,6 +786,14 @@ export function createHttpServer(options) {
786
786
  .end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
787
787
  return;
788
788
  }
789
+ if (url.pathname === "/api/terminal-shell") {
790
+ const { defaultShell, shellSource, terminalShellCandidates } = await import("./terminal.js");
791
+ const configured = context.store.snapshot().terminalShell;
792
+ const shell = defaultShell(process.env, configured);
793
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
794
+ .end(JSON.stringify({ shell, source: shellSource(process.env, configured), candidates: terminalShellCandidates() }));
795
+ return;
796
+ }
789
797
  if (url.pathname === "/api/git") {
790
798
  const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
791
799
  try {
@@ -237,6 +237,8 @@ const workspaceService = new WorkspaceService(store, runtime, broadcaster, catal
237
237
  const terminalService = new TerminalService({
238
238
  workspaceFor: (workspaceId) => store.snapshot().workspaces.find((workspace) => workspace.id === workspaceId),
239
239
  isTrustedPeer: (remoteAddress) => isLocalOrPrivateAddress(remoteAddress),
240
+ configuredShell: () => store.snapshot().terminalShell,
241
+ saveConfiguredShell: async (shell) => { await store.setTerminalShell(shell); broadcaster.broadcastState(); },
240
242
  });
241
243
  /**
242
244
  * Background command execution, independent from subagents. Uses node-pty to run shell commands
@@ -48,6 +48,11 @@ export type TurnItem =
48
48
  kind: "text";
49
49
  delta: string;
50
50
  }
51
+ /** Non-text notices share the text FIFO; delivery may await a transcript boundary. */
52
+ | {
53
+ kind: "callback";
54
+ run: () => void | Promise<void>;
55
+ }
51
56
  /** The turn is over. */
52
57
  | {
53
58
  kind: "end";
@@ -75,7 +80,7 @@ export interface TurnEventHandlers {
75
80
  onThinking?(delta: string): void;
76
81
  onThinkingEnd?(): void;
77
82
  /** A steer this turn delivered - the text Pi picked up from its own queue. */
78
- onSteerDelivered?(text: string): void;
83
+ onSteerDelivered?(text: string): void | Promise<void>;
79
84
  /** Pi's own queue, as a full snapshot. */
80
85
  onPiQueue?(queue: {
81
86
  steering: string[];
@@ -64,6 +64,54 @@ export type ProviderDefinition = {
64
64
  apiKey?: string;
65
65
  hasAuthKey?: boolean;
66
66
  models: ProviderModelDefinition[];
67
+ /**
68
+ * The key is written into `models.json` itself rather than into `auth.json`.
69
+ *
70
+ * Pi's own documentation leads with it (`"apiKey": "$MY_KEY"`), and it is the only way to point a
71
+ * provider at a key that lives in the environment. It matters here because such a provider IS
72
+ * offered by Pi when the variable is set - so the panel must not tell the person it has no
73
+ * credential, and must not warn that Pi will not offer it.
74
+ */
75
+ keyInModelsJson?: boolean;
76
+ /**
77
+ * TRUE when the provider is one of Pi's own, with no entry in `models.json`.
78
+ *
79
+ * Its models come from Pi itself and the only thing a person has configured for it is the
80
+ * credential, so it can be switched off but has no address to edit.
81
+ */
82
+ builtin?: boolean;
83
+ /**
84
+ * TRUE when the provider is switched OFF: Pi does not offer it, and switching it back on restores
85
+ * exactly what was there.
86
+ */
87
+ disabled?: boolean;
88
+ };
89
+ /**
90
+ * A provider that was switched OFF, and everything needed to switch it back on.
91
+ *
92
+ * WHY THIS EXISTS AT ALL. Turning a provider off means taking it out of Pi's files - the
93
+ * `models.json` entry and the `auth.json` credential - because that is the only thing Pi honours
94
+ * (a provider is offered exactly when it has a working credential, and `ModelRuntime.getAvailable()`
95
+ * is what the picker reads). Taking something out of a file is not the same as throwing it away, so
96
+ * what was taken out is written down HERE, in Super Pi's own directory, with the same restrictive
97
+ * permissions the registry already has. Switching it back on is then a matter of putting those two
98
+ * pieces back where Pi reads them.
99
+ *
100
+ * THE CREDENTIAL IS IN HERE, and that is deliberate. A provider that is switched off is one a person
101
+ * may well switch back on - a coding plan that was renewed, a proxy that was moved - and a
102
+ * credential that cannot be recovered cannot be restored. The file is 0600, it lives in Super Pi's own
103
+ * data directory, and nothing in it is ever broadcast: the browser is told only that a key exists.
104
+ */
105
+ export type DisabledProvider = {
106
+ id: string;
107
+ /** The provider's name, as it was. */
108
+ name?: string;
109
+ /** The `models.json` entry, verbatim, when the provider had one. */
110
+ modelsEntry?: Record<string, unknown>;
111
+ /** The `auth.json` entry, verbatim, when the provider had a credential. */
112
+ credential?: Record<string, unknown>;
113
+ /** The models the provider offered, so the panel can show what switching it back on brings. */
114
+ models: ProviderModelDefinition[];
67
115
  };
68
116
  /** One model, as the browser sees it. No key, no nothing secret - there is nothing secret here. */
69
117
  export type { PublicProvider, PublicProviderModel } from "./types.js";
@@ -116,11 +164,69 @@ export declare class ProviderRegistry {
116
164
  constructor(dataDir: string, piDir?: string);
117
165
  /** Super Pi's own registry: the providers it manages, with their keys. */
118
166
  private get registryPath();
167
+ /** The registry file as one object: the managed providers AND the ones turned off. */
168
+ private readRegistry;
169
+ /** Writes one half of the registry file without losing the other. */
170
+ private writeRegistry;
119
171
  /**
120
172
  * Reads all configured providers directly from Pi's models.json and auth.json.
121
173
  * Super Pi acts as the direct visual management plane for Pi's model configurations.
174
+ *
175
+ * PROVIDERS THAT ARE TURNED OFF ARE NOT IN HERE. They have been taken out of Pi's files - that is
176
+ * what "off" means - so this list is exactly what Pi would offer, which is what the write path
177
+ * (`set`, `remove`, `applyToPi`) has to reason about. `loadDisabled()` is the other half, for the
178
+ * panel that has to be able to switch them back on.
122
179
  */
123
180
  load(): Promise<ProviderDefinition[]>;
181
+ /**
182
+ * Pi's OWN providers: the ones that exist because they have a credential in `auth.json` and no
183
+ * entry in `models.json`.
184
+ *
185
+ * WHY THIS IS A SEPARATE READ. A built-in such as `kimi-coding` is defined inside Pi, not in a
186
+ * file - the only thing a person has configured for it is the credential. Those providers are
187
+ * invisible to `load()`, which reads `models.json`, and that is correct for the WRITE path:
188
+ * writing an empty `models.json` entry for a built-in would shadow Pi's own definition with a
189
+ * broken one. But they are exactly what a person wants to switch off ("I stopped paying for this
190
+ * plan"), so the panel has to see them. This returns the ids and the fact of a credential; the
191
+ * models come from Pi's catalogue, which this file layer cannot see.
192
+ */
193
+ loadCredentialOnly(): Promise<ProviderDefinition[]>;
194
+ /** The providers that are switched off, as the panel needs to see them. */
195
+ loadDisabled(): Promise<DisabledProvider[]>;
196
+ /**
197
+ * Takes a provider out of Pi's configuration and remembers exactly what was taken out.
198
+ *
199
+ * TWO FILES, and both are needed. `models.json` is where a custom provider's models are defined;
200
+ * `auth.json` is where its credential is. Pi offers a provider's models only when its auth is
201
+ * configured (`ModelRuntime.getAvailable()` filters on exactly that), so removing the credential
202
+ * is what makes a BUILT-IN stop being offered - it has no `models.json` entry to remove - while
203
+ * removing both is what makes a custom one disappear completely.
204
+ *
205
+ * NOTHING IS THROWN AWAY. Both pieces are written into Super Pi's own registry, which is how
206
+ * `enable` can put them back byte for byte. A person switching off a lapsed subscription provider
207
+ * is not asking to forget their endpoint and their models.
208
+ */
209
+ disable(providerId: string, models?: ProviderModelDefinition[]): Promise<void>;
210
+ /**
211
+ * Puts a provider back exactly where `disable` took it from.
212
+ *
213
+ * AN EXISTING ENTRY IS NOT OVERWRITTEN. The person may have re-created the provider (or written a
214
+ * new `models.json` entry for the same id) while it was off; the newer entry is theirs and wins,
215
+ * and only the missing piece is restored. Refusing would be worse: the switch says "back on", and
216
+ * the credential is what makes it usable.
217
+ */
218
+ enable(providerId: string): Promise<void>;
219
+ /**
220
+ * Deletes a BUILT-IN provider's credential from Pi's `auth.json`.
221
+ *
222
+ * THE ONLY THING THERE IS TO DELETE. A built-in's models live in Pi itself; what a person has
223
+ * configured for it is the credential, and removing that is what makes Pi stop offering it. This
224
+ * is the PERMANENT version: unlike `disable`, nothing is remembered, so switching it back on means
225
+ * logging in to that provider again (which is what re-subscribing to the plan involves anyway).
226
+ */
227
+ removeCredential(providerId: string): Promise<void>;
228
+ /** Forgets a disabled provider without restoring anything: the permanent form of "off". */
229
+ forgetDisabled(providerId: string): Promise<void>;
124
230
  /** Writes the registry. The key lives here, so the file is 0600. */
125
231
  save(providers: ProviderDefinition[]): Promise<void>;
126
232
  /**