@evandu/super-pi 0.1.101 → 0.1.103

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.
@@ -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-gjLqMWYT.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-8olCbCbk.css">
11
+ <script type="module" crossorigin src="/assets/index-mAu39Bra.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DDkbPYLz.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -58,6 +58,27 @@ export declare class CatalogService {
58
58
  * Broadcasts subagents to connected clients.
59
59
  */
60
60
  broadcastSubagents(): Promise<void>;
61
+ /**
62
+ * Sets the app-wide default model, or clears it back to "Pi's own default".
63
+ *
64
+ * THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
65
+ * a default Pi does not offer would be stamped onto every new conversation, and the first turn
66
+ * would fail on a model the user never chose. The check happens at SAVE time rather than at
67
+ * birth time so the user hears about it while they are still looking at the list.
68
+ *
69
+ * The label is taken from the catalog row rather than from the request, so the stored value
70
+ * always carries the display name Pi itself reported.
71
+ */
72
+ setDefaultModel(model: {
73
+ provider: string;
74
+ modelId: string;
75
+ } | null): Promise<void>;
76
+ /**
77
+ * The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
78
+ * the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
79
+ * models from the place that decides what the app-wide default may be.
80
+ */
81
+ getAllCatalog(): Promise<ModelCatalog>;
61
82
  /**
62
83
  * Helper formatting an error when a requested model/thinking level is not offered by Pi.
63
84
  */
@@ -165,6 +165,43 @@ export class CatalogService {
165
165
  async broadcastSubagents() {
166
166
  this.broadcaster.broadcast({ type: "subagent.catalog", catalog: await this.subagentCatalog() });
167
167
  }
168
+ /**
169
+ * Sets the app-wide default model, or clears it back to "Pi's own default".
170
+ *
171
+ * THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
172
+ * a default Pi does not offer would be stamped onto every new conversation, and the first turn
173
+ * would fail on a model the user never chose. The check happens at SAVE time rather than at
174
+ * birth time so the user hears about it while they are still looking at the list.
175
+ *
176
+ * The label is taken from the catalog row rather than from the request, so the stored value
177
+ * always carries the display name Pi itself reported.
178
+ */
179
+ async setDefaultModel(model) {
180
+ if (!this.store)
181
+ throw new Error("Store unavailable");
182
+ let selection;
183
+ if (model) {
184
+ const catalog = await this.runtime.catalog();
185
+ const option = catalog.source === "pi"
186
+ ? catalog.models.find(item => item.provider === model.provider && item.modelId === model.modelId)
187
+ : undefined;
188
+ if (catalog.source === "pi" && !option)
189
+ throw this.notOffered(`模型 ${model.provider}/${model.modelId}`, catalog);
190
+ selection = option
191
+ ? { provider: option.provider, modelId: option.modelId, label: option.label }
192
+ : { provider: model.provider, modelId: model.modelId, label: model.modelId };
193
+ }
194
+ await this.store.setDefaultModel(selection);
195
+ this.broadcaster.broadcastState();
196
+ }
197
+ /**
198
+ * The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
199
+ * the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
200
+ * models from the place that decides what the app-wide default may be.
201
+ */
202
+ async getAllCatalog() {
203
+ return this.runtime.catalog();
204
+ }
168
205
  /**
169
206
  * Helper formatting an error when a requested model/thinking level is not offered by Pi.
170
207
  */
@@ -42,7 +42,13 @@ export class ConversationService {
42
42
  // stamp a dead selection onto every new conversation: the scoped catalog probe would
43
43
  // fail and leave the model/thinking controls unavailable. A missing or invalid default
44
44
  // means "follow Pi's current default" for this newly-created conversation.
45
- let model = workspace.defaultModel;
45
+ // THE PRECEDENCE AT BIRTH IS WORKSPACE > APP > Pi. The app-wide default is a FALLBACK, not an
46
+ // override: a workspace that has a default of its own keeps it, and only a workspace with none
47
+ // inherits the app-wide one. Both are copied onto the conversation and never consulted again,
48
+ // so changing either default never rewrites an existing conversation.
49
+ const appDefault = this.store.snapshot().defaultModel;
50
+ const inheritsAppDefault = !workspace.defaultModel && Boolean(appDefault);
51
+ let model = workspace.defaultModel ?? appDefault;
46
52
  let thinkingLevel = workspace.defaultThinkingLevel;
47
53
  const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
48
54
  if (catalog?.source === "pi" && model) {
@@ -60,6 +66,19 @@ export class ConversationService {
60
66
  }
61
67
  }
62
68
  }
69
+ if (inheritsAppDefault && model) {
70
+ // An app-wide default belongs to no workspace, so the workspace's model rules are checked HERE,
71
+ // at birth, where the workspace is finally known. A default the rules forbid is not stamped
72
+ // on: the conversation starts on Pi's own default instead, which is the same outcome as a
73
+ // workspace default that pointed at a model Pi no longer offers.
74
+ try {
75
+ assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, model);
76
+ }
77
+ catch {
78
+ model = undefined;
79
+ thinkingLevel = undefined;
80
+ }
81
+ }
63
82
  const resolved = workspace.defaultAgentId
64
83
  ? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
65
84
  : { model, thinkingLevel };
@@ -219,6 +219,57 @@ export declare class RunService {
219
219
  * the moment Pi holds it.
220
220
  */
221
221
  onPiQueue(conversationId: string, snapshot: PiQueueSnapshot): void;
222
+ /**
223
+ * The message a delivery is about, resolved by the text Pi announced.
224
+ *
225
+ * WHY THE TEXT AND NOT THE POSITION. A message that was STEERED is `sending` here - claimed, so the
226
+ * user cannot withdraw it - and the oldest `sending` message is the one Pi picked up, because Pi's
227
+ * steering queue is FIFO. That is the old rule and it still holds. A message that was handed to Pi's
228
+ * FOLLOW-UP queue is NOT `sending`: Super Pi keeps it in its own list so the user can still withdraw
229
+ * it, and a conversation can hold a `waiting` message and a `sending` one at the same time. For that
230
+ * one, position is the wrong question and the text is the right one.
231
+ *
232
+ * THE TEXT IS THE SAME TEXT ON BOTH SIDES. Pi matches its own queue by `indexOf` on the message text
233
+ * (`agent-session.ts:1123`), so two messages with identical words are ONE entry to Pi and the first of
234
+ * them to Super Pi as well. Resolving by text is therefore not a guess that could pick a different
235
+ * message than Pi did - it is the same comparison Pi itself made.
236
+ *
237
+ * A `sending` message wins over a `waiting` one with the same text, because Pi drains its steering
238
+ * queue before its follow-up queue, so the steering message is the one that was delivered first.
239
+ */
240
+ private matchDeliveredMessage;
241
+ /**
242
+ * Hands a waiting message to Pi's follow-up queue, so the run that is already going answers it.
243
+ *
244
+ * WHY THIS EXISTS. A message typed while the agent was busy used to wait for the current turn to END
245
+ * and then be answered by a turn of its own - a fresh `prompt()`, which runs Pi's `_prepareRun`, which
246
+ * emits `before_agent_start`. Super Pi's memory extension answers that hook with an HTTP search and
247
+ * patches the system prompt with what came back, so every queued message paid for a round trip to the
248
+ * memory endpoint and for a prompt larger than the one before it. A follow-up is delivered by Pi's
249
+ * agent loop inside the running turn, so it pays for neither.
250
+ *
251
+ * IT IS AN OPTIMISATION, NOT A COMMITMENT. The message stays in Super Pi's own waiting list, so it is
252
+ * still the user's to withdraw and still the user's to interrupt, and if Pi does not deliver it - the
253
+ * run was aborted, the queues were cleared - the message falls back to being answered by a turn of its
254
+ * own. Nothing is lost either way, which is what makes it safe to try.
255
+ */
256
+ private submitToPiQueue;
257
+ /**
258
+ * Empties Pi's steering and follow-up queues for a conversation.
259
+ *
260
+ * WHY A HOST HAS TO DO THIS. Pi delivers whatever is left in those queues when the run ends, and it
261
+ * cannot take back a single message - `clear_queue` is the only queue command it has. So whenever
262
+ * Super Pi takes a message back, the queues have to be emptied, or Pi answers words the user believes
263
+ * they took back.
264
+ *
265
+ * NOTHING IS LOST. Every message Pi was holding is still in Super Pi's own waiting list, and the run's
266
+ * loop answers those as turns of their own.
267
+ *
268
+ * THE STEERS COME BACK. Emptying the queues also empties the steering queue, and a steer is a message
269
+ * the user asked to be answered NOW - so the ones Pi had not delivered go back in, and clearing the
270
+ * queues for one message does not silently un-steer another.
271
+ */
272
+ private clearPiQueues;
222
273
  /**
223
274
  * Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
224
275
  *
@@ -285,6 +285,11 @@ export class RunService {
285
285
  console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
286
286
  });
287
287
  }
288
+ else {
289
+ // THE CONVERSATION IS BUSY, so the run that is going answers this result itself - through
290
+ // Pi's own follow-up queue, so the result does not have to wait for a turn of its own.
291
+ await this.submitToPiQueue(task.conversationId, queued);
292
+ }
288
293
  }
289
294
  catch (error) {
290
295
  console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -541,6 +546,118 @@ export class RunService {
541
546
  onPiQueue(conversationId, snapshot) {
542
547
  this.broadcaster.broadcast({ type: "pi.queue", conversationId, ...snapshot });
543
548
  }
549
+ /**
550
+ * The message a delivery is about, resolved by the text Pi announced.
551
+ *
552
+ * WHY THE TEXT AND NOT THE POSITION. A message that was STEERED is `sending` here - claimed, so the
553
+ * user cannot withdraw it - and the oldest `sending` message is the one Pi picked up, because Pi's
554
+ * steering queue is FIFO. That is the old rule and it still holds. A message that was handed to Pi's
555
+ * FOLLOW-UP queue is NOT `sending`: Super Pi keeps it in its own list so the user can still withdraw
556
+ * it, and a conversation can hold a `waiting` message and a `sending` one at the same time. For that
557
+ * one, position is the wrong question and the text is the right one.
558
+ *
559
+ * THE TEXT IS THE SAME TEXT ON BOTH SIDES. Pi matches its own queue by `indexOf` on the message text
560
+ * (`agent-session.ts:1123`), so two messages with identical words are ONE entry to Pi and the first of
561
+ * them to Super Pi as well. Resolving by text is therefore not a guess that could pick a different
562
+ * message than Pi did - it is the same comparison Pi itself made.
563
+ *
564
+ * A `sending` message wins over a `waiting` one with the same text, because Pi drains its steering
565
+ * queue before its follow-up queue, so the steering message is the one that was delivered first.
566
+ */
567
+ matchDeliveredMessage(conversationId, text) {
568
+ if (!text)
569
+ return undefined;
570
+ const waiting = this.store.listWaitingMessages(conversationId);
571
+ const match = waiting.find((item) => item.delivery === "sending" && composePrompt(item) === text)
572
+ ?? waiting.find((item) => item.delivery === "waiting" && composePrompt(item) === text);
573
+ return match?.id;
574
+ }
575
+ /**
576
+ * Hands a waiting message to Pi's follow-up queue, so the run that is already going answers it.
577
+ *
578
+ * WHY THIS EXISTS. A message typed while the agent was busy used to wait for the current turn to END
579
+ * and then be answered by a turn of its own - a fresh `prompt()`, which runs Pi's `_prepareRun`, which
580
+ * emits `before_agent_start`. Super Pi's memory extension answers that hook with an HTTP search and
581
+ * patches the system prompt with what came back, so every queued message paid for a round trip to the
582
+ * memory endpoint and for a prompt larger than the one before it. A follow-up is delivered by Pi's
583
+ * agent loop inside the running turn, so it pays for neither.
584
+ *
585
+ * IT IS AN OPTIMISATION, NOT A COMMITMENT. The message stays in Super Pi's own waiting list, so it is
586
+ * still the user's to withdraw and still the user's to interrupt, and if Pi does not deliver it - the
587
+ * run was aborted, the queues were cleared - the message falls back to being answered by a turn of its
588
+ * own. Nothing is lost either way, which is what makes it safe to try.
589
+ */
590
+ async submitToPiQueue(conversationId, message) {
591
+ if (!this.runtime.followUp)
592
+ return;
593
+ // NOTHING TO FOLLOW UP ON. With no turn running, Pi would hold the message until the NEXT one,
594
+ // which is a later answer than the one the message is waiting for.
595
+ if (!this.activeRuns.has(conversationId))
596
+ return;
597
+ // AN INTERRUPT IS ALREADY STARTING. It aborts the run and empties Pi's queues, so a message handed
598
+ // to Pi now would be taken back a moment later. It waits in the list for the next run, exactly as it
599
+ // did before.
600
+ if (this.interruptTargets.has(conversationId))
601
+ return;
602
+ // A MESSAGE THAT IS A COMMAND IS NOT QUEUED. Pi expands a skill command or a prompt template before
603
+ // it queues the text (`agent-session.ts:2221-2222`), and it REFUSES an extension command outright.
604
+ // An expanded text is not the text Super Pi has, so the delivery could not be resolved back to its
605
+ // message - the answer would land under the wrong question. Such a message waits for a turn of its
606
+ // own, where it is the prompt and the question and the answer are the same thing.
607
+ if (composePrompt(message).startsWith("/"))
608
+ return;
609
+ try {
610
+ await this.runtime.followUp(conversationId, composePrompt(message), message.images);
611
+ }
612
+ catch (error) {
613
+ // THE FALLBACK IS THE OLD BEHAVIOUR, so a refusal here is not a failure: the message stays in the
614
+ // waiting list and the run's loop answers it with a turn of its own.
615
+ console.warn(`Super Pi: handing a queued message to Pi's follow-up queue failed: ${error instanceof Error ? error.message : "unknown error"}`);
616
+ }
617
+ }
618
+ /**
619
+ * Empties Pi's steering and follow-up queues for a conversation.
620
+ *
621
+ * WHY A HOST HAS TO DO THIS. Pi delivers whatever is left in those queues when the run ends, and it
622
+ * cannot take back a single message - `clear_queue` is the only queue command it has. So whenever
623
+ * Super Pi takes a message back, the queues have to be emptied, or Pi answers words the user believes
624
+ * they took back.
625
+ *
626
+ * NOTHING IS LOST. Every message Pi was holding is still in Super Pi's own waiting list, and the run's
627
+ * loop answers those as turns of their own.
628
+ *
629
+ * THE STEERS COME BACK. Emptying the queues also empties the steering queue, and a steer is a message
630
+ * the user asked to be answered NOW - so the ones Pi had not delivered go back in, and clearing the
631
+ * queues for one message does not silently un-steer another.
632
+ */
633
+ async clearPiQueues(conversationId) {
634
+ if (!this.runtime.clearQueues)
635
+ return;
636
+ let cleared;
637
+ try {
638
+ cleared = await this.runtime.clearQueues(conversationId);
639
+ }
640
+ catch (error) {
641
+ console.warn(`Super Pi: clearing Pi's queues for ${conversationId} failed: ${error instanceof Error ? error.message : "unknown error"}`);
642
+ return;
643
+ }
644
+ for (const text of cleared.steering ?? []) {
645
+ // A STEER NEEDS A TURN TO GO INTO. With no turn running there is nothing to steer, and the message
646
+ // is released back to `waiting` when the run ends anyway - so it is answered as a turn of its own.
647
+ if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
648
+ continue;
649
+ const message = this.store.listWaitingMessages(conversationId)
650
+ .find((item) => item.delivery === "sending" && composePrompt(item) === text);
651
+ if (!message)
652
+ continue;
653
+ try {
654
+ await this.runtime.steer?.(conversationId, text, message.images);
655
+ }
656
+ catch (error) {
657
+ console.warn(`Super Pi: putting a steered message back failed: ${error instanceof Error ? error.message : "unknown error"}`);
658
+ }
659
+ }
660
+ }
544
661
  /**
545
662
  * Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
546
663
  *
@@ -825,18 +942,28 @@ export class RunService {
825
942
  * cleared rather than reassigned for the same reason. `thinkingBlock` continues numbering on purpose:
826
943
  * it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
827
944
  */
828
- const onSteerDelivered = () => {
829
- enqueueTools("a steer being delivered", async () => {
945
+ const onSteerDelivered = (text) => {
946
+ enqueueTools("a queued message being delivered", async () => {
830
947
  const previous = assistant;
831
- // Checkpoint first: the message being left behind is complete - Pi drains steering at a turn
948
+ // Checkpoint first: the message being left behind is complete - Pi drains its queues at a turn
832
949
  // boundary - and this is the last chance to write it durably with the lane it belongs to.
833
950
  await this.store.checkpointMessage(conversation.id, previous.id);
834
- const delivered = await this.store.deliverWaitingMessage(conversation.id, agentIdentity);
951
+ // WHICH MESSAGE THIS DELIVERY IS ABOUT. Pi announces the text it picked up, and Pi itself matches
952
+ // its own queue by that text (`agent-session.ts:1123`), so the same text resolves to the same
953
+ // message on both sides - including when two messages carry identical words, where both sides
954
+ // pick the first. A message handed to Pi's FOLLOW-UP queue was never claimed here: it is still
955
+ // `waiting`, so the user can still withdraw it, and a conversation can hold a `waiting` and a
956
+ // `sending` message at the same time. Position cannot answer that one; the text can.
957
+ //
958
+ // WHEN THE TEXT MATCHES NOTHING the old position-based answer is used, which is the steer path's
959
+ // proven behaviour: the message Pi is holding is the one Pi picked up. A text that matches
960
+ // nothing AND has no `sending` message is a delivery of a message Super Pi no longer has - one
961
+ // the user withdrew - and there is then nothing to open an answer to.
962
+ const target = this.matchDeliveredMessage(conversation.id, text);
963
+ const delivered = await this.store.deliverWaitingMessage(conversation.id, agentIdentity, target);
835
964
  // NOTHING TO DELIVER MEANS NOTHING TO SPLIT. Pi may announce a user `message_start` that this
836
965
  // server never handed it - the prompt that opened the turn is the obvious case - and inventing an
837
- // assistant message for one of those would open an empty bubble before every single turn. The
838
- // question "was anything `sending`?" is the server's own state, not something the event decides,
839
- // which is the same reason the delivery is matched by position rather than by text.
966
+ // assistant message for one of those would open an empty bubble before every single turn.
840
967
  if (!delivered)
841
968
  return [];
842
969
  const next = delivered.assistant;
@@ -978,6 +1105,13 @@ export class RunService {
978
1105
  for (;;) {
979
1106
  if (controller.signal.aborted)
980
1107
  break;
1108
+ // PI'S QUEUES ARE EMPTIED BEFORE THE NEXT MESSAGE IS TAKEN. A message handed to Pi's follow-up
1109
+ // queue is delivered by the agent loop inside the run that has just ended - Pi drains that queue
1110
+ // before it settles - so whatever is STILL in the queue when the turn ended was not delivered.
1111
+ // Emptying it gives those messages back: they are still in the waiting list, and the loop below
1112
+ // answers them with a turn of their own, which is the behaviour there was before Pi's queue was
1113
+ // used at all. Nothing is lost, and nothing is delivered twice.
1114
+ await this.clearPiQueues(conversation.id);
981
1115
  // Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
982
1116
  // every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
983
1117
  // server that dies between the two has a message that is neither waiting nor answered, and the
@@ -1045,6 +1179,10 @@ export class RunService {
1045
1179
  await this.store.markMessageWaiting(conversationId, message.id);
1046
1180
  this.broadcaster.broadcastState();
1047
1181
  }
1182
+ // The turn that is running answers this message itself, through Pi's own follow-up queue, rather
1183
+ // than by a turn of its own once the running one is over. The message stays in the waiting list
1184
+ // either way, so it is still the user's to withdraw - Pi is holding a copy, not the only copy.
1185
+ await this.submitToPiQueue(conversationId, message);
1048
1186
  return;
1049
1187
  }
1050
1188
  const controller = new AbortController();
@@ -1199,6 +1337,12 @@ export class RunService {
1199
1337
  try {
1200
1338
  if (this.activeRuns.has(conversationId))
1201
1339
  await this.stopRun(conversationId);
1340
+ // PI'S QUEUES ARE EMPTIED BEFORE THE SELECTED MESSAGE IS ANSWERED ON ITS OWN. The message may be
1341
+ // in Pi's follow-up queue - it was handed over when it was written - and Pi would deliver it when
1342
+ // the aborted run ended. The message is about to be answered as a turn of its own, so the queue
1343
+ // copy has to go: a message answered twice is worse than one answered once, and the transcript
1344
+ // would show the same words under two answers.
1345
+ await this.clearPiQueues(conversationId);
1202
1346
  await this.awaitRunSlotReleased(conversationId);
1203
1347
  await this.processWaitingMessage(conversationId, messageId);
1204
1348
  }
@@ -1300,6 +1444,12 @@ export class RunService {
1300
1444
  async cancelQueuedUnsafe(conversationId, messageId) {
1301
1445
  if (this.interruptTargets.get(conversationId) === messageId)
1302
1446
  throw new Error("这条消息正在打断并回答,无法撤回");
1447
+ // PI'S QUEUES ARE EMPTIED BEFORE THE MESSAGE IS TAKEN BACK. A message that was handed to Pi's
1448
+ // follow-up queue is in Pi's hands as well as in Super Pi's, and Pi would deliver it when the run
1449
+ // ends - the model would answer words the user believes they withdrew. Pi cannot take back one
1450
+ // message, so its queues are emptied; the other messages Pi was holding are still in Super Pi's
1451
+ // waiting list and are answered as turns of their own.
1452
+ await this.clearPiQueues(conversationId);
1303
1453
  await this.store.cancelWaitingMessage(conversationId, messageId);
1304
1454
  this.broadcaster.broadcastState();
1305
1455
  }
@@ -65,6 +65,10 @@ export class CommandHandler {
65
65
  await this.catalogService.setModelRules(command.rules);
66
66
  return;
67
67
  }
68
+ if (command.type === "settings.defaultModel") {
69
+ await this.catalogService.setDefaultModel(command.model);
70
+ return;
71
+ }
68
72
  if (command.type === "workspace.create") {
69
73
  return await this.workspaceService.createWorkspace(command.name, command.path);
70
74
  }
@@ -239,6 +243,14 @@ export class CommandHandler {
239
243
  await this.catalogService.broadcastCatalog(undefined, command.conversationId);
240
244
  return;
241
245
  }
246
+ // The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
247
+ // it is a panel's private list, and broadcasting it would overwrite every open conversation's
248
+ // filtered catalog with one that no workspace's rules apply to.
249
+ if (command.type === "catalog.fetchAll") {
250
+ if (sendToSocket)
251
+ sendToSocket({ type: "model.catalog.all", catalog: await this.catalogService.getAllCatalog() });
252
+ return;
253
+ }
242
254
  if (command.type === "agent.catalog.fetch") {
243
255
  await this.catalogService.broadcastAgents();
244
256
  return;
@@ -174,6 +174,31 @@ export interface PiSessionLike {
174
174
  images?: unknown[];
175
175
  streamingBehavior?: "steer" | "followUp";
176
176
  }): Promise<void>;
177
+ /**
178
+ * Queues a message to be answered AFTER the current run finishes its pending work.
179
+ *
180
+ * PI'S OWN FOLLOW-UP QUEUE, and the reason a queued message does not need a turn of its own. A
181
+ * `prompt()` runs `_prepareRun`, which emits `before_agent_start` - and Super Pi's memory extension
182
+ * answers that hook with an HTTP search whose result is patched into the system prompt. So a fresh
183
+ * prompt pays a round trip to the memory endpoint and a bigger prompt; a follow-up is delivered by
184
+ * the agent loop inside the run and pays neither.
185
+ *
186
+ * Optional because a test double has no queues; its absence means "this session cannot hold a
187
+ * message for later", and the caller falls back to answering it as a turn of its own.
188
+ */
189
+ followUp?(message: string, images?: unknown[]): Promise<"queued" | "handled">;
190
+ /**
191
+ * Removes every message Pi is holding in its steering and follow-up queues, and returns what was
192
+ * removed.
193
+ *
194
+ * PI CANNOT TAKE BACK ONE MESSAGE - `clear_queue` is the only queue command it has - so this is
195
+ * how a host says "none of these are wanted any more". The returned texts are the queue as it
196
+ * was, which is what lets a host put back the ones that should still be answered.
197
+ */
198
+ clearQueue?(): {
199
+ steering: string[];
200
+ followUp: string[];
201
+ };
177
202
  /** Aborts the current operation and waits for the session to become idle. */
178
203
  abort(): Promise<void>;
179
204
  /** Subscribes to session events. Returns the unsubscribe function. */
@@ -184,6 +209,15 @@ export interface PiSessionLike {
184
209
  readonly sessionFile?: string | undefined;
185
210
  /** The id of the session. */
186
211
  readonly sessionId?: string | undefined;
212
+ /**
213
+ * The system prompt Pi will send with every turn.
214
+ *
215
+ * READ FOR ITS SIZE, not for its content. The time to first token is a function of how many bytes
216
+ * the provider has to receive before it can answer, so this number is what turns "the model was
217
+ * slow" into a fact. Pi builds the prompt from the agent directory, the skills, the extensions and
218
+ * the context files - none of which Super Pi controls.
219
+ */
220
+ readonly systemPrompt?: string | undefined;
187
221
  /**
188
222
  * The model the session is running on.
189
223
  *
@@ -236,6 +236,32 @@ export interface AgentRuntime {
236
236
  */
237
237
  steer?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
238
238
  canSteer?(conversationId: string): boolean;
239
+ /**
240
+ * Hands one message to the Pi that is running RIGHT NOW, to be answered after the current run
241
+ * finishes its pending work.
242
+ *
243
+ * PI'S OWN FOLLOW-UP QUEUE, and the difference from a steer is the difference in WHEN. A steer is
244
+ * injected at the next turn boundary of the running turn; a follow-up waits until the agent would
245
+ * otherwise stop. Both are delivered by the agent loop inside the same run, which is what makes
246
+ * them cheap: neither goes through `_prepareRun`, so neither re-emits `before_agent_start` and
247
+ * neither pays for the memory search and the system-prompt patch that hook does.
248
+ *
249
+ * A message handed over this way is still Super Pi's to cancel, because Super Pi keeps it in its
250
+ * own waiting list - Pi is holding a copy, not the only copy.
251
+ */
252
+ followUp?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
253
+ /**
254
+ * Empties Pi's steering and follow-up queues, and returns what was in them.
255
+ *
256
+ * WHY A HOST NEEDS THIS. Pi cannot take back a single queued message, and it will deliver whatever
257
+ * is left in those queues when the run ends. So whenever Super Pi takes a message back - the user
258
+ * withdrew it, or the user asked for it to be answered on its own - the queues have to be emptied,
259
+ * or Pi answers words the user believes they took back.
260
+ */
261
+ clearQueues?(conversationId: string): Promise<{
262
+ steering: string[];
263
+ followUp: string[];
264
+ }>;
239
265
  /**
240
266
  * ONE-SHOT TITLE GENERATION. Spawns its own short-lived Pi child rather than borrowing a running one.
241
267
  *
@@ -966,6 +992,31 @@ export declare class PiRpcRuntime implements AgentRuntime {
966
992
  * silent success.
967
993
  */
968
994
  private sessionFor;
995
+ /**
996
+ * Hands a message to Pi's FOLLOW-UP queue, to be answered when the current run finishes its work.
997
+ *
998
+ * WHY THIS EXISTS ALONGSIDE A STEER. Both put a message into a queue Pi drains inside the running
999
+ * turn, and neither of them re-runs `_prepareRun` - so neither re-emits `before_agent_start`, and
1000
+ * neither pays for the memory search and the system-prompt patch that hook does. A queued message
1001
+ * that answered itself with a fresh `prompt()` paid for both, once per message, and the prompt it
1002
+ * sent was larger than the one before it.
1003
+ *
1004
+ * The difference between the two is WHEN Pi delivers the message: a steer lands at the next turn
1005
+ * boundary (in the middle of the answer, which is what 「插队」 means), and a follow-up lands when
1006
+ * the agent would otherwise stop (after the answer, which is what a message typed while the agent
1007
+ * was busy means).
1008
+ */
1009
+ followUp(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
1010
+ /**
1011
+ * Empties Pi's steering and follow-up queues and returns what was in them.
1012
+ *
1013
+ * The session path asks Pi directly (`session.clearQueue`). The one-shot path writes Pi's
1014
+ * `clear_queue` command, which answers with the text it removed.
1015
+ */
1016
+ clearQueues(conversationId: string): Promise<{
1017
+ steering: string[];
1018
+ followUp: string[];
1019
+ }>;
969
1020
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
970
1021
  /**
971
1022
  * The model/thinking catalog, from Pi, cached.
@@ -755,12 +755,29 @@ export class PiRpcRuntime {
755
755
  abortTurn();
756
756
  else
757
757
  signal.addEventListener("abort", onAbort, { once: true });
758
+ // HOW BIG IS THE PROMPT WE ARE ABOUT TO SEND. The time to first token is a function of
759
+ // this, not of anything Super Pi does, and it is the number that explains a slow first turn: the
760
+ // model's provider has to RECEIVE the whole thing before it can answer, so a big prompt on a slow
761
+ // link is seconds before the first word.
762
+ //
763
+ // It is traced rather than computed, because the system prompt is what Pi builds from the agent
764
+ // directory, the skills, the extensions and the context files - none of which Super Pi controls,
765
+ // and all of which the person who installed them controls.
766
+ const promptSize = () => {
767
+ try {
768
+ const system = typeof session.systemPrompt === "string" ? session.systemPrompt : "";
769
+ return `systemPrompt=${Buffer.byteLength(system, "utf8")}B`;
770
+ }
771
+ catch {
772
+ return "systemPrompt=?";
773
+ }
774
+ };
758
775
  let firstTextSeen = false;
759
776
  const finished = (async () => {
760
777
  try {
761
778
  const promptOptions = images?.length
762
- ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) }
763
- : { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) };
779
+ ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
780
+ : { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
764
781
  await session.prompt(prompt, promptOptions);
765
782
  // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
766
783
  // been ACCEPTED and the run has been started; the agent is still streaming, still executing
@@ -2080,6 +2097,76 @@ export class PiRpcRuntime {
2080
2097
  throw new SteerUnavailableError(`会话 ${conversationId} 还没有可用的 Pi 会话`);
2081
2098
  return session;
2082
2099
  }
2100
+ /**
2101
+ * Hands a message to Pi's FOLLOW-UP queue, to be answered when the current run finishes its work.
2102
+ *
2103
+ * WHY THIS EXISTS ALONGSIDE A STEER. Both put a message into a queue Pi drains inside the running
2104
+ * turn, and neither of them re-runs `_prepareRun` - so neither re-emits `before_agent_start`, and
2105
+ * neither pays for the memory search and the system-prompt patch that hook does. A queued message
2106
+ * that answered itself with a fresh `prompt()` paid for both, once per message, and the prompt it
2107
+ * sent was larger than the one before it.
2108
+ *
2109
+ * The difference between the two is WHEN Pi delivers the message: a steer lands at the next turn
2110
+ * boundary (in the middle of the answer, which is what 「插队」 means), and a follow-up lands when
2111
+ * the agent would otherwise stop (after the answer, which is what a message typed while the agent
2112
+ * was busy means).
2113
+ */
2114
+ async followUp(conversationId, message, images) {
2115
+ // THE SESSION PATH. `session.followUp` is Pi's own follow-up queue, so the message joins the run
2116
+ // that is already going and is delivered by the agent loop when that run has nothing left to do.
2117
+ if (this.activeTurns.has(conversationId)) {
2118
+ const session = await this.sessionFor(conversationId);
2119
+ const imagePayloads = this.options.uploadsDir && images?.length
2120
+ ? await loadConversationImages(this.options.uploadsDir, conversationId, images)
2121
+ : undefined;
2122
+ if (!session.followUp)
2123
+ throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 不支持排队消息`);
2124
+ const disposition = await session.followUp(message, imagePayloads);
2125
+ return disposition === "handled" ? "handled" : "queued";
2126
+ }
2127
+ // NO TURN TO FOLLOW. A follow-up is only meaningful while something is running - with nothing
2128
+ // running, Pi would hold the message until the NEXT run, which is the same answer arriving much
2129
+ // later than a prompt would have given it.
2130
+ //
2131
+ // THE ONE-SHOT PATH IS THIS CASE TOO, and it is not an accident: that path runs each turn in a
2132
+ // short-lived child that is killed when the turn ends, so a follow-up queue would die with it. The
2133
+ // message falls back to being answered by a turn of its own, which is the only thing that path can
2134
+ // do anyway.
2135
+ throw new SteerUnavailableError(`会话 ${conversationId} 当前没有可以接手的 Pi 会话,无法排队`);
2136
+ }
2137
+ /**
2138
+ * Empties Pi's steering and follow-up queues and returns what was in them.
2139
+ *
2140
+ * The session path asks Pi directly (`session.clearQueue`). The one-shot path writes Pi's
2141
+ * `clear_queue` command, which answers with the text it removed.
2142
+ */
2143
+ async clearQueues(conversationId) {
2144
+ const empty = { steering: [], followUp: [] };
2145
+ if (this.activeTurns.has(conversationId)) {
2146
+ const session = this.sessionPool.get(conversationId);
2147
+ if (!session?.clearQueue)
2148
+ return empty;
2149
+ try {
2150
+ const cleared = session.clearQueue();
2151
+ return { steering: [...(cleared?.steering ?? [])], followUp: [...(cleared?.followUp ?? [])] };
2152
+ }
2153
+ catch (error) {
2154
+ console.warn(`PiRpcRuntime: clearing the queues of ${conversationId} threw: ${error instanceof Error ? error.message : "unknown error"}`);
2155
+ return empty;
2156
+ }
2157
+ }
2158
+ const run = this.runs.get(conversationId);
2159
+ if (!run || !this.canSteer(conversationId))
2160
+ return empty;
2161
+ // THE ONE-SHOT PATH NEEDS NO CLEAR, and the reason is structural rather than convenient: that path
2162
+ // runs each turn in a short-lived child that is KILLED when the turn ends (`answerOneShot`'s
2163
+ // finally), and the child's queues die with it. A message Pi was holding in that child is gone the
2164
+ // moment the turn is over, so there is nothing to clear and nothing to wait for. Clearing it
2165
+ // anyway would cost a round trip to a child that is about to be killed, on the path between a turn
2166
+ // ending and the next one starting.
2167
+ void run;
2168
+ return empty;
2169
+ }
2083
2170
  async steer(conversationId, message, images) {
2084
2171
  // THE SESSION PATH. A steer on a persistent session is a `prompt` with `streamingBehavior:
2085
2172
  // "steer"` - the same queueing semantics Pi's RPC `steer` command has, expressed the way the SDK