@evandu/super-pi 0.1.90 → 0.1.92

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-p1BjYE_T.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DgQRMp3j.css">
11
+ <script type="module" crossorigin src="/assets/index-DxMdKg12.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Db0poZWq.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,9 +37,35 @@ export class ConversationService {
37
37
  const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
38
38
  if (!workspace)
39
39
  throw new Error("Workspace not found");
40
- const defaults = workspace.defaultAgentId
41
- ? await this.resolveAgentConfiguration({ id: workspaceId, model: workspace.defaultModel, thinkingLevel: workspace.defaultThinkingLevel }, workspace.defaultAgentId, workspaceId)
42
- : undefined;
40
+ // Workspace defaults are copied at birth, but Pi's model list can change after the
41
+ // setting was saved (for example when a model is removed from Pi's config). Do not
42
+ // stamp a dead selection onto every new conversation: the scoped catalog probe would
43
+ // fail and leave the model/thinking controls unavailable. A missing or invalid default
44
+ // means "follow Pi's current default" for this newly-created conversation.
45
+ let model = workspace.defaultModel;
46
+ let thinkingLevel = workspace.defaultThinkingLevel;
47
+ const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
48
+ if (catalog?.source === "pi" && model) {
49
+ const option = catalog.models.find(item => item.provider === model?.provider && item.modelId === model?.modelId);
50
+ if (!option) {
51
+ model = undefined;
52
+ thinkingLevel = undefined;
53
+ }
54
+ else {
55
+ model = { provider: option.provider, modelId: option.modelId, label: option.label };
56
+ if (thinkingLevel) {
57
+ const scoped = await this.runtime.catalog(model);
58
+ if (scoped.source === "pi" && !scoped.levels.includes(thinkingLevel))
59
+ thinkingLevel = undefined;
60
+ }
61
+ }
62
+ }
63
+ const resolved = workspace.defaultAgentId
64
+ ? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
65
+ : { model, thinkingLevel };
66
+ // `null` is intentional here: Store uses it to remove a workspace value that it
67
+ // would otherwise have copied before applying the resolved configuration.
68
+ const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null };
43
69
  const created = await this.store.createConversation(workspaceId, defaults);
44
70
  this.broadcaster.broadcastState();
45
71
  return created.id;
@@ -239,6 +239,15 @@ export declare class RunService {
239
239
  steer(conversationId: string, messageId: string): Promise<void>;
240
240
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
241
241
  sendQueued(conversationId: string, messageId: string): Promise<void>;
242
+ /**
243
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
244
+ *
245
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
246
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
247
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
248
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
249
+ */
250
+ interruptQueued(conversationId: string, messageId: string): Promise<void>;
242
251
  /**
243
252
  * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
244
253
  *
@@ -31,7 +31,7 @@ function formatTurnFailure(error) {
31
31
  // PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
32
32
  // answering them a moment ago and now the model will not let it. The action is "wait or change
33
33
  // model"; the wording has to make that obvious from the banner alone, without a tooltip.
34
- if (/quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
34
+ if (/\b429\b|quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
35
35
  return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
36
36
  }
37
37
  // RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
@@ -542,6 +542,22 @@ export class RunService {
542
542
  let assistant = assistantMessage;
543
543
  let toolCalls = [];
544
544
  let pendingTools = Promise.resolve();
545
+ // A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
546
+ // OR a tool-call notice. The `running` state means "we have data flowing", and that is true the
547
+ // moment Pi yields anything back, regardless of shape. Without this, a pure tool-call turn would
548
+ // stay in `waiting` until the very end and the reader would see a stuck turn for the whole of a
549
+ // long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
550
+ // every delta would be a per-token status write.
551
+ let firstSignal = true;
552
+ const promoteToRunning = async () => {
553
+ if (!firstSignal)
554
+ return;
555
+ firstSignal = false;
556
+ if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
557
+ await this.store.setStatus(conversation.id, "running");
558
+ this.broadcaster.broadcastState();
559
+ }
560
+ };
545
561
  // Live events are independent from durable checkpoints. Persisting every token made the stdout
546
562
  // reader wait for one disk transaction per token; over a remote browser connection that backlog
547
563
  // looked exactly like a slow model. Keep the in-memory transcript current, publish immediately, and
@@ -570,6 +586,12 @@ export class RunService {
570
586
  };
571
587
  const enqueueTools = (what, work) => {
572
588
  pendingTools = pendingTools.then(async () => {
589
+ // A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
590
+ // path as text deltas: a tool row appearing on screen is the same "we have data" event
591
+ // the status needs to reflect. Doing it BEFORE the tool write keeps the order
592
+ // honest - a client that saw the new tool row in the same frame also saw the running
593
+ // status, instead of flashing back to a still-spinning waiting mark for the next paint.
594
+ await promoteToRunning();
573
595
  toolCalls = await work();
574
596
  this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
575
597
  }).catch((error) => {
@@ -853,6 +875,12 @@ export class RunService {
853
875
  // publish a new message underneath it. One `await` at the top covers both, and costs nothing:
854
876
  // the lane is empty most of the time.
855
877
  await pendingTools;
878
+ // `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
879
+ // opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
880
+ // and the first response token is the observable fact that says the LLM is actually
881
+ // answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
882
+ // after the first signal so this stays a one-shot status write, not a per-token one.
883
+ await promoteToRunning();
856
884
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
857
885
  spoken += delta.length;
858
886
  // Tool events are structural transcript boundaries. Publish any boundary Pi announced before
@@ -1088,6 +1116,19 @@ export class RunService {
1088
1116
  }
1089
1117
  await this.processWaitingMessage(conversationId, messageId);
1090
1118
  }
1119
+ /**
1120
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
1121
+ *
1122
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
1123
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
1124
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
1125
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
1126
+ */
1127
+ async interruptQueued(conversationId, messageId) {
1128
+ if (this.activeRuns.has(conversationId))
1129
+ await this.stopRun(conversationId);
1130
+ await this.sendQueued(conversationId, messageId);
1131
+ }
1091
1132
  /**
1092
1133
  * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
1093
1134
  *
@@ -1115,7 +1156,10 @@ export class RunService {
1115
1156
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1116
1157
  }
1117
1158
  catch (error) {
1118
- await this.store.setStatus(conversationId, "failed");
1159
+ await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
1160
+ if (!controller.signal.aborted) {
1161
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1162
+ }
1119
1163
  // A preflight failure must put the message BACK where the reader can see it. Re-adding it would
1120
1164
  // duplicate the row that is still in the transcript, so this only re-marks the one that is
1121
1165
  // there - which is what makes "the send failed, this row is still in the queue" a true statement.
@@ -169,6 +169,10 @@ export class CommandHandler {
169
169
  await this.runService.steer(command.conversationId, command.messageId);
170
170
  return;
171
171
  }
172
+ if (command.type === "message.interrupt") {
173
+ await this.runService.interruptQueued(command.conversationId, command.messageId);
174
+ return;
175
+ }
172
176
  if (command.type === "message.sendQueued") {
173
177
  await this.runService.sendQueued(command.conversationId, command.messageId);
174
178
  return;
@@ -29,9 +29,18 @@ export type RpcRecord = {
29
29
  role?: string;
30
30
  content?: unknown;
31
31
  stopReason?: string;
32
+ errorMessage?: string;
32
33
  model?: string;
33
34
  };
34
35
  };
36
+ /** `agent_end` is Pi's terminal result for one attempt. `willRetry` keeps a transient provider failure from becoming a terminal failure. */
37
+ messages?: Array<{
38
+ role?: string;
39
+ content?: unknown;
40
+ stopReason?: string;
41
+ errorMessage?: string;
42
+ }>;
43
+ willRetry?: boolean;
35
44
  /**
36
45
  * Cumulative usage of the CURRENT assistant message, repeated on every one of that message's updates
37
46
  * (modes/json-event.ts:56-59). Reading it anywhere but on `done` counts the same tokens per token.
@@ -588,6 +588,27 @@ export class PiRpcRuntime {
588
588
  channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
589
589
  traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
590
590
  let settled = false;
591
+ let finalAgentOutcome;
592
+ let finalDoneOutcome;
593
+ const terminalOutcome = (message, reason) => {
594
+ if (!message?.stopReason && !message?.errorMessage && !reason)
595
+ return undefined;
596
+ return { reason, stopReason: message?.stopReason, errorMessage: message?.errorMessage };
597
+ };
598
+ const throwIfTerminalFailure = () => {
599
+ if (signal.aborted)
600
+ return;
601
+ const outcome = finalAgentOutcome ?? finalDoneOutcome;
602
+ if (!outcome)
603
+ throw new Error("Pi 已结束本轮运行,但没有报告最终结果。");
604
+ const reason = outcome.stopReason ?? outcome.reason;
605
+ if (reason === "error")
606
+ throw new Error(outcome.errorMessage || "Pi 报告模型或服务提供方错误。");
607
+ if (reason === "aborted")
608
+ throw new Error(outcome.errorMessage || "Pi 意外中止了本轮回答。");
609
+ if (reason === "length")
610
+ throw new Error(outcome.errorMessage || "模型输出达到本轮长度上限,回答未完成。");
611
+ };
591
612
  let firstPiRecord = true;
592
613
  // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
593
614
  // session events (tool_call, thinking, compaction_*, auto_retry_*, ...), so an unhandled
@@ -774,6 +795,9 @@ export class PiRpcRuntime {
774
795
  }
775
796
  if (record.type === "message_update") {
776
797
  const event = record.assistantMessageEvent;
798
+ if (event?.type === "done") {
799
+ finalDoneOutcome = terminalOutcome(event.message, event.reason);
800
+ }
777
801
  // text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
778
802
  // call VISIBLE WHILE IT IS BEING WRITTEN.
779
803
  //
@@ -854,6 +878,15 @@ export class PiRpcRuntime {
854
878
  }
855
879
  continue;
856
880
  }
881
+ if (record.type === "agent_end") {
882
+ const message = [...(record.messages ?? [])].reverse().find((item) => item.role === "assistant");
883
+ const outcome = terminalOutcome(message);
884
+ // Pi emits `agent_end` for every attempt. A retryable error is deliberately only observed
885
+ // here; Pi owns the retry and will replace this with the final attempt before it settles.
886
+ if (record.willRetry !== true && outcome)
887
+ finalAgentOutcome = outcome;
888
+ continue;
889
+ }
857
890
  if (record.type === "extension_ui_request") {
858
891
  await this.handleExtensionUi(record, child, host, ui);
859
892
  continue;
@@ -868,6 +901,9 @@ export class PiRpcRuntime {
868
901
  }
869
902
  if (record.type === "agent_settled") {
870
903
  traceLatency(conversation.id, "pi.settled", latencyStartedAt);
904
+ // Only now is it safe to inspect a provider error: before this point Pi may still be
905
+ // automatically retrying a 429, timeout, or temporary upstream failure.
906
+ throwIfTerminalFailure();
871
907
  settled = true;
872
908
  continue;
873
909
  }
@@ -107,8 +107,8 @@ export declare class Store {
107
107
  deleteWorkspace(workspaceId: string): Promise<string[]>;
108
108
  createConversation(workspaceId: string, configuration?: {
109
109
  agentId?: string;
110
- model?: ModelSelection;
111
- thinkingLevel?: string;
110
+ model?: ModelSelection | null;
111
+ thinkingLevel?: string | null;
112
112
  }): Promise<Conversation>;
113
113
  /**
114
114
  * Copies a conversation into a NEW one, under an id the caller has already minted.
@@ -549,9 +549,10 @@ export declare class Store {
549
549
  * question that is still waiting - a transcript lying about its own order. Splicing at
550
550
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
551
551
  *
552
- * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
552
+ * The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
553
553
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
554
- * waiting. They belong here, at the moment the turn genuinely begins.
554
+ * waiting. They belong here, at the moment the turn genuinely begins - the model still has not
555
+ * returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
555
556
  */
556
557
  openTurnOnMessage(conversationId: string, messageId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
557
558
  user: Message;
@@ -135,7 +135,11 @@ function reconcileConversations(state) {
135
135
  state.conversations = conversations;
136
136
  const orphaned = [];
137
137
  for (const conversation of conversations) {
138
- if (!conversation || conversation.status !== "running")
138
+ // `waiting` joins the orphan set for the same reason `running` does: the turn was opened (a prompt
139
+ // was sent) and the server died before any settled state was recorded. A conversation that was in
140
+ // either of those two states when the process went away has a half-open turn whose outcome nobody
141
+ // can determine from the file alone - exactly the situation this sweep exists to remove.
142
+ if (!conversation || (conversation.status !== "running" && conversation.status !== "waiting"))
139
143
  continue;
140
144
  conversation.status = "failed";
141
145
  conversation.error = ORPHANED_CONVERSATION_ERROR;
@@ -690,10 +694,18 @@ export class Store {
690
694
  if (workspace.defaultAgentId)
691
695
  conversation.agentId = workspace.defaultAgentId;
692
696
  if (configuration) {
693
- if (configuration.model)
694
- conversation.model = { ...configuration.model };
695
- if (configuration.thinkingLevel)
696
- conversation.thinkingLevel = configuration.thinkingLevel;
697
+ if (configuration.model !== undefined) {
698
+ if (configuration.model)
699
+ conversation.model = { ...configuration.model };
700
+ else
701
+ delete conversation.model;
702
+ }
703
+ if (configuration.thinkingLevel !== undefined) {
704
+ if (configuration.thinkingLevel)
705
+ conversation.thinkingLevel = configuration.thinkingLevel;
706
+ else
707
+ delete conversation.thinkingLevel;
708
+ }
697
709
  if (configuration.agentId)
698
710
  conversation.agentId = configuration.agentId;
699
711
  }
@@ -876,8 +888,13 @@ export class Store {
876
888
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
877
889
  if (!conversation.titleLocked)
878
890
  conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
891
+ // A TURN STARTS IN `waiting`, NOT `running`. The first delta from the model is what flips the
892
+ // conversation to `running` (see `RunService.streamTurn`); opening a turn only proves a prompt was
893
+ // sent, and a prompt that was sent but never answered looks identical to one that is mid-stream
894
+ // until something tells them apart. Holding the gap open is what makes a stuck LLM endpoint
895
+ // visible to a person - the `waiting` mark on the sidebar row is the only honest signal.
879
896
  conversation.updatedAt = message.createdAt;
880
- conversation.status = "running";
897
+ conversation.status = "waiting";
881
898
  // The reason this conversation was failed - startup reconciliation's note that a previous run was cut
882
899
  // short - describes THAT run, and this line is the moment it stops being the current one. Deleting the
883
900
  // key rather than overwriting it keeps the file byte-identical to a conversation that was never
@@ -1610,9 +1627,10 @@ export class Store {
1610
1627
  * question that is still waiting - a transcript lying about its own order. Splicing at
1611
1628
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
1612
1629
  *
1613
- * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
1630
+ * The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
1614
1631
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
1615
- * waiting. They belong here, at the moment the turn genuinely begins.
1632
+ * waiting. They belong here, at the moment the turn genuinely begins - the model still has not
1633
+ * returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
1616
1634
  */
1617
1635
  async openTurnOnMessage(conversationId, messageId, agentIdentity) {
1618
1636
  const conversation = this.requireConversation(conversationId);
@@ -1629,8 +1647,10 @@ export class Store {
1629
1647
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
1630
1648
  if (!conversation.titleLocked)
1631
1649
  conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
1650
+ // Same `waiting` reasoning as `addUserMessage` above - the prompt was just handed off and no
1651
+ // token has come back yet, which is a different observable state from "we have data flowing".
1632
1652
  conversation.updatedAt = user.createdAt;
1633
- conversation.status = "running";
1653
+ conversation.status = "waiting";
1634
1654
  delete conversation.error;
1635
1655
  await this.persistMessages(conversation);
1636
1656
  return { user: structuredClone(user), assistant: structuredClone(assistant) };
@@ -396,7 +396,19 @@ export class SubagentSupervisor {
396
396
  if (signal?.aborted)
397
397
  throw new SubagentCancelled(id);
398
398
  for (;;) {
399
- const record = await channel.next(DRAIN_POLL_MS);
399
+ let record;
400
+ try {
401
+ record = await channel.next(DRAIN_POLL_MS);
402
+ }
403
+ catch (error) {
404
+ // RpcChannel surfaces EOF immediately so a caller cannot mistake a dead Pi for an
405
+ // idle one. Preserve this supervisor's richer contract: the agent identity, phase
406
+ // and exit code are what make an unexpected child exit actionable to the user.
407
+ if (error?.code === "CHANNEL_CLOSED") {
408
+ throw new Error(`sub-agent ${agentId} (${id}) exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
409
+ }
410
+ throw error;
411
+ }
400
412
  if (!record)
401
413
  break;
402
414
  if (record.type === "response" && record.id === "prompt" && record.success === false)
@@ -5,7 +5,19 @@ export type ModelFilterRule = {
5
5
  label?: string;
6
6
  };
7
7
  import type { WorkflowRun } from "./workflow.js";
8
- export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
8
+ /**
9
+ * The lifecycle states a `Conversation.status` moves through while a turn is in flight. `waiting` is the
10
+ * gap between "prompt handed to the LLM" and "the first response token arrived" - holding it apart from
11
+ * `running` is what lets the reader tell a slow or broken LLM endpoint (still waiting) apart from a
12
+ * streaming turn that is just slow (already running, just no new tokens in the last second). Without
13
+ * the gap the two states look the same on screen for as long as the model is silent, and the reader has
14
+ * no signal to debug from.
15
+ *
16
+ * `completed` / `stopped` / `failed` are the three settled states. `stopped` is what `run.stop` lands;
17
+ * `failed` is what any unhandled throw lands (with `Conversation.error` carrying the translated reason);
18
+ * `completed` is the clean path.
19
+ */
20
+ export type RunStatus = "idle" | "waiting" | "running" | "completed" | "stopped" | "failed";
9
21
  export type MessageRole = "user" | "assistant" | "tool" | "system";
10
22
  /**
11
23
  * One message in `state.json`.
@@ -1078,6 +1090,12 @@ export type ClientCommandBody =
1078
1090
  conversationId: string;
1079
1091
  messageId: string;
1080
1092
  }
1093
+ /** Stops the active turn, then starts this waiting message as a fresh turn. */
1094
+ | {
1095
+ type: "message.interrupt";
1096
+ conversationId: string;
1097
+ messageId: string;
1098
+ }
1081
1099
  /** Starts this waiting message as the next turn, only after the current turn has ended. */
1082
1100
  | {
1083
1101
  type: "message.sendQueued";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",