@evandu/super-pi 0.1.84 → 0.1.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/assets/knowledge-model/README.md +37 -0
  3. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  9. package/dist/.super-pi-check-inputs.json +4 -0
  10. package/dist/client/.super-pi-client-sources.json +8 -7
  11. package/dist/client/assets/index-B_jOwjyp.js +72 -0
  12. package/dist/client/assets/index-CNNGl4eF.css +1 -0
  13. package/dist/client/index.html +2 -2
  14. package/dist/server/application/run-service.d.ts +50 -11
  15. package/dist/server/application/run-service.js +206 -77
  16. package/dist/server/command-handler.js +16 -4
  17. package/dist/server/http-server.js +59 -1
  18. package/dist/server/index.js +7 -0
  19. package/dist/server/knowledge-vectors.d.ts +133 -0
  20. package/dist/server/knowledge-vectors.js +302 -0
  21. package/dist/server/knowledge.d.ts +263 -0
  22. package/dist/server/knowledge.js +743 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  24. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  25. package/dist/server/runtime.d.ts +48 -1
  26. package/dist/server/runtime.js +83 -7
  27. package/dist/server/server-context.d.ts +1 -0
  28. package/dist/server/store.d.ts +153 -68
  29. package/dist/server/store.js +405 -187
  30. package/dist/server/types.d.ts +97 -65
  31. package/package.json +163 -155
  32. package/dist/client/assets/index-8Pf3MG2d.js +0 -72
  33. package/dist/client/assets/index-Jnj15dhT.css +0 -1
@@ -297,53 +297,103 @@ function reconcileWorkflows(state) {
297
297
  return orphaned;
298
298
  }
299
299
  /**
300
- * Startup reconciliation for the follow-up queue: an entry the previous process left `sending` goes
301
- * back to `queued`.
300
+ * Startup reconciliation for the waiting messages: one the previous process left `sending` goes back
301
+ * to `waiting`, and any legacy waiting list is folded back into the transcripts it belonged to.
302
302
  *
303
303
  * `sending` is one claim only: "this text has been written to a live Pi child's stdin as a `steer` and
304
304
  * Pi has not confirmed delivering it". The claim dies with the process that made it, exactly like the
305
305
  * `running` claims above - the Pi that would have delivered it was a child of the server that is no
306
306
  * longer running, so it can never confirm anything now.
307
307
  *
308
- * RETURNING IT TO THE QUEUE, RATHER THAN DROPPING IT OR LEAVING IT SENDING, is the only honest third
309
- * option:
308
+ * RETURNING IT TO THE WAITING SET, RATHER THAN DROPPING IT OR LEAVING IT SENDING, is the only honest
309
+ * third option:
310
310
  *
311
- * - Dropping it would lose a message the user is looking at in their dock, silently and permanently.
311
+ * - Dropping it would lose a message the user is looking at in their own transcript, silently and
312
+ * permanently - and unlike the old separate list, that message is not one entry among several, it is
313
+ * the only copy of those words there is.
312
314
  * - Leaving it `sending` would show a message as "already handed to the agent, cannot be cancelled"
313
315
  * that in fact never reached any model and can be cancelled - a UI claiming an irreversible fact
314
316
  * about a reversible state.
315
317
  * - Re-queueing is TRUE: Pi drains steering before its loop can end (`agent-loop.ts:295`), and it
316
- * emits `queue_update` with the item removed before it emits the delivered `message_start`
317
- * (`agent-session.ts:1116-1136`). A delivered steer is therefore already gone from this list before
318
- * `agent_settled` arrives, so anything still `sending` at the end of a turn provably was not
319
- * delivered. Re-queued, the user sees it again and can steer it into the next turn.
318
+ * announces the delivery with `message_start` before the turn settles
319
+ * (`agent-session.ts:1116-1136`), which this server consumes and applies. So a message still `sending`
320
+ * when the process died provably was not delivered. Re-queued, the user sees it again and can steer it
321
+ * into the next turn.
322
+ *
323
+ * THE LEGACY FOLD IS WHAT MAKES THIS AN UPGRADE RATHER THAN A RESET. A state file written by a build
324
+ * that kept waiting messages in their own array holds real words the user typed, in a shape this build
325
+ * no longer reads. Dropping that array would silently lose every message that was waiting at the moment
326
+ * of the upgrade - and that is the one moment where a reader is most likely to be looking at the queue.
327
+ * So each legacy entry is appended to its own conversation as a waiting MESSAGE, keeping its id, text
328
+ * and images, in the order the array had them.
329
+ *
330
+ * IT IS ONE-WAY, deliberately: the key is deleted after the fold, so a second boot cannot duplicate
331
+ * them, and a folded message keeps its `q_`-prefixed id so nothing can mistake one for a message this
332
+ * build wrote (`claimClientMessageId` only accepts `message_` ids, and a collision there falls back to
333
+ * a minted id rather than to a fold).
320
334
  *
321
335
  * Mutated in place and persisted by `open`, so a queue written by a build that predates it, or by a
322
336
  * hand edit, is repaired on the first boot rather than read as permanently stuck.
323
337
  *
324
338
  * `changed` is reported separately from `recovered` because they are different repairs with different
325
- * consequences: a `recovered` row is a message the user can see and therefore needs announcing, while a
339
+ * consequences: a `recovered` message is one the user can see and therefore needs announcing, while a
326
340
  * DROPPED row is a broken file that has to be rewritten or it is re-dropped on every single boot. Neither
327
341
  * is worth a persist on its own account - `open` persists once, for all the sweeps together.
328
342
  */
329
- function reconcileQueuedMessages(state) {
330
- // `Array.isArray` rather than `?? []`, for the same reason the two sweeps above do it: state.json is
331
- // read blind, and a truthy non-array left by a hand edit would make every call below throw.
332
- const queue = Array.isArray(state.queuedMessages) ? state.queuedMessages : [];
333
- const missing = !Array.isArray(state.queuedMessages);
334
- // An entry that is not a usable QueuedMessage is dropped rather than repaired: every field is
335
- // load-bearing (the id is what cancel/steer name, the content is what Pi is handed), so a partial
336
- // entry has no safe default and keeping it would put `undefined` text on the wire.
337
- const usable = queue.filter((item) => Boolean(item) && typeof item?.id === "string" && typeof item?.conversationId === "string" && typeof item?.content === "string" && (item.state === "queued" || item.state === "sending"));
338
- state.queuedMessages = usable;
343
+ function reconcileWaitingMessages(state) {
344
+ // The legacy array, read blind: `Array.isArray` rather than `?? []`, for the same reason the sweeps
345
+ // above do it, because a truthy non-array left by a hand edit would make every call below throw.
346
+ const legacy = state.queuedMessages;
347
+ const queue = Array.isArray(legacy) ? legacy : [];
348
+ const hadKey = Array.isArray(legacy);
349
+ // An entry that is not usable is dropped rather than repaired: every field is load-bearing (the id is
350
+ // what cancel/steer name, the content is what the user wrote), so a partial entry has no safe default
351
+ // and keeping it would put `undefined` text on the wire.
352
+ const usable = queue.filter((item) => Boolean(item) && typeof item.id === "string"
353
+ && typeof item.conversationId === "string"
354
+ && typeof item.content === "string");
355
+ // DELETED UNCONDITIONALLY, before anything else reads it. The fold below is the only consumer, and
356
+ // leaving the key in place would re-fold the same words on every single boot.
357
+ delete state.queuedMessages;
339
358
  const recovered = [];
340
- for (const message of usable) {
341
- if (message.state !== "sending")
359
+ for (const conversation of state.conversations) {
360
+ 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") {
364
+ delete message.delivery;
365
+ continue;
366
+ }
367
+ if (message.delivery !== "sending")
368
+ continue;
369
+ message.delivery = "waiting";
370
+ recovered.push({ conversationId: conversation.id, message });
371
+ }
372
+ }
373
+ const folded = [];
374
+ for (const entry of usable) {
375
+ const conversation = state.conversations.find((item) => item.id === entry.conversationId);
376
+ // An entry naming a conversation that is gone has nowhere to go. That is the one legacy case that is
377
+ // genuinely dropped, and it is reported through `changed` so the key's removal is written to disk.
378
+ if (!conversation)
342
379
  continue;
343
- message.state = "queued";
344
- recovered.push(message);
380
+ const message = {
381
+ id: entry.id,
382
+ role: "user",
383
+ content: entry.content,
384
+ createdAt: typeof entry.createdAt === "string" ? entry.createdAt : now(),
385
+ // ALWAYS `waiting`, never the legacy `sending`: the claim died with the process that made it, which
386
+ // is the same argument the sweep above makes about a message that is genuinely `sending`.
387
+ delivery: "waiting",
388
+ };
389
+ if (entry.images && entry.images.length > 0)
390
+ message.images = structuredClone(entry.images);
391
+ if (entry.attachments && entry.attachments.length > 0)
392
+ message.attachments = structuredClone(entry.attachments);
393
+ conversation.messages.push(message);
394
+ folded.push({ conversationId: conversation.id, message });
345
395
  }
346
- return { recovered, changed: missing || usable.length !== queue.length };
396
+ return { recovered, folded, changed: hadKey || usable.length !== queue.length || recovered.length > 0 };
347
397
  }
348
398
  /**
349
399
  * Which messages and which sub-agent runs a revert to `messageId` removes.
@@ -390,7 +440,7 @@ function planRevert(state, conversationId, messageId, withdraw = false) {
390
440
  /**
391
441
  * Brings `state.todosByConversation` back to a shape the browser can render, on boot.
392
442
  *
393
- * THE SAME REASON `reconcileQueuedMessages` EXISTS, and the same three cases, but nothing here RECOVERS
443
+ * THE SAME REASON `reconcileWaitingMessages` EXISTS, and the same three cases, but nothing here RECOVERS
394
444
  * anything: there is no "still running" claim in a todo list to contradict, because the list is not a
395
445
  * record of a running thing - it is a record of what a model last said it was doing. So there is nothing to
396
446
  * reconcile against a fact and nothing to announce; the sweep exists purely because `state.json` is read
@@ -451,6 +501,18 @@ export class Store {
451
501
  static async openRepository(repository) {
452
502
  try {
453
503
  const state = await repository.load();
504
+ // A COLLECTION THE FILE DID NOT HAVE IS A REPAIR TOO, and it is counted as one here rather than left
505
+ // to the sweeps below - which is what this branch used to do by accident. The follow-up queue sweep
506
+ // reported `changed` for any file without a `queuedMessages` key, so every state written before that
507
+ // feature shipped was rewritten on its first boot, and a hand-seeded file still is. Deleting the
508
+ // queue took that side effect with it, and with it the observable fact that a server which had just
509
+ // defaulted a collection says so on disk.
510
+ //
511
+ // The rule is the one already written above the queue sweep: a repaired file has to be rewritten, or
512
+ // it is repaired again on every single boot - and until it is, anything reading the file sees the
513
+ // un-normalised shape rather than the one the store is actually running on.
514
+ const defaulted = ["workspaces", "conversations", "subagentRuns"].filter((key) => !Array.isArray(state[key])).length > 0
515
+ || !Array.isArray(state.backgroundTasks);
454
516
  if (!Array.isArray(state.backgroundTasks))
455
517
  state.backgroundTasks = [];
456
518
  // Every sweep runs before the store is constructed, so a single persist below publishes them together.
@@ -459,13 +521,13 @@ export class Store {
459
521
  const orphaned = reconcileSubagentRuns(state);
460
522
  const orphanedWorkflows = reconcileWorkflows(state);
461
523
  const interrupted = reconcileConversations(state);
462
- const requeued = reconcileQueuedMessages(state);
524
+ const requeued = reconcileWaitingMessages(state);
463
525
  const todos = reconcileTodos(state);
464
526
  const store = new Store(repository, normalizeMessageTimestamps(state));
465
527
  // Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
466
528
  // is only rewritten by a later mutation would still read `running` to anything that opened the file
467
529
  // in the meantime, and would be reconciled a SECOND time on the next restart.
468
- if (orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
530
+ if (defaulted || orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
469
531
  for (const run of orphaned)
470
532
  console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
471
533
  for (const plan of orphanedWorkflows)
@@ -475,8 +537,10 @@ export class Store {
475
537
  // their run is over rather than guessing from the fact that it stopped moving.
476
538
  for (const conversation of interrupted)
477
539
  console.warn(`Super Pi: conversation ${conversation.id} was still marked running when the last server stopped - it is now failed, because no turn was in flight any more.`);
478
- for (const message of requeued.recovered)
479
- console.warn(`Super Pi: queued message ${message.id} of conversation ${message.conversationId} was still marked sending when the last server stopped - it is back in the queue and cancellable again, because the Pi that would have delivered it is gone.`);
540
+ for (const entry of requeued.recovered)
541
+ console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was still marked sending when the last server stopped - it is waiting again and cancellable, because the Pi that would have delivered it is gone.`);
542
+ for (const entry of requeued.folded)
543
+ console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was waiting in the old queue format - it has been moved into that conversation's transcript.`);
480
544
  await store.persist();
481
545
  }
482
546
  return store;
@@ -500,7 +564,12 @@ export class Store {
500
564
  }
501
565
  }
502
566
  const createdAt = now();
503
- return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
567
+ // NO WRITE HERE, AND THAT IS AN INVARIANT RATHER THAN AN OMISSION: a store with no file behind it
568
+ // stays lazy, so opening one never creates a file and never has to un-create one. `persistence-
569
+ // repository.test.mjs` asserts it by reading the path and expecting ENOENT. Repairing an EXISTING
570
+ // file is the other case and is handled above; this branch invented its contents and says nothing
571
+ // until something actually happens.
572
+ return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], backgroundTasks: [] });
504
573
  }
505
574
  }
506
575
  async setModelFilterRules(rules) {
@@ -585,14 +654,15 @@ export class Store {
585
654
  throw new Error("Workspace not found");
586
655
  if (this.state.workspaces.length <= 1)
587
656
  throw new Error("Cannot delete the last workspace");
588
- // Cascade: workspaces, conversations and their waiting queues live in one state.json, so this single
589
- // atomic persist removes all three together. The queue has to follow: a waiting message is keyed by
590
- // conversation id, so an entry left behind would name a conversation that no longer exists and could
591
- // never be delivered or cancelled - invisible to the browser AND undeletable from the UI.
657
+ // Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
658
+ // both together. A waiting message needs no sweep of its own any more - it is a message inside one of
659
+ // the conversations being removed, so it goes with its container, which is the whole reason it is a
660
+ // better shape than an entry keyed by conversation id in a separate array: that array needed this
661
+ // cascade to avoid naming a conversation that no longer exists, and could not have been trusted to
662
+ // get it right on its own.
592
663
  const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
593
664
  this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
594
665
  this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
595
- this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.has(item.conversationId));
596
666
  await this.persist();
597
667
  return [...doomed];
598
668
  }
@@ -679,7 +749,15 @@ export class Store {
679
749
  workspaceId: source.workspaceId,
680
750
  updatedAt: now(),
681
751
  status: "idle",
682
- messages: structuredClone(source.messages).map((message) => ({ ...message, id: id("message") })),
752
+ // A WAITING MESSAGE IS NOT COPIED, and this filter is why the fork still answers "no queued
753
+ // messages" after the waiting list stopped being a list of its own. Forking is refused while a turn
754
+ // is running, but a conversation can still be holding messages that were sent during that run and
755
+ // have not been started; they belong to the run that is in progress and to nothing else. Copying them
756
+ // would put words in the fork's transcript that Pi's session file - which the fork copies separately -
757
+ // has no record of, so the two halves of the new conversation would disagree from its first turn.
758
+ messages: structuredClone(source.messages)
759
+ .filter((message) => !message.delivery)
760
+ .map((message) => ({ ...message, id: id("message") })),
683
761
  };
684
762
  if (source.model)
685
763
  conversation.model = { provider: source.model.provider, modelId: source.model.modelId, label: source.model.label };
@@ -743,19 +821,48 @@ export class Store {
743
821
  await this.persistMetadata(conversationId);
744
822
  return structuredClone(conversation);
745
823
  }
746
- async addUserMessage(conversationId, content, images, attachments) {
824
+ /**
825
+ * Writes ONE user message into the transcript - the single place a user utterance comes into being.
826
+ *
827
+ * Two callers exist and they differ only in `delivery`, because whether a send starts a turn or waits
828
+ * for one is decided by the RUN GUARD in `run-service`, not here. This method records the words and,
829
+ * if they are waiting, records that they are waiting. Deciding anything about turns would put a second,
830
+ * contradictory answer next to the guard's.
831
+ *
832
+ * `clientId` is the browser's own id for the message it has already drawn. When it is well formed and
833
+ * not already taken, the message IS that id - which is what lets the optimistic bubble and the stored
834
+ * message be the same object rather than two that have to be matched up by content afterwards. A
835
+ * collision falls back to a minted id rather than throwing: the browser is asking for its words to be
836
+ * kept, and refusing the whole send because an id was already in use would lose them.
837
+ *
838
+ * `delivery: "waiting"` ALSO SKIPS EVERY SIDE EFFECT BELOW - no `running`, no title, no `updatedAt`,
839
+ * no clearing of a failure reason. Those all describe a turn, and a message waiting for one has not
840
+ * started anything. It is why a conversation holding only waiting messages still reads as idle in the
841
+ * sidebar, and why the title comes from the first message that was actually asked rather than from
842
+ * whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
843
+ * moment a turn genuinely begins.
844
+ */
845
+ async addUserMessage(conversationId, content, images, attachments, options) {
747
846
  const conversation = this.requireConversation(conversationId);
748
847
  // The browser cannot reach an archived conversation, so this guard exists for any other caller -
749
848
  // a direct WebSocket client, or a future code path. Archiving is the promise that a conversation is
750
849
  // closed, and a closed conversation must not accept a new turn.
751
850
  if (conversation.archivedAt)
752
851
  throw new Error("无法向已归档的会话发送消息,请先恢复该会话");
753
- const message = { id: id("message"), role: "user", content, createdAt: now() };
852
+ const message = { id: this.claimClientMessageId(conversation, options?.clientId), role: "user", content, createdAt: now() };
754
853
  if (images && images.length > 0)
755
854
  message.images = structuredClone(images);
756
855
  if (attachments && attachments.length > 0)
757
856
  message.attachments = structuredClone(attachments);
857
+ if (options?.delivery)
858
+ message.delivery = options.delivery;
758
859
  conversation.messages.push(message);
860
+ // Persisted as a transcript change and nothing more: this is the honest record of "the user said this
861
+ // and nothing has picked it up yet", and a refresh has to show it in the same place it was sent from.
862
+ if (message.delivery) {
863
+ await this.persistMessages(conversation);
864
+ return structuredClone(message);
865
+ }
759
866
  const firstUser = conversation.messages.find((item) => item.role === "user");
760
867
  const autoTitle = firstUser?.content.trim()
761
868
  ? firstUser.content.slice(0, 42)
@@ -774,6 +881,20 @@ export class Store {
774
881
  await this.persist();
775
882
  return structuredClone(message);
776
883
  }
884
+ /**
885
+ * Takes the browser's id for its own optimistic message, when it is safe to.
886
+ *
887
+ * A client that names an id already in this conversation gets a minted one instead: two messages sharing
888
+ * an id would break `updateToolCall`, `revertMessage` and every per-message write that follows, so
889
+ * uniqueness within the conversation is worth more than the client getting its own id back in that case.
890
+ * The `message_` prefix is required so a folded legacy id (which is `q_`-prefixed) can never be adopted.
891
+ */
892
+ claimClientMessageId(conversation, clientId) {
893
+ const usable = typeof clientId === "string" && /^message_[0-9a-f-]{36}$/.test(clientId);
894
+ if (usable && !conversation.messages.some((message) => message.id === clientId))
895
+ return clientId;
896
+ return id("message");
897
+ }
777
898
  /**
778
899
  * Appends one background-result message to a conversation, without starting a turn.
779
900
  *
@@ -1190,199 +1311,296 @@ export class Store {
1190
1311
  }
1191
1312
  requireConversation(conversationId) { const conversation = this.state.conversations.find((item) => item.id === conversationId); if (!conversation)
1192
1313
  throw new Error("Conversation not found"); return conversation; }
1193
- // ------------------------------------------------------------- Follow-up queue
1194
- // The durable waiting list for messages the user sent while the agent was working. Owned HERE and not
1195
- // by Pi, because Pi's own queue is process memory that Super Pi destroys at the end of every turn -
1196
- // see `QueuedMessage`. Every method persists on every call, for the reason
1197
- // `setSubagentRun` does: what a browser reads and what the file holds must never differ, and a
1198
- // message waiting for a turn that has not started is exactly as durable as the conversation.
1314
+ // ------------------------------------------------------------- Waiting messages
1315
+ //
1316
+ // A message the user sent while the agent was working WAITS HERE, and "here" is a field on the message
1317
+ // (`MessageDelivery`) rather than a list beside the conversation. Nothing in this section copies text
1318
+ // anywhere, because there is no second copy: the words a user typed live in exactly one place, in the
1319
+ // transcript, from the frame they pressed send until long after the agent answered them.
1320
+ //
1321
+ // ORDER IS THE QUEUE. The waiting messages are the `role: "user"` messages carrying a `delivery`, and
1322
+ // their array order is the order they will be started in - so "put this one first" is a move within
1323
+ // that group, and there is no `priority` flag to disagree with it.
1324
+ //
1325
+ // Every method persists on every call, for the reason `setSubagentRun` does: what a browser reads and
1326
+ // what the file holds must never differ, and a message waiting for a turn that has not started is
1327
+ // exactly as durable as the conversation around it.
1199
1328
  /**
1200
- * This conversation's waiting list, oldest first.
1329
+ * This conversation's waiting messages, in the order they will be started.
1201
1330
  *
1202
1331
  * Cloned like every other value this store hands out, because the caller broadcasts it straight to
1203
- * browsers and the array is mutated in place by the very next enqueue.
1332
+ * browsers and the array is mutated in place by the very next send.
1204
1333
  */
1205
- listQueuedMessages(conversationId) {
1206
- return structuredClone(this.state.queuedMessages.filter((item) => item.conversationId === conversationId));
1334
+ listWaitingMessages(conversationId) {
1335
+ return structuredClone(this.waitingMessages(this.requireConversation(conversationId)));
1336
+ }
1337
+ /** The same list by reference, for callers that only read a field and must not pay for a clone. */
1338
+ waitingMessages(conversation) {
1339
+ return conversation.messages.filter((message) => message.role === "user" && message.delivery);
1340
+ }
1341
+ requireWaitingMessage(conversationId, messageId) {
1342
+ const conversation = this.requireConversation(conversationId);
1343
+ const message = conversation.messages.find((item) => item.id === messageId);
1344
+ if (!message || message.role !== "user" || !message.delivery)
1345
+ throw new Error(`等待中的消息不存在或已被处理:${messageId}`);
1346
+ return { conversation, message };
1347
+ }
1348
+ async persistMessages(conversation) {
1349
+ await this.persistTransaction({ kind: "conversation.messages", conversationId: conversation.id, messages: structuredClone(conversation.messages), updatedAt: conversation.updatedAt });
1207
1350
  }
1208
1351
  /**
1209
- * Adds one message to the waiting list and returns it.
1352
+ * Claims one waiting message for delivery to the live Pi process: `waiting` becomes `sending`, and the
1353
+ * message STAYS WHERE IT IS - still on screen, still in the transcript, still at the same index.
1210
1354
  *
1211
- * The id is minted here and is the entry's identity for its whole life. Pi matches its own queue by
1212
- * message TEXT (`agent-session.ts:1123`), so two identical messages are one entry to Pi and two here;
1213
- * `cancel` and `steer` name the id, which is the only reason "cancel the second of two identical
1214
- * messages" is expressible at all. `crypto.randomUUID`, like every other id in this store.
1355
+ * The read and the write are adjacent statements with no `await` between them, which is what makes the
1356
+ * claim atomic here. This store has no mutex and does not need one: JavaScript runs this synchronous
1357
+ * run-to-completion, so a second caller arriving in the same tick observes the write above and cannot
1358
+ * claim the same message.
1215
1359
  */
1216
- async enqueueMessage(conversationId, content, images, attachments) {
1217
- this.requireConversation(conversationId);
1218
- if (this.state.conversations.find((item) => item.id === conversationId)?.archivedAt)
1219
- throw new Error("无法向已归档的会话排队消息,请先恢复该会话");
1220
- const message = { id: id("q"), conversationId, content, state: "queued", createdAt: now() };
1221
- if (images && images.length > 0)
1222
- message.images = structuredClone(images);
1223
- if (attachments && attachments.length > 0)
1224
- message.attachments = structuredClone(attachments);
1225
- this.state.queuedMessages.push(message);
1226
- await this.persistCollections("queuedMessages");
1360
+ async claimWaitingMessage(conversationId, messageId) {
1361
+ const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1362
+ if (message.delivery === "sending")
1363
+ throw new Error("这条消息已经交给 Agent 了(Pi 没有撤回单条队列消息的能力)");
1364
+ message.delivery = "sending";
1365
+ await this.persistMessages(conversation);
1227
1366
  return structuredClone(message);
1228
1367
  }
1229
1368
  /**
1230
- * Removes one waiting message. Returns it, or `undefined` when it is already gone.
1369
+ * Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
1370
+ *
1371
+ * The one legal use, and it is narrow on purpose: this is called ONLY when we can prove the message
1372
+ * never reached a model - the turn ended while Pi still held it, or the write to Pi's stdin failed
1373
+ * before it left. Pi drains steering before its loop can end and announces the delivery before the turn
1374
+ * settles, so a message still `sending` when the child is gone provably was not delivered, and
1375
+ * returning it is a fact rather than a guess. It is deliberately NOT used for "Pi has not answered yet",
1376
+ * where the outcome is genuinely unknown and the honest state is still `sending`.
1377
+ */
1378
+ async releaseWaitingMessage(conversationId, messageId) {
1379
+ const conversation = this.requireConversation(conversationId);
1380
+ const message = conversation.messages.find((item) => item.id === messageId);
1381
+ if (!message || message.delivery !== "sending")
1382
+ return;
1383
+ message.delivery = "waiting";
1384
+ await this.persistMessages(conversation);
1385
+ }
1386
+ /**
1387
+ * Returns every `sending` message of this conversation to `waiting`, for the end of a turn.
1388
+ *
1389
+ * See `releaseWaitingMessage` for why this is a fact and not a guess. Called both when a turn ends and
1390
+ * when the user stops it: in the stop case the child is killed mid-turn and a steer it was still holding
1391
+ * was never delivered either, so putting it back is what lets the user decide what to do with it instead
1392
+ * of silently dropping a message they typed - a message they can still see, because it never left the
1393
+ * transcript in the first place.
1394
+ */
1395
+ async releaseSendingMessages(conversationId) {
1396
+ const conversation = this.requireConversation(conversationId);
1397
+ let changed = false;
1398
+ for (const message of this.waitingMessages(conversation)) {
1399
+ if (message.delivery !== "sending")
1400
+ continue;
1401
+ message.delivery = "waiting";
1402
+ changed = true;
1403
+ }
1404
+ if (changed)
1405
+ await this.persistMessages(conversation);
1406
+ }
1407
+ /**
1408
+ * Withdraws one waiting message, by id. Returns it, or `undefined` when it is already gone.
1231
1409
  *
1232
- * "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`:
1233
- * the click that sent this was almost always triggered by a dock row the browser has not been told
1234
- * about yet, so an id that has already been delivered or cancelled is the ordinary case and must not
1235
- * surface as a failure.
1410
+ * "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`: the
1411
+ * click that sent this was almost always triggered by a bubble the browser has not been told about
1412
+ * yet, so an id that has already been answered or cancelled is the ordinary case and must not surface
1413
+ * as a failure.
1236
1414
  *
1237
- * A `sending` entry REFUSES, because the text is already in a live Pi child's stdin and Pi has no way
1415
+ * A `sending` message REFUSES, because the text is already in a live Pi child's stdin and Pi has no way
1238
1416
  * to forget one item (its only queue command is `clear_queue`, which clears every one of them,
1239
- * including follow-ups that have nothing to do with this click). Removing it from this list would not
1240
- * stop Pi from delivering it - it would make the model act on a message the user believed they had
1241
- * withdrawn.
1417
+ * including follow-ups that have nothing to do with this click). Removing it here would not stop Pi from
1418
+ * delivering it - it would make the model act on a message the user believed they had withdrawn, while
1419
+ * their own transcript quietly lost the words.
1242
1420
  */
1243
- async cancelQueuedMessage(conversationId, queueId) {
1244
- const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
1421
+ async cancelWaitingMessage(conversationId, messageId) {
1422
+ const conversation = this.requireConversation(conversationId);
1423
+ const index = conversation.messages.findIndex((item) => item.id === messageId);
1245
1424
  if (index < 0)
1246
1425
  return undefined;
1247
- if (this.state.queuedMessages[index].state === "sending")
1426
+ const message = conversation.messages[index];
1427
+ if (message.role !== "user" || !message.delivery)
1428
+ return undefined;
1429
+ if (message.delivery === "sending")
1248
1430
  throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
1249
- const [removed] = this.state.queuedMessages.splice(index, 1);
1250
- await this.persistCollections("queuedMessages");
1431
+ const [removed] = conversation.messages.splice(index, 1);
1432
+ await this.persistMessages(conversation);
1251
1433
  return structuredClone(removed);
1252
1434
  }
1253
1435
  /**
1254
- * Claims one waiting message for delivery to the live Pi process: `queued` becomes `sending`, and the
1255
- * entry STAYS in the list until Pi reports it delivered.
1256
- *
1257
- * The read and the write are adjacent statements with no `await` between them, which is what makes
1258
- * the claim atomic here. This store has no mutex, and it does not need one: JavaScript runs this
1259
- * synchronous run-to-completion, so a second caller arriving in the same tick observes the write above
1260
- * and cannot claim the same entry. Anything that needed to await between reading and writing would
1261
- * need real serialization instead.
1262
- *
1263
- * Throws for an unknown id and for one that is already `sending`, because both mean the caller is
1264
- * acting on a state the store has already moved past - and the second one means a second steer of the
1265
- * same message would put the text into Pi twice.
1436
+ * Rewrites the words of one waiting message.
1437
+ *
1438
+ * Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
1439
+ * changing it is changing the whole truth of the message - there is no second copy anywhere that could
1440
+ * fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
1441
+ * not one the user gets to rewrite after the fact.
1266
1442
  */
1267
- async claimQueuedMessage(conversationId, queueId) {
1268
- const message = this.state.queuedMessages.find((item) => item.id === queueId && item.conversationId === conversationId);
1269
- if (!message)
1270
- throw new Error(`排队消息不存在或已不在队列中:${queueId}`);
1271
- if (message.state === "sending")
1272
- throw new Error(`排队消息已经交给 Agent 了:${queueId}`);
1273
- message.state = "sending";
1274
- message.priority = true;
1275
- await this.persistCollections("queuedMessages");
1443
+ async editWaitingMessage(conversationId, messageId, content) {
1444
+ const trimmed = content.trim();
1445
+ if (!trimmed)
1446
+ throw new Error("消息不能为空");
1447
+ const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1448
+ if (message.delivery !== "waiting")
1449
+ throw new Error("这条消息已经交给 Agent 了,改不了(Pi 已经收到了原文)");
1450
+ message.content = trimmed;
1451
+ await this.persistMessages(conversation);
1276
1452
  return structuredClone(message);
1277
1453
  }
1278
- /** Keep a priority request at the front even if the current Pi process has already exited. */
1279
- async prioritizeQueuedMessage(conversationId, queueId) {
1280
- const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
1281
- if (index < 0)
1282
- throw new Error(`排队消息不存在或已发出:${queueId}`);
1283
- const message = this.state.queuedMessages[index];
1284
- if (message.state === "sending")
1454
+ /**
1455
+ * Moves one waiting message to a new position among the waiting messages.
1456
+ *
1457
+ * `toIndex` is counted over the WAITING messages only, so `0` is the next one the agent will start and
1458
+ * the caller never has to know how many answered messages sit above it. Moving to the front and moving
1459
+ * back are the same operation, which is the point: the old arrangement had a `priority` boolean for the
1460
+ * first and no way at all to express the second, so undoing a priority meant cancelling the message and
1461
+ * retyping it.
1462
+ *
1463
+ * NOTHING CAN GO IN FRONT OF A `sending` MESSAGE. It is already in Pi's stdin and Pi's steering queue
1464
+ * is FIFO, so it will be delivered before anything this server could do about the order. Accepting the
1465
+ * move and then delivering the other one first would be the UI promising an outcome the transport has
1466
+ * already decided, so it is refused instead.
1467
+ */
1468
+ async reorderWaitingMessage(conversationId, messageId, toIndex) {
1469
+ const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1470
+ const waiting = this.waitingMessages(conversation);
1471
+ const from = waiting.indexOf(message);
1472
+ const target = Math.max(0, Math.min(Math.trunc(toIndex), waiting.length - 1));
1473
+ if (target === from)
1285
1474
  return structuredClone(message);
1286
- message.priority = true;
1287
- await this.persistCollections("queuedMessages");
1475
+ // NOTHING MAY OCCUPY THE `sending` MESSAGE'S SLOT, in either direction. It is already in Pi's stdin and
1476
+ // Pi's steering queue is FIFO, so it is delivered next whatever this method does - meaning a different
1477
+ // message landing in front of it would be a move the UI performed and the transport then ignored, and
1478
+ // moving the `sending` message itself off that spot would be a lie of the same kind.
1479
+ const sendingIndex = waiting.findIndex((item) => item.delivery === "sending");
1480
+ if (sendingIndex >= 0 && (from === sendingIndex || target <= sendingIndex)) {
1481
+ throw new Error(message.delivery === "sending"
1482
+ ? "这条消息已经交给 Agent 了,挪不动(Pi 会先回答已经收到的那条)"
1483
+ : "前面那条消息已经交给 Agent 了,插不过去(Pi 按收到的顺序送达)");
1484
+ }
1485
+ // Permute THE WAITING SLOTS rather than splicing a message to an index. The waiting messages are
1486
+ // usually a suffix of the array, but they are not guaranteed to be one - an assistant message sits
1487
+ // between two of them once a steer has been delivered mid-turn - and an offset derived from "where in
1488
+ // the waiting group am I" would then be computing a position that means nothing. Rewriting the slots in
1489
+ // place is total: it cannot land a message outside the group, and it moves nothing that is not waiting.
1490
+ const slots = waiting.map((item) => conversation.messages.indexOf(item));
1491
+ const reordered = [...waiting];
1492
+ reordered.splice(from, 1);
1493
+ reordered.splice(target, 0, message);
1494
+ slots.forEach((slot, order) => { conversation.messages[slot] = reordered[order]; });
1495
+ await this.persistMessages(conversation);
1288
1496
  return structuredClone(message);
1289
1497
  }
1290
1498
  /**
1291
- * Puts a `sending` entry back into the queue.
1499
+ * Pi has just delivered one steered message: unmark it and open the answer to it, in ONE write.
1292
1500
  *
1293
- * The one legal use, and it is narrow on purpose: this is called ONLY when we can prove the message
1294
- * never reached a model - the turn ended while Pi still held it, or the write to Pi's stdin failed
1295
- * before it left. Pi drains steering before its loop can end and reports the removal before the
1296
- * delivered message, so a message still `sending` when the child is gone provably was not delivered,
1297
- * and returning it is a fact rather than a guess. It is deliberately NOT used for "Pi has not answered
1298
- * yet" (see `steer` in `index.ts`), where the outcome is genuinely unknown and the honest state is
1299
- * still `sending`.
1501
+ * Oldest rather than matched on text, and that is forced on us by Pi rather than chosen: Pi identifies
1502
+ * its own queued entry by `indexOf` on the message text (`agent-session.ts:1123`), so two identical
1503
+ * steered messages are ONE entry to it and no host-side matching could do better. Position is sufficient
1504
+ * anyway because Pi's steering queue is FIFO and Super Pi only ever steers from the front of the waiting
1505
+ * group.
1506
+ *
1507
+ * ONE WRITE, because the reader must never see the intermediate: a message that has stopped waiting but
1508
+ * has no answer under it yet is the exact broken state this refactor exists to remove, and a browser
1509
+ * that broadcast between the two halves would put it right back on screen.
1510
+ *
1511
+ * WHAT IS DIFFERENT FROM BEFORE, and is the whole point: the message used to be DELETED here. It is now
1512
+ * merely unmarked, with an assistant message spliced in after it. It was on screen the whole time - it
1513
+ * was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
1514
+ * exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
1515
+ * with no question above it.
1300
1516
  */
1301
- async releaseQueuedMessage(queueId) {
1302
- const message = this.state.queuedMessages.find((item) => item.id === queueId);
1303
- if (!message || message.state !== "sending")
1304
- return;
1305
- message.state = "queued";
1306
- await this.persistCollections("queuedMessages");
1517
+ async deliverWaitingMessage(conversationId, agentIdentity) {
1518
+ const conversation = this.requireConversation(conversationId);
1519
+ const index = conversation.messages.findIndex((item) => item.role === "user" && item.delivery === "sending");
1520
+ if (index < 0)
1521
+ return undefined;
1522
+ const user = conversation.messages[index];
1523
+ delete user.delivery;
1524
+ const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
1525
+ conversation.messages.splice(index + 1, 0, assistant);
1526
+ await this.persistMessages(conversation);
1527
+ return { user: structuredClone(user), assistant: structuredClone(assistant) };
1307
1528
  }
1308
1529
  /**
1309
- * Removes the OLDEST `count` messages this conversation has in `sending`, and returns them.
1310
- *
1311
- * Driven by a COUNT rather than by matching text, and that is the whole point. Pi's `queue_update`
1312
- * reports only the strings it still holds, and Pi itself identifies which of its entries a delivered
1313
- * message came from with `indexOf` on that text - so with two identical steered messages, "which one
1314
- * was delivered" is exactly the question Pi cannot answer. It does not need to be answered: Pi's
1315
- * steering queue is FIFO and Super Pi only ever steers from the front of this list, so a drop in
1316
- * `steering.length` from N to N-k means Super Pi's oldest k `sending` entries were delivered, in
1317
- * order, whatever their text was.
1530
+ * Opens a turn on a message that is ALREADY IN THE TRANSCRIPT: clears its delivery state, applies every
1531
+ * side effect that means "a turn started here", and splices the assistant placeholder in DIRECTLY AFTER
1532
+ * IT.
1533
+ *
1534
+ * THE INSERT POSITION IS THE WHOLE DESIGN. `push` was the old behaviour and it is only correct when the
1535
+ * message being answered is the last thing in the conversation. With two messages waiting, answering
1536
+ * the first by appending puts its reply BELOW the second one, so the reader sees the answer to a
1537
+ * question that is still waiting - a transcript lying about its own order. Splicing at
1538
+ * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
1539
+ *
1540
+ * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
1541
+ * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
1542
+ * waiting. They belong here, at the moment the turn genuinely begins.
1318
1543
  */
1319
- async dropSendingMessages(conversationId, count) {
1320
- if (count <= 0)
1321
- return [];
1322
- const sending = this.state.queuedMessages.filter((item) => item.conversationId === conversationId && item.state === "sending");
1323
- // Never more than are actually in `sending`: a snapshot that arrives out of order, or a Pi that
1324
- // clears its own queue, must not be able to remove entries that were never delivered.
1325
- const doomed = sending.slice(0, Math.min(count, sending.length)).map((item) => item.id);
1326
- if (!doomed.length)
1327
- return [];
1328
- this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.includes(item.id));
1329
- await this.persistCollections("queuedMessages");
1330
- return structuredClone(sending.slice(0, doomed.length));
1544
+ async openTurnOnMessage(conversationId, messageId, agentIdentity) {
1545
+ const conversation = this.requireConversation(conversationId);
1546
+ const index = conversation.messages.findIndex((item) => item.id === messageId);
1547
+ if (index < 0)
1548
+ throw new Error(`消息不存在或已被撤回:${messageId}`);
1549
+ const user = conversation.messages[index];
1550
+ delete user.delivery;
1551
+ const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
1552
+ conversation.messages.splice(index + 1, 0, assistant);
1553
+ const firstUser = conversation.messages.find((item) => item.role === "user");
1554
+ const autoTitle = firstUser?.content.trim()
1555
+ ? firstUser.content.slice(0, 42)
1556
+ : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
1557
+ if (!conversation.titleLocked)
1558
+ conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
1559
+ conversation.updatedAt = user.createdAt;
1560
+ conversation.status = "running";
1561
+ delete conversation.error;
1562
+ await this.persistMessages(conversation);
1563
+ return { user: structuredClone(user), assistant: structuredClone(assistant) };
1331
1564
  }
1332
1565
  /**
1333
- * Takes the oldest still-`queued` message of this conversation OUT of the list and returns it, for the
1566
+ * Takes the OLDEST still-`waiting` message of this conversation, clearing its delivery state, for the
1334
1567
  * next turn to send as its prompt.
1335
1568
  *
1336
- * Removing it here, rather than marking it and removing it after the turn, is what bounds the loop:
1337
- * every pass through the follow-up loop strictly shrinks the list, so the loop cannot run forever
1338
- * even if a turn succeeds without consuming anything. And removing it BEFORE the spawn is what makes a
1339
- * crash honest - a server that dies mid-turn has already taken the message out of the file, and
1340
- * `open` does not put it back, because it may well have been delivered.
1569
+ * Clearing the state and taking the message are the same write on purpose: it bounds the follow-up loop
1570
+ * (every pass strictly shrinks the set of waiting messages, so the loop cannot run forever even if a turn
1571
+ * succeeds without consuming anything) and it is what makes the crash honest - a server that dies
1572
+ * mid-turn has already taken the message out of the waiting set, so startup does not put it back, because
1573
+ * it may well have been delivered.
1341
1574
  */
1342
- async takeNextQueuedMessage(conversationId) {
1343
- let index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued" && item.priority);
1344
- if (index < 0)
1345
- index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued");
1346
- if (index < 0)
1575
+ async takeNextWaitingMessage(conversationId) {
1576
+ const conversation = this.requireConversation(conversationId);
1577
+ const next = this.waitingMessages(conversation).find((item) => item.delivery === "waiting");
1578
+ if (!next)
1347
1579
  return undefined;
1348
- const [taken] = this.state.queuedMessages.splice(index, 1);
1349
- await this.persistCollections("queuedMessages");
1350
- return structuredClone(taken);
1580
+ delete next.delivery;
1581
+ await this.persistMessages(conversation);
1582
+ return structuredClone(next);
1351
1583
  }
1352
- /** Removes one still-cancellable row by id so an explicit user action can start it as a new turn. */
1353
- async takeQueuedMessage(conversationId, queueId) {
1354
- const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
1355
- if (index < 0)
1356
- throw new Error("这条排队消息已经发出或不存在");
1357
- if (this.state.queuedMessages[index].state !== "queued")
1358
- throw new Error("当前轮还没有结束;请先停止或等待它结束,再发送这条消息");
1359
- const [taken] = this.state.queuedMessages.splice(index, 1);
1360
- await this.persistCollections("queuedMessages");
1361
- return structuredClone(taken);
1584
+ /** Takes one specific waiting message by id, so an explicit user action can start it as a new turn. */
1585
+ async takeWaitingMessage(conversationId, messageId) {
1586
+ const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1587
+ if (message.delivery !== "waiting")
1588
+ throw new Error("当前轮还没有结束,这条消息已经交给 Agent 了(Pi 没有撤回单条队列消息的能力)");
1589
+ delete message.delivery;
1590
+ await this.persistMessages(conversation);
1591
+ return structuredClone(message);
1362
1592
  }
1363
1593
  /**
1364
- * Returns every `sending` message of this conversation to the queue, for the end of a turn.
1594
+ * Opens a turn for a message the user chose to be answered now, when the conversation is idle.
1365
1595
  *
1366
- * See `releaseQueuedMessage` for why this is a fact and not a guess, and `reconcileQueuedMessages` for
1367
- * why the same treatment is right after a crash. Called both when a turn ends and when the user stops
1368
- * it: in the stop case the child is killed mid-turn and a steer it was still holding was never
1369
- * delivered either, so putting it back is what lets the user decide what to do with it instead of
1370
- * silently dropping a message they typed.
1596
+ * The one place that both claims a specific message and opens its turn, so there is no window between
1597
+ * "the message stopped waiting" and "the turn began" in which a crash would leave a message that is
1598
+ * neither waiting nor answered.
1371
1599
  */
1372
- async releaseSendingMessages(conversationId) {
1373
- let changed = false;
1374
- for (const message of this.state.queuedMessages) {
1375
- if (message.conversationId !== conversationId || message.state !== "sending")
1376
- continue;
1377
- message.state = "queued";
1378
- changed = true;
1379
- }
1380
- if (changed)
1381
- await this.persistCollections("queuedMessages");
1600
+ async openTurnOnSpecificMessage(conversationId, messageId, agentIdentity) {
1601
+ await this.takeWaitingMessage(conversationId, messageId);
1602
+ return this.openTurnOnMessage(conversationId, messageId, agentIdentity);
1382
1603
  }
1383
- // ------------------------------------------------------------- Sub-agent runs
1384
- // Records, not transcripts: what lands here is the dispatch, the last assistant message and the
1385
- // usage - see `SubagentRun` for why the child's own conversation deliberately does not.
1386
1604
  /**
1387
1605
  * Records a sub-agent run as `running`, BEFORE the child is spawned.
1388
1606
  *