@evandu/super-pi 0.1.138 → 0.1.140

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 (72) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/agents/super-pi-task/core.ts +418 -1
  4. package/agents/super-pi-task/index.ts +115 -0
  5. package/bin/super-pi.js +7 -11
  6. package/dist/.super-pi-check-inputs.json +2 -2
  7. package/dist/client/.super-pi-client-sources.json +7 -6
  8. package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
  9. package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
  10. package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
  11. package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
  12. package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
  13. package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
  14. package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
  15. package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
  16. package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
  17. package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
  18. package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
  19. package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
  20. package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
  21. package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
  22. package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
  23. package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
  24. package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
  25. package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
  26. package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
  27. package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
  28. package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
  29. package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
  30. package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
  31. package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
  32. package/dist/client/assets/index-DCSFD4VV.css +1 -0
  33. package/dist/client/index.html +2 -2
  34. package/dist/server/application/conversation-service.d.ts +13 -4
  35. package/dist/server/application/conversation-service.js +112 -61
  36. package/dist/server/application/run-service.d.ts +13 -0
  37. package/dist/server/application/run-service.js +255 -69
  38. package/dist/server/application/scheduled-task-service.js +8 -1
  39. package/dist/server/application/session-orchestrator-service.d.ts +113 -0
  40. package/dist/server/application/session-orchestrator-service.js +234 -0
  41. package/dist/server/application/workspace-service.js +16 -1
  42. package/dist/server/feishu-cli.js +15 -4
  43. package/dist/server/file-index.d.ts +30 -0
  44. package/dist/server/file-index.js +49 -4
  45. package/dist/server/files.d.ts +21 -0
  46. package/dist/server/files.js +38 -0
  47. package/dist/server/http-server.js +1580 -1402
  48. package/dist/server/index.js +23 -6
  49. package/dist/server/logging.js +56 -3
  50. package/dist/server/pi-session-pool.d.ts +2 -0
  51. package/dist/server/pi-session-pool.js +1 -0
  52. package/dist/server/pi-session.d.ts +118 -0
  53. package/dist/server/pi-session.js +132 -10
  54. package/dist/server/runtime.d.ts +48 -0
  55. package/dist/server/runtime.js +189 -86
  56. package/dist/server/server-context.d.ts +2 -0
  57. package/dist/server/session-ledger.d.ts +60 -0
  58. package/dist/server/session-ledger.js +76 -0
  59. package/dist/server/store.d.ts +44 -1
  60. package/dist/server/store.js +114 -12
  61. package/dist/server/subagent-runs.d.ts +31 -1
  62. package/dist/server/subagent-runs.js +48 -20
  63. package/dist/server/subagents.js +7 -0
  64. package/dist/server/task-tool.d.ts +18 -0
  65. package/dist/server/task-tool.js +85 -0
  66. package/dist/server/tool-runner.js +13 -4
  67. package/dist/server/types.d.ts +50 -0
  68. package/dist/server/ws-server.d.ts +86 -6
  69. package/dist/server/ws-server.js +291 -51
  70. package/package.json +16 -2
  71. package/scripts/diagnose.mjs +161 -58
  72. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -21,7 +21,7 @@ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, Turn
21
21
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
22
22
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
23
23
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
24
- import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
24
+ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, rosterPayload, } from "./task-tool.js";
25
25
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
26
26
  import { loadConversationImages } from "./upload.js";
27
27
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -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
@@ -695,6 +709,8 @@ export class PiRpcRuntime {
695
709
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
696
710
  [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
697
711
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
712
+ [ORCHESTRATOR_ENDPOINT_ENV]: this.options.orchestratorEndpoint ?? "",
713
+ SUPER_PI_ORCHESTRATOR_CONVERSATION: conversationId,
698
714
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
699
715
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
700
716
  // The document tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
@@ -801,6 +817,7 @@ export class PiRpcRuntime {
801
817
  const session = await this.sessionPool.acquire({
802
818
  conversationId: conversation.id,
803
819
  cwd: resolve(cwd),
820
+ ...(conversation.piSessionFile ? { sessionFile: conversation.piSessionFile } : {}),
804
821
  agentId: conversation.agentId ?? null,
805
822
  extensionEnv: configuration.extensionEnv,
806
823
  ...(configuration.tools ? { tools: configuration.tools } : {}),
@@ -898,20 +915,18 @@ export class PiRpcRuntime {
898
915
  // The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
899
916
  // on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
900
917
  // 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
918
  const turnController = new AbortController();
907
919
  let abortDone = Promise.resolve();
920
+ const abortSession = () => {
921
+ abortDone = session.abort().catch((error) => {
922
+ console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
923
+ });
924
+ };
908
925
  const abortTurn = () => {
909
926
  if (turnController.signal.aborted)
910
927
  return;
911
928
  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
- });
929
+ abortSession();
915
930
  };
916
931
  const onAbort = () => { abortTurn(); };
917
932
  if (signal.aborted)
@@ -936,11 +951,29 @@ export class PiRpcRuntime {
936
951
  }
937
952
  };
938
953
  let firstTextSeen = false;
954
+ let markPromptFinished;
955
+ const promptFinished = new Promise((resolvePromise) => { markPromptFinished = resolvePromise; });
939
956
  const finished = (async () => {
940
957
  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()) };
958
+ let promptAccepted = false;
959
+ const promptOptions = {
960
+ ...(images?.length ? { images: images } : {}),
961
+ preflightResult: (success) => {
962
+ if (!success)
963
+ return;
964
+ promptAccepted = true;
965
+ traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize());
966
+ // Pi calls this hook immediately BEFORE _runAgentPrompt synchronously marks the run
967
+ // active. An abort during before_agent_start can have returned while Pi was idle. Repeat
968
+ // it in the next microtask, after the active flag and the agent's controller exist. Waiting
969
+ // for prompt() would wait for the entire answer and make the cancellation ineffective.
970
+ if (turnController.signal.aborted)
971
+ queueMicrotask(() => {
972
+ if (!queue.isSettled)
973
+ abortSession();
974
+ });
975
+ },
976
+ };
944
977
  // A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
945
978
  //
946
979
  // The window between "the person pressed stop" and "the prompt goes out" is the whole session
@@ -962,38 +995,15 @@ export class PiRpcRuntime {
962
995
  return;
963
996
  }
964
997
  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.
998
+ markPromptFinished();
999
+ // Pi 0.86's prompt() awaits the whole agent run. Input hooks and extension commands can also
1000
+ // handle it without starting a run, in which case no agent_settled event is emitted. Pi's idle
1001
+ // flag distinguishes that legitimate completion from an asynchronous session adapter whose
1002
+ // prompt promise returns before its stream finishes.
1003
+ if (promptAccepted && session.isIdle === true && !queue.isSettled)
1004
+ queue.push({ kind: "end" });
1005
+ // Drain every queued callback and delta before releasing this turn, even if Pi already went
1006
+ // idle. An adapter that returns early must still provide the authoritative settled event.
997
1007
  await queue.settledSignal;
998
1008
  traceLatency(conversation.id, "session.settled", latencyStartedAt);
999
1009
  // THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
@@ -1008,9 +1018,22 @@ export class PiRpcRuntime {
1008
1018
  catch (error) {
1009
1019
  queue.fail(error instanceof Error ? error : new Error(String(error)));
1010
1020
  }
1021
+ finally {
1022
+ // Also release preparation failures and a turn cancelled before prompt() was called.
1023
+ markPromptFinished();
1024
+ }
1011
1025
  })();
1012
- this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
1026
+ this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, promptFinished, abort: abortTurn, get abortDone() { return abortDone; } });
1013
1027
  try {
1028
+ // THE SPLITTER LIVES HERE, INSIDE THE LOOP THAT DRAINS THE QUEUE, and its thinking events are
1029
+ // applied IN LINE rather than deferred. Two producers write into this one queue - Pi's session
1030
+ // events (text deltas, tool calls) and the splitter's reading of the text - and the deferred way
1031
+ // was to announce the second producer's findings at the END of the queue, which put "the thinking
1032
+ // ended" BEHIND the tool call that Pi had announced after the very text that ended it. The tool
1033
+ // callback then ran while the turn still thought it was thinking, and the call was filed inside
1034
+ // the thinking block (UX-008). In line, the thinking lifecycle happens where its text sits, so
1035
+ // the order Pi announced is the order the turn sees.
1036
+ const splitter = new InlineThinkingSplitter();
1014
1037
  for (;;) {
1015
1038
  const item = await queue.next();
1016
1039
  if (item.kind === "text") {
@@ -1018,7 +1041,17 @@ export class PiRpcRuntime {
1018
1041
  firstTextSeen = true;
1019
1042
  traceLatency(conversation.id, "session.first-text", latencyStartedAt);
1020
1043
  }
1021
- yield item.delta;
1044
+ for (const segment of splitter.push(item.delta)) {
1045
+ if (segment.kind === "text") {
1046
+ yield segment.text;
1047
+ }
1048
+ else if (segment.kind === "thinking") {
1049
+ await input.onThinking?.(segment.text);
1050
+ }
1051
+ else {
1052
+ input.onThinkingEnd?.();
1053
+ }
1054
+ }
1022
1055
  continue;
1023
1056
  }
1024
1057
  if (item.kind === "callback") {
@@ -1027,6 +1060,19 @@ export class PiRpcRuntime {
1027
1060
  }
1028
1061
  if (item.kind === "error")
1029
1062
  throw item.error;
1063
+ // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
1064
+ // region behind, and it belongs in the block rather than in the answer.
1065
+ for (const segment of splitter.flush()) {
1066
+ if (segment.kind === "text") {
1067
+ yield segment.text;
1068
+ }
1069
+ else if (segment.kind === "thinking") {
1070
+ await input.onThinking?.(segment.text);
1071
+ }
1072
+ else {
1073
+ input.onThinkingEnd?.();
1074
+ }
1075
+ }
1030
1076
  break;
1031
1077
  }
1032
1078
  }
@@ -1196,6 +1242,8 @@ export class PiRpcRuntime {
1196
1242
  // loaded-but-undeclared tool - broken with no error to explain it.
1197
1243
  if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1198
1244
  names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1245
+ if (this.options.orchestratorEndpoint)
1246
+ names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME);
1199
1247
  return names;
1200
1248
  }
1201
1249
  /**
@@ -1220,27 +1268,20 @@ export class PiRpcRuntime {
1220
1268
  * transcript all work without knowing where the reasoning came from.
1221
1269
  */
1222
1270
  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
- }
1271
+ // THE SPLITTING HAPPENS INSIDE `answerTurn`, NOT HERE - and the reason is ORDER.
1272
+ //
1273
+ // The reasoning of an inline-thinking model is a `<think>…</think>` region INSIDE the answer text,
1274
+ // so "the thinking ended" is a fact that only exists once the text carrying the closing tag has
1275
+ // been read. When that fact was produced out here - one layer ABOVE the queue that carries Pi's
1276
+ // events - it was announced by DEFERRED, and a deferred announcement lands at the END of the
1277
+ // queue: BEHIND the tool call that Pi had already announced. So the tool callback ran while the
1278
+ // turn still believed it was thinking, and the call was filed INSIDE the thinking block - a
1279
+ // question that came after the answer was drawn inside the fold above it (UX-008).
1280
+ //
1281
+ // Splitting inside the queue's consumer keeps the two producers in ONE order: the thinking
1282
+ // lifecycle derived from a piece of text happens where that text sits, and a tool call Pi
1283
+ // announced after that text is announced after it too.
1284
+ yield* this.answerTurn(input);
1244
1285
  }
1245
1286
  /**
1246
1287
  * THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
@@ -1252,7 +1293,7 @@ export class PiRpcRuntime {
1252
1293
  // lifetimes, and folding them into a conversation's session would give them that conversation's
1253
1294
  // context. They are identified by the flags that describe exactly that.
1254
1295
  if (input.memoryReview || input.knowledgeRerank) {
1255
- yield* this.answerOneShot(input);
1296
+ yield* this.answerOneShotWithInlineThinking(input);
1256
1297
  return;
1257
1298
  }
1258
1299
  // WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
@@ -1275,7 +1316,7 @@ export class PiRpcRuntime {
1275
1316
  // anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
1276
1317
  // 0.1.84.
1277
1318
  if (this.sessionMode === "oneshot") {
1278
- yield* this.answerOneShot(input);
1319
+ yield* this.answerOneShotWithInlineThinking(input);
1279
1320
  return;
1280
1321
  }
1281
1322
  // The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
@@ -1287,11 +1328,53 @@ export class PiRpcRuntime {
1287
1328
  this.warnedMissingSdk = true;
1288
1329
  console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
1289
1330
  }
1290
- yield* this.answerOneShot(input);
1331
+ yield* this.answerOneShotWithInlineThinking(input);
1291
1332
  return;
1292
1333
  }
1293
1334
  yield* this.answerOnSession(input);
1294
1335
  }
1336
+ /**
1337
+ * THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
1338
+ *
1339
+ * WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
1340
+ * model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
1341
+ * step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
1342
+ * ended") belong where the text is read.
1343
+ *
1344
+ * WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
1345
+ * `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
1346
+ * ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
1347
+ * CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
1348
+ * suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
1349
+ * `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
1350
+ * suite running the other path to notice.
1351
+ *
1352
+ * SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
1353
+ * queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
1354
+ */
1355
+ async *answerOneShotWithInlineThinking(input) {
1356
+ const splitter = new InlineThinkingSplitter();
1357
+ for await (const chunk of this.answerOneShot(input)) {
1358
+ for (const segment of splitter.push(chunk)) {
1359
+ if (segment.kind === "text")
1360
+ yield segment.text;
1361
+ else if (segment.kind === "thinking")
1362
+ await input.onThinking?.(segment.text);
1363
+ else
1364
+ await input.onThinkingEnd?.();
1365
+ }
1366
+ }
1367
+ // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed region
1368
+ // behind, and it belongs in the block rather than in the answer.
1369
+ for (const segment of splitter.flush()) {
1370
+ if (segment.kind === "text")
1371
+ yield segment.text;
1372
+ else if (segment.kind === "thinking")
1373
+ await input.onThinking?.(segment.text);
1374
+ else
1375
+ await input.onThinkingEnd?.();
1376
+ }
1377
+ }
1295
1378
  /**
1296
1379
  * The one-shot path: a fresh child process for a single turn.
1297
1380
  *
@@ -1379,7 +1462,7 @@ export class PiRpcRuntime {
1379
1462
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1380
1463
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1381
1464
  // tool call anyway the reranker times out and falls back to the lexical order.
1382
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1465
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1383
1466
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1384
1467
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1385
1468
  if (!this.pi.found)
@@ -1967,6 +2050,23 @@ export class PiRpcRuntime {
1967
2050
  * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
1968
2051
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1969
2052
  */
2053
+ /**
2054
+ * THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
2055
+ *
2056
+ * A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
2057
+ * nothing in Pi would notice the difference: the model would keep answering from history the user
2058
+ * has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
2059
+ * the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
2060
+ * next turn rebuilds it from the truncated file.
2061
+ *
2062
+ * THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
2063
+ * called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
2064
+ * the one taken when the transcript path was not cached - missed it, so a session reverted before
2065
+ * its path was ever learned kept talking about the withdrawn turn with nothing saying so.
2066
+ */
2067
+ async evictAfterRevert(conversationId) {
2068
+ await this.sessionPool.evict(conversationId);
2069
+ }
1970
2070
  async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
1971
2071
  if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
1972
2072
  throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
@@ -1981,14 +2081,7 @@ export class PiRpcRuntime {
1981
2081
  const exists = await stat(cached).catch(() => undefined);
1982
2082
  if (exists?.isFile()) {
1983
2083
  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);
2084
+ await this.evictAfterRevert(conversation.id);
1992
2085
  return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
1993
2086
  }
1994
2087
  }
@@ -2014,7 +2107,7 @@ export class PiRpcRuntime {
2014
2107
  if (!found?.isFile())
2015
2108
  throw new Error(`Pi 还没有为这个会话建立记录文件(${resolved}),没有可以回退的历史。`);
2016
2109
  const result = await truncateSessionFile(resolved, boundaryEntryId, conversation.id, beforeBoundary);
2017
- await this.sessionPool.evict(conversation.id);
2110
+ await this.evictAfterRevert(conversation.id);
2018
2111
  return { sessionFile: resolved, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
2019
2112
  }
2020
2113
  // NO agent flags and NO extension on this spawn. It sends no prompt and must leave nothing behind:
@@ -2065,6 +2158,7 @@ export class PiRpcRuntime {
2065
2158
  if (!exists?.isFile())
2066
2159
  throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
2067
2160
  const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id, beforeBoundary);
2161
+ await this.evictAfterRevert(conversation.id);
2068
2162
  return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
2069
2163
  }
2070
2164
  /**
@@ -2236,12 +2330,21 @@ export class PiRpcRuntime {
2236
2330
  const turn = this.activeTurns.get(conversationId);
2237
2331
  if (turn) {
2238
2332
  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))]);
2333
+ // abort() can resolve during prompt preflight, before any agent exists. Await the prompt call
2334
+ // as well, which includes that preflight and the actual run in Pi 0.86. For adapters returning
2335
+ // earlier, abortDone still waits for idle. Do not wait for the caller to drain the event queue:
2336
+ // a caller may stop while paused at a yielded delta. A deadline bounds only this request;
2337
+ // RunService retains the real run slot until the reader ends.
2338
+ let stopDeadline;
2339
+ try {
2340
+ await Promise.race([Promise.all([turn.promptFinished, turn.abortDone]), new Promise((resolvePromise) => {
2341
+ stopDeadline = setTimeout(resolvePromise, 5_000);
2342
+ })]);
2343
+ }
2344
+ finally {
2345
+ if (stopDeadline !== undefined)
2346
+ clearTimeout(stopDeadline);
2347
+ }
2245
2348
  return;
2246
2349
  }
2247
2350
  // The one-shot path: a child process that has to be told to abort over its stdin.
@@ -71,6 +71,8 @@ export interface ServerContext {
71
71
  * task of its own.
72
72
  */
73
73
  scheduledTaskService?: import("./application/scheduled-task-service.js").ScheduledTaskService;
74
+ sessionLedger?: import("./session-ledger.js").SessionLedger;
75
+ sessionOrchestratorService?: import("./application/session-orchestrator-service.js").SessionOrchestratorService;
74
76
  execService: ExecService;
75
77
  toolRegistry: ToolRegistry;
76
78
  toolRunner: ToolRunner;
@@ -0,0 +1,60 @@
1
+ export type SessionOrigin = "user" | "orchestrator";
2
+ export type SessionLedgerStatus = "idle" | "running" | "waiting" | "stopped" | "failed" | "completed";
3
+ /**
4
+ * Metadata entry for a conversation managed or observed by the orchestrator.
5
+ * Crucially, NO transcript or message history is retained here to keep memory footprint bounded.
6
+ */
7
+ export interface SessionLedgerEntry {
8
+ conversationId: string;
9
+ workspaceId: string;
10
+ /** Whether created manually by user or by orchestrator agent */
11
+ origin: SessionOrigin;
12
+ /** If created by orchestrator: the parent conversation that dispatched it */
13
+ parentConversationId?: string;
14
+ /** One-line task description assigned to this session */
15
+ task?: string;
16
+ /** Current status */
17
+ status: SessionLedgerStatus;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ /** High-level progress snippet or latest outcome */
21
+ lastProgress?: string;
22
+ }
23
+ /**
24
+ * In-memory relationship ledger for sibling and child sessions.
25
+ * Holds only metadata (parent-child relationships, tasks, statuses) - never transcripts.
26
+ */
27
+ export declare class SessionLedger {
28
+ private readonly entries;
29
+ /**
30
+ * Registers a session into the ledger.
31
+ */
32
+ register(entry: Omit<SessionLedgerEntry, "createdAt" | "updatedAt"> & {
33
+ createdAt?: number;
34
+ updatedAt?: number;
35
+ }): SessionLedgerEntry;
36
+ /**
37
+ * Updates metadata for an existing session in the ledger.
38
+ */
39
+ update(conversationId: string, patch: Partial<Omit<SessionLedgerEntry, "conversationId" | "createdAt">>): SessionLedgerEntry | undefined;
40
+ /**
41
+ * Retrieves metadata for one session.
42
+ */
43
+ get(conversationId: string): SessionLedgerEntry | undefined;
44
+ /**
45
+ * Lists all sessions dispatched by a given parent conversation, sorted newest first.
46
+ */
47
+ listByParent(parentConversationId: string): SessionLedgerEntry[];
48
+ /**
49
+ * Lists all recorded sessions in the ledger, sorted newest first.
50
+ */
51
+ listAll(): SessionLedgerEntry[];
52
+ /**
53
+ * Removes a session from the ledger.
54
+ */
55
+ delete(conversationId: string): boolean;
56
+ /**
57
+ * Clears all ledger entries (primarily for test cleanup).
58
+ */
59
+ clear(): void;
60
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * In-memory relationship ledger for sibling and child sessions.
3
+ * Holds only metadata (parent-child relationships, tasks, statuses) - never transcripts.
4
+ */
5
+ export class SessionLedger {
6
+ entries = new Map();
7
+ /**
8
+ * Registers a session into the ledger.
9
+ */
10
+ register(entry) {
11
+ const now = Date.now();
12
+ const fullEntry = {
13
+ ...entry,
14
+ createdAt: entry.createdAt ?? now,
15
+ updatedAt: entry.updatedAt ?? now,
16
+ };
17
+ this.entries.set(entry.conversationId, fullEntry);
18
+ return { ...fullEntry };
19
+ }
20
+ /**
21
+ * Updates metadata for an existing session in the ledger.
22
+ */
23
+ update(conversationId, patch) {
24
+ const existing = this.entries.get(conversationId);
25
+ if (!existing)
26
+ return undefined;
27
+ const updated = {
28
+ ...existing,
29
+ ...patch,
30
+ updatedAt: Date.now(),
31
+ };
32
+ this.entries.set(conversationId, updated);
33
+ return { ...updated };
34
+ }
35
+ /**
36
+ * Retrieves metadata for one session.
37
+ */
38
+ get(conversationId) {
39
+ const entry = this.entries.get(conversationId);
40
+ return entry ? { ...entry } : undefined;
41
+ }
42
+ /**
43
+ * Lists all sessions dispatched by a given parent conversation, sorted newest first.
44
+ */
45
+ listByParent(parentConversationId) {
46
+ const result = [];
47
+ for (const entry of this.entries.values()) {
48
+ if (entry.parentConversationId === parentConversationId) {
49
+ result.push({ ...entry });
50
+ }
51
+ }
52
+ return result.sort((a, b) => b.createdAt - a.createdAt);
53
+ }
54
+ /**
55
+ * Lists all recorded sessions in the ledger, sorted newest first.
56
+ */
57
+ listAll() {
58
+ const result = [];
59
+ for (const entry of this.entries.values()) {
60
+ result.push({ ...entry });
61
+ }
62
+ return result.sort((a, b) => b.createdAt - a.createdAt);
63
+ }
64
+ /**
65
+ * Removes a session from the ledger.
66
+ */
67
+ delete(conversationId) {
68
+ return this.entries.delete(conversationId);
69
+ }
70
+ /**
71
+ * Clears all ledger entries (primarily for test cleanup).
72
+ */
73
+ clear() {
74
+ this.entries.clear();
75
+ }
76
+ }