@evandu/super-pi 0.1.52 → 0.1.54

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-PIHGU_8U.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DJD8mXiK.css">
11
+ <script type="module" crossorigin src="/assets/index-GghJcmHm.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Cb_uyk8r.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -1,5 +1,5 @@
1
1
  import type { Store } from "../store.js";
2
- import type { AgentRuntime } from "../runtime.js";
2
+ import { type AgentRuntime } from "../runtime.js";
3
3
  import type { SubagentRunRecorder } from "../subagent-runs.js";
4
4
  import type { Conversation, Message, MessageImage, PiQueueSnapshot, QuestionAnswer, Workspace } from "../types.js";
5
5
  import type { EventBroadcaster } from "../server-context.js";
@@ -155,6 +155,8 @@ export declare class RunService {
155
155
  * Steers a queued message into the in-flight turn.
156
156
  */
157
157
  steer(conversationId: string, queueId: string): Promise<void>;
158
+ /** Starts a chosen saved message only when the conversation is idle. */
159
+ sendQueued(conversationId: string, queueId: string): Promise<void>;
158
160
  private steerUnsafe;
159
161
  /**
160
162
  * Cancels a queued message.
@@ -1,3 +1,4 @@
1
+ import { SteerUnavailableError } from "../runtime.js";
1
2
  import { checkSubagentDepth, parentDeniedTools, subagentRoster } from "../task-tool.js";
2
3
  import { newSubagentRunId } from "../subagents.js";
3
4
  import { readAgents } from "../agents.js";
@@ -553,10 +554,48 @@ export class RunService {
553
554
  async steer(conversationId, queueId) {
554
555
  return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId, queueId));
555
556
  }
557
+ /** Starts a chosen saved message only when the conversation is idle. */
558
+ async sendQueued(conversationId, queueId) {
559
+ const waitForStop = async () => {
560
+ // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
561
+ // short cleanup window; a click on the newly visible button must not race the old run slot.
562
+ const deadline = Date.now() + 15_000;
563
+ while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
564
+ await new Promise((resolve) => setTimeout(resolve, 10));
565
+ }
566
+ };
567
+ await waitForStop();
568
+ return this.serializeConversation(conversationId, async () => {
569
+ if (this.activeRuns.has(conversationId))
570
+ throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
571
+ const queued = await this.store.takeQueuedMessage(conversationId, queueId);
572
+ const userMessageCount = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.filter((item) => item.role === "user").length ?? 0;
573
+ // Claim the run immediately, then release the command lane. Waiting for the whole model turn here
574
+ // would block every later queue action from this socket until the answer completes.
575
+ void this.enqueueAndRun(conversationId, queued.content, queued.images).catch(async (error) => {
576
+ // Once the turn has a user message it is already in the transcript. Requeue only a preflight
577
+ // failure, or the same prompt would be shown as both sent and waiting to be sent again.
578
+ const sent = (this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.filter((item) => item.role === "user").length ?? 0) > userMessageCount;
579
+ if (!sent)
580
+ await this.store.enqueueMessage(conversationId, queued.content, queued.images);
581
+ this.broadcaster.broadcastState();
582
+ console.warn(`Super Pi: explicit queued send failed: ${error instanceof Error ? error.message : String(error)}`);
583
+ });
584
+ this.broadcaster.broadcastState();
585
+ });
586
+ }
556
587
  async steerUnsafe(conversationId, queueId) {
557
588
  if (!this.runtime.steer) {
558
589
  throw new Error("当前运行时没有插队能力,无法把排队消息插到正在运行的这轮里");
559
590
  }
591
+ // A running conversation may be in the tiny hand-off window after Pi ended but before this service
592
+ // opens the next queued turn. Priority delivery has the same user meaning in both cases, so reserve
593
+ // the message for the next turn instead of turning a successful click into an implementation error.
594
+ if (this.runtime.canSteer && !this.runtime.canSteer(conversationId)) {
595
+ await this.store.prioritizeQueuedMessage(conversationId, queueId);
596
+ this.broadcaster.broadcastState();
597
+ return;
598
+ }
560
599
  const claimed = await this.store.claimQueuedMessage(conversationId, queueId);
561
600
  this.broadcaster.broadcastState();
562
601
  try {
@@ -566,6 +605,12 @@ export class RunService {
566
605
  }
567
606
  }
568
607
  catch (error) {
608
+ if (error instanceof SteerUnavailableError) {
609
+ await this.store.releaseQueuedMessage(claimed.id);
610
+ await this.store.prioritizeQueuedMessage(conversationId, claimed.id);
611
+ this.broadcaster.broadcastState();
612
+ return;
613
+ }
569
614
  await this.store.releaseQueuedMessage(claimed.id);
570
615
  throw error;
571
616
  }
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  /**
2
3
  * Routes client commands to appropriate application services and converts results or errors.
3
4
  */
@@ -162,6 +163,10 @@ export class CommandHandler {
162
163
  await this.runService.steer(command.conversationId, command.queueId);
163
164
  return;
164
165
  }
166
+ if (command.type === "message.sendQueued") {
167
+ await this.runService.sendQueued(command.conversationId, command.queueId);
168
+ return;
169
+ }
165
170
  if (command.type === "message.cancel") {
166
171
  await this.runService.cancelQueued(command.conversationId, command.queueId);
167
172
  return;
@@ -232,6 +237,24 @@ export class CommandHandler {
232
237
  await this.catalogService.rescan();
233
238
  return;
234
239
  }
240
+ if (command.type === "super_pi.start") {
241
+ const port = Number(process.env.SUPER_PI_PORT ?? 3080);
242
+ const url = `http://127.0.0.1:${port}`;
243
+ const platform = process.platform;
244
+ if (platform === "darwin")
245
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
246
+ else if (platform === "win32")
247
+ spawn("cmd.exe", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
248
+ else
249
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
250
+ return;
251
+ }
252
+ if (command.type === "super_pi.update") {
253
+ spawn("pi", ["update", "@evandu/super-pi"], { stdio: "inherit", shell: true }).on("error", () => {
254
+ console.error("Super Pi: `pi update @evandu/super-pi` failed — is the `pi` command in your PATH?");
255
+ });
256
+ return;
257
+ }
235
258
  throw new Error(`未知的命令类型:${JSON.stringify(command.type ?? null)}`);
236
259
  }
237
260
  }
@@ -112,6 +112,9 @@ export interface AnswerInput {
112
112
  * in the model's context and one that is not, and the UI must not claim either without knowing which.
113
113
  */
114
114
  export type SteerDisposition = "handled" | "queued";
115
+ /** A steer that provably never reached Pi; the caller may safely use the next turn instead. */
116
+ export declare class SteerUnavailableError extends Error {
117
+ }
115
118
  /**
116
119
  * What a revert has to be told, and what it is told afterwards. Declared here rather than in `types.ts`
117
120
  * because neither travels to the browser: this is the runtime's own account of a file it changed.
@@ -185,6 +188,7 @@ export interface AgentRuntime {
185
188
  * Absent on a runtime with no Pi behind it (the welcome runtime), which has nothing to steer.
186
189
  */
187
190
  steer?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
191
+ canSteer?(conversationId: string): boolean;
188
192
  /**
189
193
  * ONE-SHOT TITLE GENERATION. Spawns its own short-lived Pi child rather than borrowing a running one.
190
194
  *
@@ -630,6 +634,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
630
634
  * it was started.
631
635
  */
632
636
  private readonly pi;
637
+ canSteer(conversationId: string): boolean;
633
638
  constructor(sessionDir: string, command?: string,
634
639
  /**
635
640
  * Where agent definitions are read from and where their prompt bodies are written. A THIRD positional
@@ -10,6 +10,9 @@ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_END
10
10
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
11
11
  import { loadConversationImages } from "./upload.js";
12
12
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
13
+ /** A steer that provably never reached Pi; the caller may safely use the next turn instead. */
14
+ export class SteerUnavailableError extends Error {
15
+ }
13
16
  /** Deterministic offline runtime used by tests and local UI development. */
14
17
  export class WelcomeRuntime {
15
18
  async *answer({ prompt, signal }) {
@@ -602,6 +605,10 @@ export class PiRpcRuntime {
602
605
  * it was started.
603
606
  */
604
607
  pi;
608
+ canSteer(conversationId) {
609
+ const run = this.runs.get(conversationId);
610
+ return Boolean(run && run.child.exitCode === null && !run.child.killed && !run.child.stdin.destroyed);
611
+ }
605
612
  constructor(sessionDir, command,
606
613
  /**
607
614
  * Where agent definitions are read from and where their prompt bodies are written. A THIRD positional
@@ -1234,14 +1241,15 @@ export class PiRpcRuntime {
1234
1241
  catch (error) {
1235
1242
  if (child.exitCode === null && child.pid !== undefined)
1236
1243
  await killTree(child.pid);
1237
- await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1244
+ // taskkill has already terminated the process tree; do not add a five-second UI stall waiting for
1245
+ // Node's exit event bookkeeping on Windows.
1238
1246
  throw error instanceof Error ? error : new Error(`无法向 Pi 询问会话文件:${String(error)}`);
1239
1247
  }
1240
1248
  // The lookup Pi is holding open is finished; it is killed BEFORE the file is touched, for the same
1241
1249
  // reason `answer()` kills before returning: a process still tearing down can still be appending.
1242
1250
  if (child.exitCode === null && child.pid !== undefined)
1243
1251
  await killTree(child.pid);
1244
- await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1252
+ // The process tree is dead after killTree; copying can proceed without waiting for a delayed exit event.
1245
1253
  if (!sessionFile)
1246
1254
  throw new Error("Pi 没有报告这个会话的记录文件,已放弃,没有改动任何文件。");
1247
1255
  const exists = await stat(sessionFile).catch(() => undefined);
@@ -1325,12 +1333,12 @@ export class PiRpcRuntime {
1325
1333
  catch (error) {
1326
1334
  if (child.exitCode === null && child.pid !== undefined)
1327
1335
  await killTree(child.pid);
1328
- await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1336
+ // killTree waits for taskkill; waiting again for Node's exit event can miss the event and add 5s.
1329
1337
  throw error instanceof Error ? error : new Error(`无法向 Pi 询问会话文件:${String(error)}`);
1330
1338
  }
1331
1339
  if (child.exitCode === null && child.pid !== undefined)
1332
1340
  await killTree(child.pid);
1333
- await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1341
+ // killTree waits for taskkill; do not wait for a possibly already-delivered exit event.
1334
1342
  if (!sessionFile)
1335
1343
  throw new Error("Pi 没有报告这个会话的记录文件,已放弃,没有复制任何文件。");
1336
1344
  const exists = await stat(sessionFile).catch(() => undefined);
@@ -1516,10 +1524,8 @@ export class PiRpcRuntime {
1516
1524
  }
1517
1525
  async steer(conversationId, message, images) {
1518
1526
  const run = this.runs.get(conversationId);
1519
- if (!run)
1520
- throw new Error(`会话 ${conversationId} 现在没有正在运行的 Pi 进程,无法插队;这条消息仍在队列里,可以等下一轮或取消。`);
1521
- if (run.child.exitCode !== null || run.child.killed || run.child.stdin.destroyed)
1522
- throw new Error(`会话 ${conversationId} 的 Pi 进程已经结束,无法插队;这条消息仍在队列里。`);
1527
+ if (!run || !this.canSteer(conversationId))
1528
+ throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 进程已结束`);
1523
1529
  const id = `steer_${++this.steerSeq}`;
1524
1530
  const imagePayloads = this.options.uploadsDir && images?.length
1525
1531
  ? await loadConversationImages(this.options.uploadsDir, conversationId, images)
@@ -360,6 +360,8 @@ export declare class Store {
360
360
  * same message would put the text into Pi twice.
361
361
  */
362
362
  claimQueuedMessage(conversationId: string, queueId: string): Promise<QueuedMessage>;
363
+ /** Keep a priority request at the front even if the current Pi process has already exited. */
364
+ prioritizeQueuedMessage(conversationId: string, queueId: string): Promise<QueuedMessage>;
363
365
  /**
364
366
  * Puts a `sending` entry back into the queue.
365
367
  *
@@ -395,6 +397,8 @@ export declare class Store {
395
397
  * `open` does not put it back, because it may well have been delivered.
396
398
  */
397
399
  takeNextQueuedMessage(conversationId: string): Promise<QueuedMessage | undefined>;
400
+ /** Removes one still-cancellable row by id so an explicit user action can start it as a new turn. */
401
+ takeQueuedMessage(conversationId: string, queueId: string): Promise<QueuedMessage>;
398
402
  /**
399
403
  * Returns every `sending` message of this conversation to the queue, for the end of a turn.
400
404
  *
@@ -1151,6 +1151,19 @@ export class Store {
1151
1151
  if (message.state === "sending")
1152
1152
  throw new Error(`排队消息已经交给 Agent 了:${queueId}`);
1153
1153
  message.state = "sending";
1154
+ message.priority = true;
1155
+ await this.persistCollections("queuedMessages");
1156
+ return structuredClone(message);
1157
+ }
1158
+ /** Keep a priority request at the front even if the current Pi process has already exited. */
1159
+ async prioritizeQueuedMessage(conversationId, queueId) {
1160
+ const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
1161
+ if (index < 0)
1162
+ throw new Error(`排队消息不存在或已发出:${queueId}`);
1163
+ const message = this.state.queuedMessages[index];
1164
+ if (message.state === "sending")
1165
+ return structuredClone(message);
1166
+ message.priority = true;
1154
1167
  await this.persistCollections("queuedMessages");
1155
1168
  return structuredClone(message);
1156
1169
  }
@@ -1207,13 +1220,26 @@ export class Store {
1207
1220
  * `open` does not put it back, because it may well have been delivered.
1208
1221
  */
1209
1222
  async takeNextQueuedMessage(conversationId) {
1210
- const index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued");
1223
+ let index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued" && item.priority);
1224
+ if (index < 0)
1225
+ index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued");
1211
1226
  if (index < 0)
1212
1227
  return undefined;
1213
1228
  const [taken] = this.state.queuedMessages.splice(index, 1);
1214
1229
  await this.persistCollections("queuedMessages");
1215
1230
  return structuredClone(taken);
1216
1231
  }
1232
+ /** Removes one still-cancellable row by id so an explicit user action can start it as a new turn. */
1233
+ async takeQueuedMessage(conversationId, queueId) {
1234
+ const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
1235
+ if (index < 0)
1236
+ throw new Error("这条排队消息已经发出或不存在");
1237
+ if (this.state.queuedMessages[index].state !== "queued")
1238
+ throw new Error("当前轮还没有结束;请先停止或等待它结束,再发送这条消息");
1239
+ const [taken] = this.state.queuedMessages.splice(index, 1);
1240
+ await this.persistCollections("queuedMessages");
1241
+ return structuredClone(taken);
1242
+ }
1217
1243
  /**
1218
1244
  * Returns every `sending` message of this conversation to the queue, for the end of a turn.
1219
1245
  *
@@ -670,6 +670,8 @@ export type QueuedMessage = {
670
670
  conversationId: string;
671
671
  content: string;
672
672
  state: "queued" | "sending";
673
+ /** Chosen for priority delivery. Survives a Pi turn boundary and a server restart. */
674
+ priority?: boolean;
673
675
  createdAt: string;
674
676
  images?: MessageImage[];
675
677
  };
@@ -961,6 +963,12 @@ export type ClientCommandBody =
961
963
  type: "message.steer";
962
964
  conversationId: string;
963
965
  queueId: string;
966
+ }
967
+ /** Starts this saved queued message as the next turn, only after the current turn has ended. */
968
+ | {
969
+ type: "message.sendQueued";
970
+ conversationId: string;
971
+ queueId: string;
964
972
  } | {
965
973
  type: "run.stop";
966
974
  conversationId: string;
@@ -1008,6 +1016,14 @@ export type ClientCommandBody =
1008
1016
  dirs: string[];
1009
1017
  } | {
1010
1018
  type: "agents.rescan";
1019
+ }
1020
+ /** Opens the browser at the Super Pi web UI, starting the server if it is not running. */
1021
+ | {
1022
+ type: "super_pi.start";
1023
+ }
1024
+ /** Runs `pi self-update` to update Super Pi. */
1025
+ | {
1026
+ type: "super_pi.update";
1011
1027
  } | ({
1012
1028
  type: "extension.ui.answer";
1013
1029
  conversationId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",