@evandu/super-pi 0.1.89 → 0.1.91

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.
@@ -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;
@@ -358,9 +362,16 @@ function reconcileWaitingMessages(state) {
358
362
  const recovered = [];
359
363
  for (const conversation of state.conversations) {
360
364
  for (const message of conversation.messages) {
361
- // A `delivery` on anything but a user message is a broken file: no code path writes one, and
362
- // rendering a waiting state on an assistant bubble would be a claim about nothing.
363
- if (message.role !== "user") {
365
+ // A `delivery` on an ASSISTANT message is still a broken file: rendering a waiting state on an
366
+ // assistant bubble would be a claim about nothing, and nothing in the code path writes one. Tool
367
+ // messages with `delivery` are queue items now (exec / sub-agent background completions) - if they
368
+ // died in `sending`, recover them to `waiting` just like user messages, so a crashed server does
369
+ // not strand a 后台回传 whose delivery it had already accepted.
370
+ if (message.role === "assistant") {
371
+ delete message.delivery;
372
+ continue;
373
+ }
374
+ if (message.role !== "user" && message.role !== "tool") {
364
375
  delete message.delivery;
365
376
  continue;
366
377
  }
@@ -683,10 +694,18 @@ export class Store {
683
694
  if (workspace.defaultAgentId)
684
695
  conversation.agentId = workspace.defaultAgentId;
685
696
  if (configuration) {
686
- if (configuration.model)
687
- conversation.model = { ...configuration.model };
688
- if (configuration.thinkingLevel)
689
- 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
+ }
690
709
  if (configuration.agentId)
691
710
  conversation.agentId = configuration.agentId;
692
711
  }
@@ -869,8 +888,13 @@ export class Store {
869
888
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
870
889
  if (!conversation.titleLocked)
871
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.
872
896
  conversation.updatedAt = message.createdAt;
873
- conversation.status = "running";
897
+ conversation.status = "waiting";
874
898
  // The reason this conversation was failed - startup reconciliation's note that a previous run was cut
875
899
  // short - describes THAT run, and this line is the moment it stops being the current one. Deleting the
876
900
  // key rather than overwriting it keeps the file byte-identical to a conversation that was never
@@ -896,22 +920,26 @@ export class Store {
896
920
  return id("message");
897
921
  }
898
922
  /**
899
- * Appends one background-result message to a conversation, without starting a turn.
900
- *
901
- * Used by sub-agent dispatch completions and the exec service when they finish out of band - the
902
- * result is the prompt for a follow-up turn in the old design, which made a "后台回传" render under
903
- * the "你" bubble and asked the model to consume a tool envelope as user prose. This appends a
904
- * `role: "tool"` message that the client renders with its own column and its own block UI, so the
905
- * transcript shows the result AND nothing pretends the user said it.
906
- *
907
- * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript and a subsequent
908
- * caller can start a turn normally if it wants the model to react to the result.
923
+ * Appends one background-result message to a conversation. The render column is the same as before
924
+ * (a `role: "tool"` row, never impersonating a user bubble) - what this method now ALSO supports is
925
+ * putting the message straight into the dock by passing `delivery: "waiting"`, which is what exec and
926
+ * sub-agent background completions do once a 后台回传 is treated as a queue item with steer priority
927
+ * rather than a one-way notice the model never gets to read.
928
+ *
929
+ * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript; with a delivery
930
+ * state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
909
931
  */
910
- async addToolMessage(conversationId, content) {
932
+ async addToolMessage(conversationId, content, options) {
911
933
  const conversation = this.requireConversation(conversationId);
912
934
  if (conversation.archivedAt)
913
935
  throw new Error("无法向已归档的会话追加后台回传,请先恢复该会话");
914
- const message = { id: id("message"), role: "tool", content, createdAt: now() };
936
+ const message = {
937
+ id: id("message"),
938
+ role: "tool",
939
+ content,
940
+ createdAt: now(),
941
+ ...(options?.delivery ? { delivery: options.delivery } : {}),
942
+ };
915
943
  conversation.messages.push(message);
916
944
  conversation.updatedAt = message.createdAt;
917
945
  await this.persist();
@@ -1358,14 +1386,18 @@ export class Store {
1358
1386
  listWaitingMessages(conversationId) {
1359
1387
  return structuredClone(this.waitingMessages(this.requireConversation(conversationId)));
1360
1388
  }
1361
- /** The same list by reference, for callers that only read a field and must not pay for a clone. */
1389
+ /** The same list by reference, for callers that only read a field and must not pay for a clone.
1390
+ *
1391
+ * Includes `role: "tool"` messages now that a 后台回传 is a real queue item: same dock, same ordering,
1392
+ * same delivery state machine, same steer/claim semantics - the only thing different is the render
1393
+ * (kept as a 后台回传 block) and what the agent is told when its turn comes (see `composePrompt`). */
1362
1394
  waitingMessages(conversation) {
1363
- return conversation.messages.filter((message) => message.role === "user" && message.delivery);
1395
+ return conversation.messages.filter((message) => (message.role === "user" || message.role === "tool") && message.delivery);
1364
1396
  }
1365
1397
  requireWaitingMessage(conversationId, messageId) {
1366
1398
  const conversation = this.requireConversation(conversationId);
1367
1399
  const message = conversation.messages.find((item) => item.id === messageId);
1368
- if (!message || message.role !== "user" || !message.delivery)
1400
+ if (!message || !message.delivery || (message.role !== "user" && message.role !== "tool"))
1369
1401
  throw new Error(`等待中的消息不存在或已被处理:${messageId}`);
1370
1402
  return { conversation, message };
1371
1403
  }
@@ -1410,7 +1442,7 @@ export class Store {
1410
1442
  async markMessageWaiting(conversationId, messageId) {
1411
1443
  const conversation = this.requireConversation(conversationId);
1412
1444
  const message = conversation.messages.find((item) => item.id === messageId);
1413
- if (!message || message.role !== "user")
1445
+ if (!message || (message.role !== "user" && message.role !== "tool"))
1414
1446
  return undefined;
1415
1447
  if (message.delivery === "sending")
1416
1448
  throw new Error("这条消息已经交给 Agent 了");
@@ -1479,7 +1511,7 @@ export class Store {
1479
1511
  if (index < 0)
1480
1512
  return undefined;
1481
1513
  const message = conversation.messages[index];
1482
- if (message.role !== "user" || !message.delivery)
1514
+ if ((message.role !== "user" && message.role !== "tool") || !message.delivery)
1483
1515
  return undefined;
1484
1516
  if (message.delivery === "sending")
1485
1517
  throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
@@ -1488,18 +1520,17 @@ export class Store {
1488
1520
  return structuredClone(removed);
1489
1521
  }
1490
1522
  /**
1491
- * Rewrites the words of one waiting message.
1492
- *
1493
- * Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
1494
- * changing it is changing the whole truth of the message - there is no second copy anywhere that could
1495
- * fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
1496
- * not one the user gets to rewrite after the fact.
1523
+ * Rewrites the words of one waiting message. User sends only - a 后台回传 carries a `<task>` envelope
1524
+ * the model parses for state, summary and body, and editing it would change a fact the agent has
1525
+ * already started reasoning from. The client dock shows no 「改字」 button for `role: "tool"` rows.
1497
1526
  */
1498
1527
  async editWaitingMessage(conversationId, messageId, content) {
1499
1528
  const trimmed = content.trim();
1500
1529
  if (!trimmed)
1501
1530
  throw new Error("消息不能为空");
1502
1531
  const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1532
+ if (message.role === "tool")
1533
+ throw new Error("后台任务结果不能改字");
1503
1534
  if (message.delivery !== "waiting")
1504
1535
  throw new Error("这条消息已经交给 Agent 了,改不了(Pi 已经收到了原文)");
1505
1536
  message.content = trimmed;
@@ -1571,7 +1602,11 @@ export class Store {
1571
1602
  */
1572
1603
  async deliverWaitingMessage(conversationId, agentIdentity) {
1573
1604
  const conversation = this.requireConversation(conversationId);
1574
- const index = conversation.messages.findIndex((item) => item.role === "user" && item.delivery === "sending");
1605
+ // Looks for the single message Pi is currently answering. A `role: "tool"` 后台回传 that has been
1606
+ // steered or taken is on the same delivery state machine as a user message, so it is the answer
1607
+ // target too; the assistant placeholder still lands directly below it and the role/identity is read
1608
+ // from the conversation (not the message) when the turn opens.
1609
+ const index = conversation.messages.findIndex((item) => (item.role === "user" || item.role === "tool") && item.delivery === "sending");
1575
1610
  if (index < 0)
1576
1611
  return undefined;
1577
1612
  const user = conversation.messages[index];
@@ -1592,9 +1627,10 @@ export class Store {
1592
1627
  * question that is still waiting - a transcript lying about its own order. Splicing at
1593
1628
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
1594
1629
  *
1595
- * 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
1596
1631
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
1597
- * 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.
1598
1634
  */
1599
1635
  async openTurnOnMessage(conversationId, messageId, agentIdentity) {
1600
1636
  const conversation = this.requireConversation(conversationId);
@@ -1611,8 +1647,10 @@ export class Store {
1611
1647
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
1612
1648
  if (!conversation.titleLocked)
1613
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".
1614
1652
  conversation.updatedAt = user.createdAt;
1615
- conversation.status = "running";
1653
+ conversation.status = "waiting";
1616
1654
  delete conversation.error;
1617
1655
  await this.persistMessages(conversation);
1618
1656
  return { user: structuredClone(user), assistant: structuredClone(assistant) };
@@ -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.89",
3
+ "version": "0.1.91",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",