@evandu/super-pi 0.1.137 → 0.1.139

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 (62) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/bin/super-pi.js +7 -11
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +6 -6
  6. package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
  7. package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
  8. package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
  9. package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
  10. package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
  11. package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
  12. package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
  13. package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
  14. package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
  15. package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
  16. package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
  17. package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
  18. package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
  19. package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
  20. package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
  21. package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
  22. package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
  23. package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
  24. package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
  25. package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
  26. package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
  27. package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
  28. package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
  29. package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
  30. package/dist/client/assets/index-Bdym8BPo.css +1 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/application/conversation-service.d.ts +13 -4
  33. package/dist/server/application/conversation-service.js +112 -61
  34. package/dist/server/application/run-service.d.ts +13 -0
  35. package/dist/server/application/run-service.js +255 -69
  36. package/dist/server/application/scheduled-task-service.js +8 -1
  37. package/dist/server/application/workspace-service.js +16 -1
  38. package/dist/server/feishu-cli.js +15 -4
  39. package/dist/server/file-index.d.ts +30 -0
  40. package/dist/server/file-index.js +49 -4
  41. package/dist/server/files.d.ts +21 -0
  42. package/dist/server/files.js +38 -0
  43. package/dist/server/http-server.js +1455 -1412
  44. package/dist/server/index.js +3 -4
  45. package/dist/server/logging.js +56 -3
  46. package/dist/server/pi-session-pool.d.ts +2 -0
  47. package/dist/server/pi-session-pool.js +1 -0
  48. package/dist/server/pi-session.d.ts +118 -0
  49. package/dist/server/pi-session.js +132 -10
  50. package/dist/server/runtime.d.ts +47 -0
  51. package/dist/server/runtime.js +183 -84
  52. package/dist/server/store.d.ts +44 -1
  53. package/dist/server/store.js +114 -12
  54. package/dist/server/subagent-runs.js +18 -7
  55. package/dist/server/subagents.js +7 -0
  56. package/dist/server/tool-runner.js +13 -4
  57. package/dist/server/types.d.ts +27 -0
  58. package/dist/server/ws-server.d.ts +34 -5
  59. package/dist/server/ws-server.js +170 -46
  60. package/package.json +15 -1
  61. package/scripts/diagnose.mjs +161 -58
  62. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -411,6 +411,20 @@ export class PiRpcRuntime {
411
411
  * it was started.
412
412
  */
413
413
  pi;
414
+ /**
415
+ * HOW MANY LIVE SESSIONS THIS CONVERSATION HAS, and whether it has one at all.
416
+ *
417
+ * A reader of this number learns whether a conversation's context is still in Pi's memory. That is
418
+ * the fact a revert has to change: it rewrites the transcript on disk, and a live session keeps the
419
+ * OLD context in memory, so a session that outlived the revert is a model that answers from
420
+ * history the person withdrew - with nothing saying so.
421
+ *
422
+ * It is here because that failure is invisible from the outside: the file is shorter, the state is
423
+ * clean, and only the model knows something the reader cannot see.
424
+ */
425
+ liveSessionCount(conversationId) {
426
+ return this.sessionPool.has(conversationId) ? 1 : 0;
427
+ }
414
428
  canSteer(conversationId) {
415
429
  // A steer is only meaningful while a turn is RUNNING. A live session with nothing running is not a
416
430
  // steer target: there is no current answer for the message to be inserted into, and the transcript's
@@ -801,6 +815,7 @@ export class PiRpcRuntime {
801
815
  const session = await this.sessionPool.acquire({
802
816
  conversationId: conversation.id,
803
817
  cwd: resolve(cwd),
818
+ ...(conversation.piSessionFile ? { sessionFile: conversation.piSessionFile } : {}),
804
819
  agentId: conversation.agentId ?? null,
805
820
  extensionEnv: configuration.extensionEnv,
806
821
  ...(configuration.tools ? { tools: configuration.tools } : {}),
@@ -898,20 +913,18 @@ export class PiRpcRuntime {
898
913
  // The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
899
914
  // on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
900
915
  // flight" are two different facts, and only the second one is Pi's business.
901
- //
902
- // `abortDone` is the abort's OWN promise, kept so `stop()` can await Pi actually going idle rather
903
- // than merely having been ASKED to. `AgentSession.abort()` resolves when the session is idle, which
904
- // is the fact an interrupt needs before the next prompt: prompting earlier is refused by Pi with
905
- // "Agent is already processing".
906
916
  const turnController = new AbortController();
907
917
  let abortDone = Promise.resolve();
918
+ const abortSession = () => {
919
+ abortDone = session.abort().catch((error) => {
920
+ console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
921
+ });
922
+ };
908
923
  const abortTurn = () => {
909
924
  if (turnController.signal.aborted)
910
925
  return;
911
926
  turnController.abort();
912
- abortDone = session.abort().catch((error) => {
913
- console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
914
- });
927
+ abortSession();
915
928
  };
916
929
  const onAbort = () => { abortTurn(); };
917
930
  if (signal.aborted)
@@ -936,11 +949,29 @@ export class PiRpcRuntime {
936
949
  }
937
950
  };
938
951
  let firstTextSeen = false;
952
+ let markPromptFinished;
953
+ const promptFinished = new Promise((resolvePromise) => { markPromptFinished = resolvePromise; });
939
954
  const finished = (async () => {
940
955
  try {
941
- const promptOptions = images?.length
942
- ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
943
- : { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
956
+ let promptAccepted = false;
957
+ const promptOptions = {
958
+ ...(images?.length ? { images: images } : {}),
959
+ preflightResult: (success) => {
960
+ if (!success)
961
+ return;
962
+ promptAccepted = true;
963
+ traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize());
964
+ // Pi calls this hook immediately BEFORE _runAgentPrompt synchronously marks the run
965
+ // active. An abort during before_agent_start can have returned while Pi was idle. Repeat
966
+ // it in the next microtask, after the active flag and the agent's controller exist. Waiting
967
+ // for prompt() would wait for the entire answer and make the cancellation ineffective.
968
+ if (turnController.signal.aborted)
969
+ queueMicrotask(() => {
970
+ if (!queue.isSettled)
971
+ abortSession();
972
+ });
973
+ },
974
+ };
944
975
  // A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
945
976
  //
946
977
  // The window between "the person pressed stop" and "the prompt goes out" is the whole session
@@ -962,38 +993,15 @@ export class PiRpcRuntime {
962
993
  return;
963
994
  }
964
995
  await session.prompt(prompt, promptOptions);
965
- // THE CANCELLATION MAY HAVE ARRIVED WHILE PI WAS PREPARING THE PROMPT.
966
- //
967
- // `session.prompt()` is not one step: it runs Pi's input handlers (the extensions' hooks -
968
- // Super Pi's own memory search is one of them), the compaction check and the auth check before
969
- // the agent's run starts. An abort that lands in that window finds a session that has NOT
970
- // started running, so Pi's abort has nothing to abort - and the prompt goes on to the model,
971
- // which answers a question the person cancelled.
972
- //
973
- // The run IS started by the time `prompt()` resolves, so the abort is said AGAIN here. The
974
- // second one is the one that lands.
975
- if (signal.aborted) {
976
- // The same 5s backstop `runtime.stop` uses: a wedged session must not hang the turn forever.
977
- await Promise.race([
978
- session.abort().catch((error) => {
979
- console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} after a late cancellation threw: ${error instanceof Error ? error.message : "unknown error"}`);
980
- }),
981
- new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
982
- ]);
983
- }
984
- // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
985
- // been ACCEPTED and the run has been started; the agent is still streaming, still executing
986
- // tools, still settling. A turn that ended here would release the reader while the model was
987
- // still producing, and the NEXT turn would arrive at a session Pi considers busy - which Pi
988
- // refuses with "Agent is already processing".
989
- //
990
- // The turn's end is the `agent_settled` EVENT, and the queue is what carries it: the bridge
991
- // pushes the terminal item when Pi settles, and the consumer below reads it. This promise
992
- // waits for that signal, and the race with the prompt's own failure is what keeps a turn that
993
- // never settles from hanging forever - a hung turn is a conversation that never answers.
994
- // `prompt()` only means Pi accepted the request. Do not end this generator on a wall-clock
995
- // timeout: the session may still be streaming or executing a tool, and ending here drops
996
- // subsequent events while falsely allowing the next prompt onto a busy session.
996
+ markPromptFinished();
997
+ // Pi 0.86's prompt() awaits the whole agent run. Input hooks and extension commands can also
998
+ // handle it without starting a run, in which case no agent_settled event is emitted. Pi's idle
999
+ // flag distinguishes that legitimate completion from an asynchronous session adapter whose
1000
+ // prompt promise returns before its stream finishes.
1001
+ if (promptAccepted && session.isIdle === true && !queue.isSettled)
1002
+ queue.push({ kind: "end" });
1003
+ // Drain every queued callback and delta before releasing this turn, even if Pi already went
1004
+ // idle. An adapter that returns early must still provide the authoritative settled event.
997
1005
  await queue.settledSignal;
998
1006
  traceLatency(conversation.id, "session.settled", latencyStartedAt);
999
1007
  // THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
@@ -1008,9 +1016,22 @@ export class PiRpcRuntime {
1008
1016
  catch (error) {
1009
1017
  queue.fail(error instanceof Error ? error : new Error(String(error)));
1010
1018
  }
1019
+ finally {
1020
+ // Also release preparation failures and a turn cancelled before prompt() was called.
1021
+ markPromptFinished();
1022
+ }
1011
1023
  })();
1012
- this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
1024
+ this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, promptFinished, abort: abortTurn, get abortDone() { return abortDone; } });
1013
1025
  try {
1026
+ // THE SPLITTER LIVES HERE, INSIDE THE LOOP THAT DRAINS THE QUEUE, and its thinking events are
1027
+ // applied IN LINE rather than deferred. Two producers write into this one queue - Pi's session
1028
+ // events (text deltas, tool calls) and the splitter's reading of the text - and the deferred way
1029
+ // was to announce the second producer's findings at the END of the queue, which put "the thinking
1030
+ // ended" BEHIND the tool call that Pi had announced after the very text that ended it. The tool
1031
+ // callback then ran while the turn still thought it was thinking, and the call was filed inside
1032
+ // the thinking block (UX-008). In line, the thinking lifecycle happens where its text sits, so
1033
+ // the order Pi announced is the order the turn sees.
1034
+ const splitter = new InlineThinkingSplitter();
1014
1035
  for (;;) {
1015
1036
  const item = await queue.next();
1016
1037
  if (item.kind === "text") {
@@ -1018,7 +1039,17 @@ export class PiRpcRuntime {
1018
1039
  firstTextSeen = true;
1019
1040
  traceLatency(conversation.id, "session.first-text", latencyStartedAt);
1020
1041
  }
1021
- yield item.delta;
1042
+ for (const segment of splitter.push(item.delta)) {
1043
+ if (segment.kind === "text") {
1044
+ yield segment.text;
1045
+ }
1046
+ else if (segment.kind === "thinking") {
1047
+ await input.onThinking?.(segment.text);
1048
+ }
1049
+ else {
1050
+ input.onThinkingEnd?.();
1051
+ }
1052
+ }
1022
1053
  continue;
1023
1054
  }
1024
1055
  if (item.kind === "callback") {
@@ -1027,6 +1058,19 @@ export class PiRpcRuntime {
1027
1058
  }
1028
1059
  if (item.kind === "error")
1029
1060
  throw item.error;
1061
+ // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
1062
+ // region behind, and it belongs in the block rather than in the answer.
1063
+ for (const segment of splitter.flush()) {
1064
+ if (segment.kind === "text") {
1065
+ yield segment.text;
1066
+ }
1067
+ else if (segment.kind === "thinking") {
1068
+ await input.onThinking?.(segment.text);
1069
+ }
1070
+ else {
1071
+ input.onThinkingEnd?.();
1072
+ }
1073
+ }
1030
1074
  break;
1031
1075
  }
1032
1076
  }
@@ -1220,27 +1264,20 @@ export class PiRpcRuntime {
1220
1264
  * transcript all work without knowing where the reasoning came from.
1221
1265
  */
1222
1266
  async *answer(input) {
1223
- const splitter = new InlineThinkingSplitter();
1224
- for await (const chunk of this.answerTurn(input)) {
1225
- for (const segment of splitter.push(chunk)) {
1226
- if (segment.kind === "text")
1227
- yield segment.text;
1228
- else if (segment.kind === "thinking")
1229
- await input.onThinking?.(segment.text);
1230
- else
1231
- input.onThinkingEnd?.();
1232
- }
1233
- }
1234
- // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
1235
- // region behind, and it belongs in the block rather than in the answer.
1236
- for (const segment of splitter.flush()) {
1237
- if (segment.kind === "text")
1238
- yield segment.text;
1239
- else if (segment.kind === "thinking")
1240
- input.onThinking?.(segment.text);
1241
- else
1242
- input.onThinkingEnd?.();
1243
- }
1267
+ // THE SPLITTING HAPPENS INSIDE `answerTurn`, NOT HERE - and the reason is ORDER.
1268
+ //
1269
+ // The reasoning of an inline-thinking model is a `<think>…</think>` region INSIDE the answer text,
1270
+ // so "the thinking ended" is a fact that only exists once the text carrying the closing tag has
1271
+ // been read. When that fact was produced out here - one layer ABOVE the queue that carries Pi's
1272
+ // events - it was announced by DEFERRED, and a deferred announcement lands at the END of the
1273
+ // queue: BEHIND the tool call that Pi had already announced. So the tool callback ran while the
1274
+ // turn still believed it was thinking, and the call was filed INSIDE the thinking block - a
1275
+ // question that came after the answer was drawn inside the fold above it (UX-008).
1276
+ //
1277
+ // Splitting inside the queue's consumer keeps the two producers in ONE order: the thinking
1278
+ // lifecycle derived from a piece of text happens where that text sits, and a tool call Pi
1279
+ // announced after that text is announced after it too.
1280
+ yield* this.answerTurn(input);
1244
1281
  }
1245
1282
  /**
1246
1283
  * THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
@@ -1252,7 +1289,7 @@ export class PiRpcRuntime {
1252
1289
  // lifetimes, and folding them into a conversation's session would give them that conversation's
1253
1290
  // context. They are identified by the flags that describe exactly that.
1254
1291
  if (input.memoryReview || input.knowledgeRerank) {
1255
- yield* this.answerOneShot(input);
1292
+ yield* this.answerOneShotWithInlineThinking(input);
1256
1293
  return;
1257
1294
  }
1258
1295
  // WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
@@ -1275,7 +1312,7 @@ export class PiRpcRuntime {
1275
1312
  // anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
1276
1313
  // 0.1.84.
1277
1314
  if (this.sessionMode === "oneshot") {
1278
- yield* this.answerOneShot(input);
1315
+ yield* this.answerOneShotWithInlineThinking(input);
1279
1316
  return;
1280
1317
  }
1281
1318
  // The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
@@ -1287,11 +1324,53 @@ export class PiRpcRuntime {
1287
1324
  this.warnedMissingSdk = true;
1288
1325
  console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
1289
1326
  }
1290
- yield* this.answerOneShot(input);
1327
+ yield* this.answerOneShotWithInlineThinking(input);
1291
1328
  return;
1292
1329
  }
1293
1330
  yield* this.answerOnSession(input);
1294
1331
  }
1332
+ /**
1333
+ * THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
1334
+ *
1335
+ * WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
1336
+ * model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
1337
+ * step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
1338
+ * ended") belong where the text is read.
1339
+ *
1340
+ * WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
1341
+ * `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
1342
+ * ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
1343
+ * CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
1344
+ * suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
1345
+ * `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
1346
+ * suite running the other path to notice.
1347
+ *
1348
+ * SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
1349
+ * queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
1350
+ */
1351
+ async *answerOneShotWithInlineThinking(input) {
1352
+ const splitter = new InlineThinkingSplitter();
1353
+ for await (const chunk of this.answerOneShot(input)) {
1354
+ for (const segment of splitter.push(chunk)) {
1355
+ if (segment.kind === "text")
1356
+ yield segment.text;
1357
+ else if (segment.kind === "thinking")
1358
+ await input.onThinking?.(segment.text);
1359
+ else
1360
+ await input.onThinkingEnd?.();
1361
+ }
1362
+ }
1363
+ // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed region
1364
+ // behind, and it belongs in the block rather than in the answer.
1365
+ for (const segment of splitter.flush()) {
1366
+ if (segment.kind === "text")
1367
+ yield segment.text;
1368
+ else if (segment.kind === "thinking")
1369
+ await input.onThinking?.(segment.text);
1370
+ else
1371
+ await input.onThinkingEnd?.();
1372
+ }
1373
+ }
1295
1374
  /**
1296
1375
  * The one-shot path: a fresh child process for a single turn.
1297
1376
  *
@@ -1967,6 +2046,23 @@ export class PiRpcRuntime {
1967
2046
  * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
1968
2047
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1969
2048
  */
2049
+ /**
2050
+ * THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
2051
+ *
2052
+ * A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
2053
+ * nothing in Pi would notice the difference: the model would keep answering from history the user
2054
+ * has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
2055
+ * the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
2056
+ * next turn rebuilds it from the truncated file.
2057
+ *
2058
+ * THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
2059
+ * called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
2060
+ * the one taken when the transcript path was not cached - missed it, so a session reverted before
2061
+ * its path was ever learned kept talking about the withdrawn turn with nothing saying so.
2062
+ */
2063
+ async evictAfterRevert(conversationId) {
2064
+ await this.sessionPool.evict(conversationId);
2065
+ }
1970
2066
  async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
1971
2067
  if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
1972
2068
  throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
@@ -1981,14 +2077,7 @@ export class PiRpcRuntime {
1981
2077
  const exists = await stat(cached).catch(() => undefined);
1982
2078
  if (exists?.isFile()) {
1983
2079
  const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
1984
- // THE SESSION MUST GO. The truncation above changed the transcript ON DISK, and a live session
1985
- // holds the OLD context in memory. Nothing in Pi would notice the difference: the model would
1986
- // keep answering from history the user has just withdrawn, while `state.json` and the file on
1987
- // disk both said it was gone. Three views of the conversation, two of them wrong, and no error
1988
- // anywhere - so the session is destroyed here and the next turn rebuilds it from the truncated
1989
- // file. This is the one place where eviction is a correctness requirement and not an
1990
- // optimization.
1991
- await this.sessionPool.evict(conversation.id);
2080
+ await this.evictAfterRevert(conversation.id);
1992
2081
  return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
1993
2082
  }
1994
2083
  }
@@ -2014,7 +2103,7 @@ export class PiRpcRuntime {
2014
2103
  if (!found?.isFile())
2015
2104
  throw new Error(`Pi 还没有为这个会话建立记录文件(${resolved}),没有可以回退的历史。`);
2016
2105
  const result = await truncateSessionFile(resolved, boundaryEntryId, conversation.id, beforeBoundary);
2017
- await this.sessionPool.evict(conversation.id);
2106
+ await this.evictAfterRevert(conversation.id);
2018
2107
  return { sessionFile: resolved, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
2019
2108
  }
2020
2109
  // NO agent flags and NO extension on this spawn. It sends no prompt and must leave nothing behind:
@@ -2065,6 +2154,7 @@ export class PiRpcRuntime {
2065
2154
  if (!exists?.isFile())
2066
2155
  throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
2067
2156
  const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id, beforeBoundary);
2157
+ await this.evictAfterRevert(conversation.id);
2068
2158
  return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
2069
2159
  }
2070
2160
  /**
@@ -2236,12 +2326,21 @@ export class PiRpcRuntime {
2236
2326
  const turn = this.activeTurns.get(conversationId);
2237
2327
  if (turn) {
2238
2328
  turn.abort();
2239
- // WAIT FOR PI TO ACTUALLY GO IDLE, not merely to have been asked. `AgentSession.abort()` resolves
2240
- // when the session is idle, and idle is the fact the caller needs: an interrupt that starts the
2241
- // next turn before this one has unwound is refused by Pi with "Agent is already processing". The
2242
- // backstop keeps a wedged session from hanging a stop forever - the turn's own 5s safety net then
2243
- // still ends it.
2244
- await Promise.race([turn.abortDone, new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000))]);
2329
+ // abort() can resolve during prompt preflight, before any agent exists. Await the prompt call
2330
+ // as well, which includes that preflight and the actual run in Pi 0.86. For adapters returning
2331
+ // earlier, abortDone still waits for idle. Do not wait for the caller to drain the event queue:
2332
+ // a caller may stop while paused at a yielded delta. A deadline bounds only this request;
2333
+ // RunService retains the real run slot until the reader ends.
2334
+ let stopDeadline;
2335
+ try {
2336
+ await Promise.race([Promise.all([turn.promptFinished, turn.abortDone]), new Promise((resolvePromise) => {
2337
+ stopDeadline = setTimeout(resolvePromise, 5_000);
2338
+ })]);
2339
+ }
2340
+ finally {
2341
+ if (stopDeadline !== undefined)
2342
+ clearTimeout(stopDeadline);
2343
+ }
2245
2344
  return;
2246
2345
  }
2247
2346
  // The one-shot path: a child process that has to be told to abort over its stdin.
@@ -119,6 +119,29 @@ export declare class Store {
119
119
  */
120
120
  workspaceForConversation(conversationId: string): Workspace | undefined;
121
121
  workspace(workspaceId: string): Workspace | undefined;
122
+ /** Read the app-wide default without cloning conversations or transcripts. */
123
+ defaultModel(): ModelSelection | undefined;
124
+ /** Read one detached conversation for lifecycle operations. */
125
+ conversation(conversationId: string): Conversation | undefined;
126
+ /** Return every pending question in one conversation without cloning unrelated history. */
127
+ pendingQuestions(conversationId: string): Array<{
128
+ messageId: string;
129
+ index: number;
130
+ question: ToolCallQuestion;
131
+ }>;
132
+ /**
133
+ * THE SCHEDULED TASKS, WITHOUT CLONING THE STATE TO FIND THEM.
134
+ *
135
+ * The same argument as the readers above, with a sharper edge: the scheduled-task clock sweeps
136
+ * every fifteen seconds and used to read its three-to-zero tasks through `snapshot()` - a deep
137
+ * clone of the whole application, every fifteen seconds, whether the user has any scheduled task or
138
+ * not. On a server holding a long transcript that is a full copy of the app four times a minute,
139
+ * produced for a list that is usually empty.
140
+ *
141
+ * The tasks are returned as a shallow copy of the array: the sweep reads `enabled` and
142
+ * `nextRunAt` and then hands the whole task back to the store, which owns the real one.
143
+ */
144
+ scheduledTasks(): ScheduledTask[];
122
145
  /** Small detached rows used when refreshing each conversation's scoped model catalog. */
123
146
  catalogConversationTargets(): Array<Pick<Conversation, "id" | "archivedAt" | "model">>;
124
147
  /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
@@ -239,11 +262,14 @@ export declare class Store {
239
262
  * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
240
263
  * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
241
264
  */
242
- clientState(): AppState;
265
+ clientState(scope?: {
266
+ conversationId?: string;
267
+ }): AppState;
243
268
  createConversation(workspaceId: string, configuration?: {
244
269
  agentId?: string;
245
270
  model?: ModelSelection | null;
246
271
  thinkingLevel?: string | null;
272
+ modelNotice?: string;
247
273
  }): Promise<Conversation>;
248
274
  /**
249
275
  * Copies a conversation into a NEW one, under an id the caller has already minted.
@@ -480,6 +506,20 @@ export declare class Store {
480
506
  * Every field is `undefined`-tested for the same reason `settleToolCall` does it: `""` and `[]` are
481
507
  * values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
482
508
  */
509
+ /**
510
+ * THE TOOL-CALL WRITES, PERSISTED AS ONE MESSAGE INSTEAD OF THE WHOLE APPLICATION.
511
+ *
512
+ * A tool call's lifecycle touches exactly ONE thing: the `toolCalls` array of ONE message. The
513
+ * write used to be `this.persist()`, which hands the ENTIRE state to the repository - every
514
+ * workspace, every conversation, every message of every conversation - to be serialized and
515
+ * written. A turn that calls five tools therefore wrote the whole application ten to fifteen
516
+ * times, and every one of those writes was proportional to the whole history rather than to the
517
+ * one row that changed (LAT-020).
518
+ *
519
+ * `message.replace` is the transaction that means 「this one message now reads like this」, and it
520
+ * is exactly the write this is: the row's content did not change, only its tool calls did.
521
+ */
522
+ private persistToolCallMessage;
483
523
  updateToolCall(conversationId: string, messageId: string, index: number, patch: {
484
524
  label?: string;
485
525
  detail?: string;
@@ -487,6 +527,9 @@ export declare class Store {
487
527
  live?: string;
488
528
  preparing?: boolean;
489
529
  question?: ToolCallQuestion;
530
+ at?: number;
531
+ thinkingAt?: number;
532
+ thinkingBlock?: number | null;
490
533
  backgroundTask?: ToolCallBlock["backgroundTask"];
491
534
  }): Promise<ToolCallBlock[]>;
492
535
  /**