@evandu/super-pi 0.1.94 → 0.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,16 @@ import { mkdir, stat } from "node:fs/promises";
8
8
  import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
9
9
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
10
10
  import { resolve, join } from "node:path";
11
- import { agentSpawnArgs, readAgents } from "./agents.js";
11
+ import { agentSpawnArgs, readAgents, toolSelection, writeAgentPrompt } from "./agents.js";
12
12
  import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
13
13
  import { resolvePiCommand, shellQuote } from "./pi-command.js";
14
+ import { PiSessionPool, repositoryRootFrom } from "./pi-session-pool.js";
15
+ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, TurnQueue } from "./pi-session-events.js";
16
+ // The queue/steer recognisers live in pi-session-events.ts, where the SESSION path's bridge can use
17
+ // them; the one-shot drain loop below reads the same records through the same two functions, so both
18
+ // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
19
+ export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
20
+ import { createPiSession, piSdkAvailable, superPiExtensionPaths } from "./pi-session.js";
14
21
  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, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
15
22
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
16
23
  import { loadConversationImages } from "./upload.js";
@@ -43,54 +50,6 @@ export class WelcomeRuntime {
43
50
  return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error: "Welcome runtime has no Pi session to ask" };
44
51
  }
45
52
  }
46
- /**
47
- * One record off Pi's JSONL stdout. Deliberately a loose shape with optional fields: Pi emits a wide,
48
- * open-ended family of session events, and a type that enumerated all of them would break on the next
49
- * Pi release rather than on the next behaviour change.
50
- *
51
- * Exported so another consumer of `RpcChannel` - the sub-agent supervisor - can read the same records
52
- * through the same channel instead of writing a second JSONL reader. Every field below is optional, so
53
- * a reader must check before using one.
54
- */
55
- /**
56
- * Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
57
- *
58
- * Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
59
- * objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
60
- * `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
61
- * drain loop can keep using it as the recogniser.
62
- */
63
- export function describePiQueue(record) {
64
- if (record.type !== "queue_update")
65
- return undefined;
66
- return { steering: textList(record.steering) ?? [], followUp: textList(record.followUp) ?? [] };
67
- }
68
- /**
69
- * Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
70
- *
71
- * A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
72
- * opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
73
- *
74
- * Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
75
- * back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
76
- * only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
77
- * cannot tell two identical messages apart either, which is why the caller resolves a delivery by
78
- * position and not by comparing this.
79
- */
80
- export function describeDeliveredUserMessage(record) {
81
- if (record.type !== "message_start")
82
- return undefined;
83
- const message = record.message;
84
- if (!message || typeof message !== "object" || message.role !== "user")
85
- return undefined;
86
- const content = message.content;
87
- if (!Array.isArray(content))
88
- return undefined;
89
- const parts = content
90
- .map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : undefined))
91
- .filter((part) => part !== undefined);
92
- return parts.length ? parts.join("\n") : undefined;
93
- }
94
53
  const NOTIFY_TYPES = new Set(["info", "warning", "error"]);
95
54
  const PLACEMENTS = new Set(["aboveEditor", "belowEditor"]);
96
55
  const text = (value) => (typeof value === "string" ? value : undefined);
@@ -287,6 +246,43 @@ export class PiRpcRuntime {
287
246
  knowledge;
288
247
  setKnowledge(knowledge) { this.knowledge = knowledge; }
289
248
  runs = new Map();
249
+ /**
250
+ * THE LIVE SESSIONS, one per conversation, alive across turns.
251
+ *
252
+ * `runs` is the OLD registry - a child that exists only while a turn is running - and it still
253
+ * describes the one-shot paths (memory review, knowledge rerank, catalog probe), which keep their
254
+ * own throwaway children. The main conversation path does NOT use it: a conversation's session lives
255
+ * in the pool, and a turn is a run ON that session rather than a process OF its own.
256
+ */
257
+ sessionPool;
258
+ /**
259
+ * The turn currently running on each conversation's session.
260
+ *
261
+ * This is the piece the old design did not need, because a turn WAS a process and the process
262
+ * registry said everything. With a session that outlives its turns, the host has to know which turn
263
+ * is on the session RIGHT NOW - that is what an abort targets, and what a steer is delivered into.
264
+ */
265
+ activeTurns = new Map();
266
+ /**
267
+ * THE CURRENT TURN'S EXTENSION-UI HOST, per conversation.
268
+ *
269
+ * Pi's blocking UI questions (`select`/`confirm`/`input`/`editor`) are answered by a host, and the
270
+ * host is a per-TURN thing - `run-service` builds one per `answer()` call - while the UI context the
271
+ * session's extensions talk to is bound ONCE per session. This map is the bridge between the two
272
+ * lifetimes: a turn registers its host here for exactly its own duration, and the session's bound
273
+ * context reads through it at call time. A question asked while no turn is registered gets the honest
274
+ * answer - `cancelled` - never a default, which is the same rule the RPC path's no-host branch follows.
275
+ */
276
+ sessionHosts = new Map();
277
+ /** The sessions whose extension runner already has the UI bridge bound. */
278
+ uiBoundSessions = new WeakSet();
279
+ /**
280
+ * The session-file path last reported for a conversation, so `onSessionFile` fires when it CHANGES
281
+ * rather than once per turn. The path is stable for the life of a session; what changes it is a
282
+ * rebuild (a revert, an eviction, a fingerprint change), which is exactly when the cached locator
283
+ * needs refreshing.
284
+ */
285
+ reportedSessionFiles = new Map();
290
286
  /**
291
287
  * `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
292
288
  *
@@ -305,6 +301,8 @@ export class PiRpcRuntime {
305
301
  pendingCatalogs = new Map();
306
302
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
307
303
  warnedMissingExtension = false;
304
+ /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
305
+ warnedMissingSdk = false;
308
306
  /**
309
307
  * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
310
308
  * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
@@ -318,6 +316,13 @@ export class PiRpcRuntime {
318
316
  */
319
317
  pi;
320
318
  canSteer(conversationId) {
319
+ // A steer is only meaningful while a turn is RUNNING. A live session with nothing running is not a
320
+ // steer target: there is no current answer for the message to be inserted into, and the transcript's
321
+ // split-on-delivery model would have nothing to split. So this asks about the TURN, not about the
322
+ // process - which is the change the persistent session forces, and the reason it is stated here
323
+ // rather than left to a caller that used to infer it from a live child.
324
+ if (this.activeTurns.has(conversationId))
325
+ return true;
321
326
  const run = this.runs.get(conversationId);
322
327
  return Boolean(run && run.child.exitCode === null && !run.child.killed && !run.child.stdin.destroyed);
323
328
  }
@@ -347,6 +352,10 @@ export class PiRpcRuntime {
347
352
  * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
348
353
  * from "configured wrong". When it is absent the tool still registers and the extension refuses in
349
354
  * words, which is what `refuseTodo` in core.ts is for.
355
+ *
356
+ * `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
357
+ * of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
358
+ * what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
350
359
  */
351
360
  options = {}) {
352
361
  this.sessionDir = sessionDir;
@@ -355,6 +364,59 @@ export class PiRpcRuntime {
355
364
  this.pi = resolvePiCommand({ command });
356
365
  if (!this.pi.found)
357
366
  console.warn(`PiRpcRuntime: ${this.pi.reason}`);
367
+ this.sessionPool = new PiSessionPool(sessionDir, repositoryRootFrom(), undefined, (conversationId) => {
368
+ // A session's credential dies with the session. The session is what HELD the token - its
369
+ // extension read it out of the environment when it was loaded - so a session that is gone must
370
+ // not leave a usable token behind.
371
+ releaseCapability(conversationId);
372
+ // The session also held this conversation's UI bridge and session-file cache; both are facts
373
+ // ABOUT that session and stop being true with it.
374
+ this.sessionHosts.delete(conversationId);
375
+ this.reportedSessionFiles.delete(conversationId);
376
+ }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId));
377
+ }
378
+ /**
379
+ * The configuration of the session that was built for a conversation, for tests to inspect.
380
+ *
381
+ * WHY THIS EXISTS. The permission boundary - an agent's tool allowlist, its prompt file, the
382
+ * extensions it mounts - used to be proved by reading the ARGV of a spawned child. A session is not
383
+ * a child and has no argv, so the evidence moved: this is what the session was actually BUILT with,
384
+ * which is the same fact stated in the new architecture's terms.
385
+ *
386
+ * It is a RECORD, not a control channel: nothing in the runtime reads it back, and a test that
387
+ * asserts on it is asserting on the real decision the real code made.
388
+ */
389
+ builtSessions = new Map();
390
+ /** The configuration a conversation's session was built with, if one was built. */
391
+ builtSessionConfig(conversationId) {
392
+ return this.builtSessions.get(conversationId);
393
+ }
394
+ /**
395
+ * Whether the SDK-backed session layer can be used at all.
396
+ *
397
+ * The SDK is a LIBRARY and it is optional: Super Pi also supports running against a Pi installed as a
398
+ * COMMAND, and a server that cannot load the library still has a working one-shot path for the
399
+ * conversations it can serve. The answer is reported rather than thrown, so the caller can say what is
400
+ * missing instead of failing a turn with a stack trace.
401
+ */
402
+ async sessionSupport() {
403
+ return piSdkAvailable();
404
+ }
405
+ /**
406
+ * Throws away every live session.
407
+ *
408
+ * Called at shutdown, and after a `revert` - the transcript on disk changed, and a session holding
409
+ * the old context in memory would keep answering from history the user has withdrawn.
410
+ */
411
+ async disposeSessions(conversationId) {
412
+ if (conversationId)
413
+ await this.sessionPool.evict(conversationId);
414
+ else
415
+ await this.sessionPool.disposeAll();
416
+ }
417
+ /** Throws away the sessions that have been idle for a while. */
418
+ async evictIdleSessions() {
419
+ await this.sessionPool.evictIdle();
358
420
  }
359
421
  /**
360
422
  * The agent a conversation runs as, or undefined for Pi's own defaults.
@@ -417,11 +479,18 @@ export class PiRpcRuntime {
417
479
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
418
480
  * own address, so it is the same string on every turn and is read once from the constructor's options.
419
481
  */
482
+ /**
483
+ * The environment Super Pi's extensions read when they are loaded.
484
+ *
485
+ * IT DOES NOT INCLUDE THE CREDENTIAL. The credential is minted by the session pool, when the session
486
+ * is created, and added there - because the credential belongs to the SESSION (the extension reads
487
+ * its environment once, when it is loaded) and a token minted per turn would be replaced before the
488
+ * second turn of the conversation, leaving the extension holding a value the server had forgotten.
489
+ */
420
490
  async taskExtensionEnv(conversationId) {
421
491
  const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
422
492
  for (const problem of problems)
423
493
  console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
424
- const capability = mintCapability(conversationId);
425
494
  return {
426
495
  [TASK_ENDPOINT_ENV]: this.options.endpoint ?? "",
427
496
  [TASK_EXEC_ENDPOINT_ENV]: this.options.execEndpoint ?? "",
@@ -432,15 +501,460 @@ export class PiRpcRuntime {
432
501
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
433
502
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
434
503
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
435
- SUPER_PI_TOOLS_CAPABILITY: capability,
436
- // MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
437
- // decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
438
- // function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
439
- // unable to dispatch, however its allowlist is broken.
504
+ };
505
+ }
506
+ /**
507
+ * The environment for a ONE-SHOT child, which is the same as the session's except that it carries
508
+ * the credential.
509
+ *
510
+ * THE DIFFERENCE IS THE LIFETIME. A one-shot child lives for one turn, so its credential is minted
511
+ * for that turn and dies with the child - which is the original rule, and it is still exactly right
512
+ * here. A session lives for many turns, so its credential is minted when the session is created (see
513
+ * `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
514
+ */
515
+ async oneShotExtensionEnv(conversationId) {
516
+ const capability = mintCapability(conversationId);
517
+ return {
518
+ ...(await this.taskExtensionEnv(conversationId)),
440
519
  [TASK_CAPABILITY_ENV]: capability,
520
+ SUPER_PI_TOOLS_CAPABILITY: capability,
521
+ };
522
+ }
523
+ /**
524
+ * RUNS A TURN ON A PERSISTENT SESSION.
525
+ *
526
+ * This is the path every conversation takes now, and it is where the latency win comes from. There is
527
+ * no process to spawn and none to kill: the conversation's session already exists, its context is
528
+ * already in memory, and a turn is a `prompt()` on it. An interrupt is `abort()` on the same session -
529
+ * which is why an interrupt no longer costs a process teardown and a process startup.
530
+ *
531
+ * THE EVENT ROUTING. Pi pushes events through a subscription; this method pulls them, one at a time,
532
+ * through a queue. The generator yields text deltas to its caller, and every non-text event is
533
+ * delivered to the turn's callbacks as it arrives, so the transcript shows tool calls and reasoning
534
+ * WHILE the model is producing them rather than after the turn ends.
535
+ *
536
+ * THE ABORT. The caller's `signal` is the turn's abort. When it fires, the session is aborted - Pi
537
+ * stops the current operation and waits for the session to go idle - and the turn ends. The session
538
+ * ITSELF survives: it is the next turn's session, and it is the reason the next turn costs no startup.
539
+ */
540
+ /**
541
+ * The configuration a conversation's session is built with.
542
+ *
543
+ * SEPARATED FROM THE BUILD ON PURPOSE, because this is a decision and the build is its consequence.
544
+ * Everything that decides what Pi is allowed to do for a conversation - the agent's allowlist, its
545
+ * prompt file, the extensions it mounts, the model it runs on - is decided HERE, in one place, and
546
+ * the session is then built from it.
547
+ *
548
+ * Two things want that separation. The FIRST is the fingerprint: the decision is what the session is
549
+ * compared against, so a changed decision means the session is stale. The SECOND is the tests: the
550
+ * permission boundary is the most important thing this runtime does, and asserting on the decision
551
+ * is a DIRECT way to check it - a test that has to start a real Pi to learn what Pi was told is a
552
+ * test that only runs on a machine with a Pi.
553
+ */
554
+ async sessionConfiguration(conversation, cwd) {
555
+ // The agent definition, read from disk EVERY turn. That freshness is the whole point of the
556
+ // fingerprint: an edited agent file changes the configuration, the session is replaced, and the
557
+ // edit takes effect on the next message with no restart and no re-selection.
558
+ const agent = await this.agentFor(conversation);
559
+ // The SDK path has no RPC `get_state` round trip before its first prompt. Preserve the old guard at
560
+ // the equivalent boundary: before a session is created. In particular, an unspecified conversation
561
+ // model means "the catalog's Pi default", which still has to pass the workspace policy.
562
+ if (agent?.model)
563
+ await this.modelValidator?.(conversation.id, { ...agent.model, label: agent.model.modelId });
564
+ await this.modelValidator?.(conversation.id, conversation.model);
565
+ // The tool selection and the prompt file are the SAME DECISION the spawn line used to carry, in the
566
+ // form the SDK takes. `toolSelection` is the shared translation, so an agent that declares
567
+ // `tools: {"*": false, "read": true}` gets exactly the same tools here as it used to get on the
568
+ // command line - including the empty allowlist that means "this agent talks and does not act".
569
+ const selection = agent
570
+ ? toolSelection(agent.tools, { extraAllowedTools: this.extensionToolNames() })
571
+ : {};
572
+ const systemPromptFile = agent ? await writeAgentPrompt(this.agents.promptDir, agent) : undefined;
573
+ return {
574
+ ...(selection.tools ? { tools: selection.tools } : {}),
575
+ ...(selection.excludeTools ? { excludeTools: selection.excludeTools } : {}),
576
+ ...(systemPromptFile ? { systemPromptFile } : {}),
577
+ ...(agent?.promptMode ? { systemPromptMode: agent.promptMode } : {}),
578
+ extensionPaths: superPiExtensionPaths(repositoryRootFrom()),
579
+ extensionEnv: await this.taskExtensionEnv(conversation.id),
580
+ ...(conversation.model ? { model: { provider: conversation.model.provider, modelId: conversation.model.modelId } } : {}),
581
+ ...(conversation.thinkingLevel ? { thinkingLevel: conversation.thinkingLevel } : {}),
582
+ };
583
+ }
584
+ async *answerOnSession(input) {
585
+ const { conversation, prompt, signal, cwd, images } = input;
586
+ const latencyStartedAt = performance.now();
587
+ traceLatency(conversation.id, "session.begin", latencyStartedAt);
588
+ const configuration = await this.sessionConfiguration(conversation, cwd);
589
+ // The record of what this session is built with. Written BEFORE the session is acquired, so it
590
+ // exists even when the build fails - a test that wants to know what Super Pi INTENDED to give Pi
591
+ // must be able to ask after a failure too.
592
+ this.builtSessions.set(conversation.id, configuration);
593
+ const session = await this.sessionPool.acquire({
594
+ conversationId: conversation.id,
595
+ cwd: resolve(cwd),
596
+ agentId: conversation.agentId ?? null,
597
+ extensionEnv: configuration.extensionEnv,
598
+ ...(configuration.tools ? { tools: configuration.tools } : {}),
599
+ ...(configuration.excludeTools ? { excludeTools: configuration.excludeTools } : {}),
600
+ ...(configuration.systemPromptFile ? { systemPromptFile: configuration.systemPromptFile } : {}),
601
+ ...(configuration.systemPromptMode ? { systemPromptMode: configuration.systemPromptMode } : {}),
602
+ ...(configuration.model ? { model: configuration.model } : {}),
603
+ ...(configuration.thinkingLevel ? { thinkingLevel: configuration.thinkingLevel } : {}),
604
+ });
605
+ traceLatency(conversation.id, "session.acquired", latencyStartedAt);
606
+ // THE SESSION-FILE LOCATOR. `revert` and `fork` open the conversation's transcript by PATH, and the
607
+ // path is a fact the session already knows: its SessionManager resolves it at creation, before any
608
+ // byte is written. Reported when it CHANGES rather than once per turn - the path is stable for the
609
+ // life of a session, and a rebuild (revert, eviction, fingerprint change) is exactly when the cached
610
+ // locator needs refreshing. Best-effort, like the RPC path's get_state read: a failed report must not
611
+ // fail the turn, it only means the next revert pays for the spawn fallback.
612
+ if (input.onSessionFile && typeof session.sessionFile === "string" && session.sessionFile
613
+ && this.reportedSessionFiles.get(conversation.id) !== session.sessionFile) {
614
+ try {
615
+ await input.onSessionFile(session.sessionFile);
616
+ this.reportedSessionFiles.set(conversation.id, session.sessionFile);
617
+ }
618
+ catch (error) {
619
+ console.warn(`PiRpcRuntime: could not record the session file of conversation ${conversation.id} (${error instanceof Error ? error.message : "unknown error"}) - the next revert will locate it the slow way.`);
620
+ }
621
+ }
622
+ // THE EXTENSION-UI BRIDGE, bound ONCE per session. Pi's extensions ask their blocking questions
623
+ // through the session's UI context, which starts life as a no-op that silently answers
624
+ // `undefined`/`false` - and for `confirm` a silent `false` is a fabricated "no" the user never gave.
625
+ // The bridge forwards to the host of whichever turn is running RIGHT NOW (registered below), and
626
+ // answers `cancelled` when there is none - the same rule the RPC path's no-host branch follows.
627
+ if (session.extensionRunner && !this.uiBoundSessions.has(session)) {
628
+ this.uiBoundSessions.add(session);
629
+ try {
630
+ session.extensionRunner.setUIContext(this.extensionUiBridge(conversation.id), "rpc");
631
+ }
632
+ catch (error) {
633
+ console.warn(`PiRpcRuntime: could not bind the extension UI bridge for conversation ${conversation.id}: ${error instanceof Error ? error.message : "unknown error"}. Pi extensions that ask questions will get cancelled answers.`);
634
+ }
635
+ }
636
+ if (input.host)
637
+ this.sessionHosts.set(conversation.id, input.host);
638
+ // WHERE THIS TURN STARTS in Pi's own transcript, captured BEFORE the prompt. The one-shot path asked
639
+ // Pi over RPC (`get_entries` for the leaf, then `get_entries(since: leaf)` after the turn); a live
640
+ // session holds the same entries in memory, so the same two reads are synchronous. `null` is a real
641
+ // answer - an empty session, so the whole branch is this turn's; `undefined` means the session has no
642
+ // transcript to read (a test double), and nothing is recorded.
643
+ const transcript = session.sessionManager;
644
+ let turnStartLeaf;
645
+ if (input.onUserEntry && transcript) {
646
+ try {
647
+ turnStartLeaf = transcript.getLeafId();
648
+ }
649
+ catch (error) {
650
+ console.warn(`PiRpcRuntime: could not read the leaf before the turn of conversation ${conversation.id} (${error instanceof Error ? error.message : "unknown error"}) - this turn's message may not become a revert boundary.`);
651
+ }
652
+ }
653
+ const queue = new TurnQueue();
654
+ const handlers = {
655
+ onToolCall: input.onToolCall,
656
+ onToolUpdate: input.onToolUpdate,
657
+ onToolPreparing: input.onToolPreparing,
658
+ onToolEnd: input.onToolEnd,
659
+ onThinking: input.onThinking,
660
+ onThinkingEnd: input.onThinkingEnd,
661
+ onSteerDelivered: input.onSteerDelivered,
662
+ onPiQueue: input.onPiQueue,
663
+ };
664
+ const announced = new Set();
665
+ const report = (key) => {
666
+ if (announced.has(key))
667
+ return;
668
+ announced.add(key);
669
+ console.warn(`PiRpcRuntime: unhandled Pi session event ${key} - consumed without action.`);
670
+ };
671
+ const unsubscribe = session.subscribe((event) => {
672
+ try {
673
+ handleSessionEvent(event, queue, handlers, report);
674
+ }
675
+ catch (error) {
676
+ console.warn(`PiRpcRuntime: the session event handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
677
+ }
678
+ });
679
+ // The abort. The caller's signal is the turn's, and aborting the session is what makes the model
680
+ // stop. The session is NOT destroyed: it is the next turn's session.
681
+ //
682
+ // The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
683
+ // on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
684
+ // flight" are two different facts, and only the second one is Pi's business.
685
+ //
686
+ // `abortDone` is the abort's OWN promise, kept so `stop()` can await Pi actually going idle rather
687
+ // than merely having been ASKED to. `AgentSession.abort()` resolves when the session is idle, which
688
+ // is the fact an interrupt needs before the next prompt: prompting earlier is refused by Pi with
689
+ // "Agent is already processing".
690
+ const turnController = new AbortController();
691
+ let abortDone = Promise.resolve();
692
+ const abortTurn = () => {
693
+ if (turnController.signal.aborted)
694
+ return;
695
+ turnController.abort();
696
+ abortDone = session.abort().catch((error) => {
697
+ console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
698
+ });
699
+ };
700
+ const onAbort = () => { abortTurn(); };
701
+ if (signal.aborted)
702
+ abortTurn();
703
+ else
704
+ signal.addEventListener("abort", onAbort, { once: true });
705
+ let firstTextSeen = false;
706
+ const finished = (async () => {
707
+ try {
708
+ await session.prompt(prompt, images?.length ? { images: images } : undefined);
709
+ traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt);
710
+ // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
711
+ // been ACCEPTED and the run has been started; the agent is still streaming, still executing
712
+ // tools, still settling. A turn that ended here would release the reader while the model was
713
+ // still producing, and the NEXT turn would arrive at a session Pi considers busy - which Pi
714
+ // refuses with "Agent is already processing".
715
+ //
716
+ // The turn's end is the `agent_settled` EVENT, and the queue is what carries it: the bridge
717
+ // pushes the terminal item when Pi settles, and the consumer below reads it. This promise
718
+ // waits for that signal, and the race with the prompt's own failure is what keeps a turn that
719
+ // never settles from hanging forever - a hung turn is a conversation that never answers.
720
+ await Promise.race([
721
+ queue.settledSignal,
722
+ // The safety net. A session that was aborted, or a Pi that died mid-turn, may never settle,
723
+ // and the turn still has to end - otherwise the conversation is stuck on a turn that will
724
+ // never produce anything and the next message can never be sent.
725
+ new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
726
+ ]);
727
+ traceLatency(conversation.id, "session.settled", latencyStartedAt);
728
+ // THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
729
+ // settled, so every entry this turn wrote - the user message included - is in the transcript.
730
+ // Recorded on abort too, on purpose: an aborted turn still PERSISTED the user message, and a
731
+ // message in the transcript with no recorded id is a message the user can see but never revert
732
+ // to. A prompt that was never accepted wrote nothing, so there is nothing to find.
733
+ if (transcript && turnStartLeaf !== undefined && input.onUserEntry) {
734
+ await this.recordSessionTurnEntryId(transcript, turnStartLeaf, conversation.id, input.onUserEntry);
735
+ }
736
+ }
737
+ catch (error) {
738
+ queue.fail(error instanceof Error ? error : new Error(String(error)));
739
+ }
740
+ })();
741
+ this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
742
+ try {
743
+ for (;;) {
744
+ const item = await queue.next();
745
+ if (item.kind === "text") {
746
+ if (!firstTextSeen) {
747
+ firstTextSeen = true;
748
+ traceLatency(conversation.id, "session.first-text", latencyStartedAt);
749
+ }
750
+ yield item.delta;
751
+ continue;
752
+ }
753
+ if (item.kind === "error")
754
+ throw item.error;
755
+ break;
756
+ }
757
+ }
758
+ finally {
759
+ signal.removeEventListener("abort", onAbort);
760
+ unsubscribe();
761
+ this.activeTurns.delete(conversation.id);
762
+ // The host registration belongs to THIS turn: an extension question that arrives after it ends
763
+ // finds no host and is answered `cancelled`, which is the honest answer to "nobody is being asked".
764
+ if (input.host)
765
+ this.sessionHosts.delete(conversation.id);
766
+ await finished.catch(() => undefined);
767
+ }
768
+ }
769
+ /**
770
+ * The Extension UI context a session's extensions talk to, bridged to the host of the turn that is
771
+ * running right now.
772
+ *
773
+ * THE SHAPES MIRROR PI'S OWN RPC UI CONTEXT (rpc-mode.ts), method for method, because the extensions
774
+ * were written against that contract: `select`/`input`/`editor` resolve to the chosen text or
775
+ * `undefined` on cancel, `confirm` resolves to a boolean with cancel reading as `false` (Pi's own
776
+ * mapping, not an invention), and the one-way methods are fire-and-forget. The TUI-only methods
777
+ * (`setWorkingMessage`, footers, headers, `custom`, ...) are the same no-ops they are in RPC mode.
778
+ *
779
+ * A BLOCKING CALL WITH NO HOST IS CANCELLED, NEVER DEFAULTED. The no-op context Pi starts with answers
780
+ * `confirm` with `false`, which is indistinguishable from the user clicking "no" - the exact failure
781
+ * the "cancelled, never a default" rule exists to prevent. So an idle session (no turn, no host) and a
782
+ * failed host both answer `cancelled`, and the reason is logged.
783
+ */
784
+ extensionUiBridge(conversationId) {
785
+ const ask = async (request) => {
786
+ const host = this.sessionHosts.get(conversationId);
787
+ if (!host) {
788
+ console.warn(`PiRpcRuntime: an extension asked ${request.method} on conversation ${conversationId} with no turn running - cancelled rather than answering on the user's behalf.`);
789
+ return { cancelled: true };
790
+ }
791
+ try {
792
+ return await host.ask({ ...request, requestId: randomUUID(), blocking: true });
793
+ }
794
+ catch (error) {
795
+ console.warn(`PiRpcRuntime: the UI host failed a ${request.method} for conversation ${conversationId} (${error instanceof Error ? error.message : "unknown error"}) - cancelled.`);
796
+ return { cancelled: true };
797
+ }
441
798
  };
799
+ const notify = (request) => {
800
+ try {
801
+ this.sessionHosts.get(conversationId)?.notify({ ...request, requestId: randomUUID(), blocking: false });
802
+ }
803
+ catch (error) {
804
+ console.warn(`PiRpcRuntime: the UI host threw on a ${request.method} notification for conversation ${conversationId}: ${error instanceof Error ? error.message : "unknown error"}`);
805
+ }
806
+ };
807
+ /** Pi passes its dialog timeout straight through; `ExtensionUiRequest.timeoutMs` is the same number. */
808
+ const timeoutOf = (opts) => {
809
+ const timeout = opts?.timeout;
810
+ return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0 ? { timeoutMs: timeout } : {};
811
+ };
812
+ return {
813
+ select: async (title, options, opts) => {
814
+ const answer = await ask({ method: "select", title: typeof title === "string" ? title : "", options: Array.isArray(options) ? options.filter((item) => typeof item === "string") : [], ...timeoutOf(opts) });
815
+ return answer.cancelled ? undefined : answer.value;
816
+ },
817
+ confirm: async (title, message, opts) => {
818
+ const answer = await ask({ method: "confirm", title: typeof title === "string" ? title : "", message: typeof message === "string" ? message : "", ...timeoutOf(opts) });
819
+ return answer.cancelled ? false : answer.confirmed === true;
820
+ },
821
+ input: async (title, placeholder, opts) => {
822
+ const answer = await ask({ method: "input", title: typeof title === "string" ? title : "", ...(typeof placeholder === "string" ? { placeholder } : {}), ...timeoutOf(opts) });
823
+ return answer.cancelled ? undefined : answer.value;
824
+ },
825
+ editor: async (title, prefill) => {
826
+ const answer = await ask({ method: "editor", title: typeof title === "string" ? title : "", ...(typeof prefill === "string" ? { prefill } : {}) });
827
+ return answer.cancelled ? undefined : answer.value;
828
+ },
829
+ notify: (message, type) => notify({ method: "notify", title: "", message: typeof message === "string" ? message : "", notifyType: (type === "warning" || type === "error" ? type : "info") }),
830
+ onTerminalInput: () => () => { },
831
+ setStatus: (key, text) => notify({ method: "setStatus", title: "", statusKey: typeof key === "string" ? key : "", ...(typeof text === "string" ? { statusText: text } : {}) }),
832
+ setWidget: (key, content, options) => {
833
+ if (content !== undefined && !Array.isArray(content))
834
+ return; // component factories need the TUI
835
+ const placement = options?.placement;
836
+ notify({ method: "setWidget", title: "", widgetKey: typeof key === "string" ? key : "", ...(Array.isArray(content) ? { widgetLines: content.filter((line) => typeof line === "string") } : {}), ...(placement === "aboveEditor" || placement === "belowEditor" ? { widgetPlacement: placement } : {}) });
837
+ },
838
+ setTitle: (title) => notify({ method: "setTitle", title: typeof title === "string" ? title : "" }),
839
+ setEditorText: (text) => notify({ method: "set_editor_text", title: "", ...(typeof text === "string" ? { text } : {}) }),
840
+ pasteToEditor: (text) => notify({ method: "set_editor_text", title: "", ...(typeof text === "string" ? { text } : {}) }),
841
+ // The rest need the TUI or have no web meaning; they are the same no-ops RPC mode provides.
842
+ setWorkingMessage: () => { },
843
+ setWorkingVisible: () => { },
844
+ setWorkingIndicator: () => { },
845
+ setHiddenThinkingLabel: () => { },
846
+ setFooter: () => { },
847
+ setHeader: () => { },
848
+ custom: async () => undefined,
849
+ getEditorText: () => "",
850
+ addAutocompleteProvider: () => { },
851
+ setEditorComponent: () => { },
852
+ getEditorComponent: () => undefined,
853
+ };
854
+ }
855
+ /**
856
+ * Records the id of the user message this turn wrote, read from the live session's own transcript.
857
+ *
858
+ * THE SAME CONTRACT AS `recordTurnEntryId` ON THE ONE-SHOT PATH, over memory instead of RPC: the
859
+ * captured pre-turn leaf is what makes the read exact, because the entries written after it belong to
860
+ * THIS turn, and the first USER message among them is the one Super Pi just pushed. The three outcomes
861
+ * are the same three:
862
+ *
863
+ * - leaf `null` - the session was empty, so the whole branch is this turn's.
864
+ * - leaf found in the branch - everything after it is this turn's.
865
+ * - leaf NOT found - the transcript moved underneath us (a compaction rewrite, a branch switch), so
866
+ * nothing can be attributed to this turn with certainty and nothing is recorded. Guessing here would
867
+ * hand back an EARLIER turn's id, and a revert to it would silently cut more than the user asked for.
868
+ *
869
+ * Every failure is a warning, never a throw: the answer has already streamed by this point, and "this
870
+ * message cannot be a revert boundary" is not "your message failed".
871
+ */
872
+ async recordSessionTurnEntryId(transcript, leaf, conversationId, onUserEntry) {
873
+ try {
874
+ const branch = transcript.getBranch();
875
+ let startIndex = 0;
876
+ if (leaf !== null) {
877
+ const at = branch.findIndex((entry) => entry?.id === leaf);
878
+ if (at === -1) {
879
+ console.warn(`PiRpcRuntime: the entry this turn started from is no longer in the transcript of conversation ${conversationId}, so its message was not recorded and cannot be used as a revert boundary.`);
880
+ return;
881
+ }
882
+ startIndex = at + 1;
883
+ }
884
+ const firstUser = branch.slice(startIndex).find((entry) => {
885
+ const candidate = entry;
886
+ return candidate?.type === "message" && candidate.message?.role === "user" && typeof candidate.id === "string";
887
+ });
888
+ if (!firstUser) {
889
+ console.warn(`PiRpcRuntime: the entries this turn of conversation ${conversationId} wrote contain no user message, so nothing was recorded as a revert boundary.`);
890
+ return;
891
+ }
892
+ await onUserEntry(firstUser.id);
893
+ }
894
+ catch (error) {
895
+ console.warn(`PiRpcRuntime: could not record this turn's Pi entry id for conversation ${conversationId} (${error instanceof Error ? error.message : "unknown error"}) - its message cannot be used as a revert boundary.`);
896
+ }
897
+ }
898
+ /**
899
+ * The tool names Super Pi's own extensions contribute, for the agent's allowlist.
900
+ *
901
+ * The same list the spawn line used to carry, so an agent that declares `tools: {"*": false, "read":
902
+ * true}` gets exactly the same tools it used to get - and an agent that says `task: false` still wins,
903
+ * because `toolFlags` treats an explicit false as outranking a host extra.
904
+ */
905
+ extensionToolNames() {
906
+ const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"];
907
+ if (this.memory)
908
+ names.push("memory", "skill_manage", "session_search");
909
+ if (this.knowledge)
910
+ names.push(KNOWLEDGE_TOOL_NAME);
911
+ return names;
912
+ }
913
+ async *answer(input) {
914
+ // The one-shot paths keep their own child process, and they are NOT the same thing as a conversation
915
+ // turn: a memory review and a knowledge reranker have their own ids, their own tool sets and their own
916
+ // lifetimes, and folding them into a conversation's session would give them that conversation's
917
+ // context. They are identified by the flags that describe exactly that.
918
+ if (input.memoryReview || input.knowledgeRerank) {
919
+ yield* this.answerOneShot(input);
920
+ return;
921
+ }
922
+ // AN EXPLICITLY NAMED PI MEANS THE ONE-SHOT PATH, and this is a rule about the user's CONFIGURATION
923
+ // rather than about speed.
924
+ //
925
+ // `SUPER_PI_PI_COMMAND` says "use THIS Pi". The session path does not use a Pi command at all: it
926
+ // imports Pi as a library, and the library it imports is the one installed next to Super Pi. So a
927
+ // server configured with an explicit command would silently ignore it - the user would point Super Pi
928
+ // at one Pi and get a different one, with nothing saying so.
929
+ //
930
+ // A named command is therefore honoured: the child process IS that command, and the one-shot path is
931
+ // the only path that can promise that. This also keeps the test suite's fake Pi meaningful, because
932
+ // a test that names a fake Pi is naming a program that speaks the RPC protocol, not a library.
933
+ if (this.pi.found && this.pi.explicit) {
934
+ yield* this.answerOneShot(input);
935
+ return;
936
+ }
937
+ // The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
938
+ // falls back to the one-shot path rather than failing, and the reason is reported once - a server
939
+ // that cannot use the SDK still serves conversations, just at the old speed.
940
+ const support = await this.sessionSupport();
941
+ if (!support.available) {
942
+ if (!this.warnedMissingSdk) {
943
+ this.warnedMissingSdk = true;
944
+ console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
945
+ }
946
+ yield* this.answerOneShot(input);
947
+ return;
948
+ }
949
+ yield* this.answerOnSession(input);
442
950
  }
443
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
951
+ /**
952
+ * The one-shot path: a fresh child process for a single turn.
953
+ *
954
+ * Still used for the memory review, the knowledge reranker, and as the fallback when Pi's SDK is not
955
+ * installed. The main conversation path does NOT come here - see `answer`.
956
+ */
957
+ async *answerOneShot({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
444
958
  const latencyStartedAt = performance.now();
445
959
  traceLatency(conversation.id, "answer.begin", latencyStartedAt);
446
960
  await mkdir(this.sessionDir, { recursive: true });
@@ -515,7 +1029,7 @@ export class PiRpcRuntime {
515
1029
  // every turn would silently replace the child's environment with whatever THIS process has at
516
1030
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
517
1031
  // but the four documented additions.
518
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.taskExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
1032
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.oneShotExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
519
1033
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
520
1034
  SUPER_PI_MEMORY_TOKEN: memoryToken,
521
1035
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
@@ -928,9 +1442,10 @@ export class PiRpcRuntime {
928
1442
  finally {
929
1443
  signal.removeEventListener("abort", abort);
930
1444
  this.runs.delete(conversation.id);
931
- // The capability dies with the turn that was given it. In this `finally`, so it happens on every
1445
+ // The capability dies with the CHILD that was given it. In this `finally`, so it happens on every
932
1446
  // exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
933
- // credential whose turn has ended is a credential with no owner.
1447
+ // credential whose process has ended is a credential with no owner. The one-shot path is the only
1448
+ // place this still holds literally: on the session path the child does not die with the turn.
934
1449
  releaseCapability(conversation.id);
935
1450
  if (memoryToken)
936
1451
  this.memory?.release(memoryToken);
@@ -1056,7 +1571,7 @@ export class PiRpcRuntime {
1056
1571
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1057
1572
  */
1058
1573
  async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
1059
- if (this.runs.has(conversation.id))
1574
+ if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
1060
1575
  throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
1061
1576
  if (!this.pi.found)
1062
1577
  throw new Error(this.pi.reason);
@@ -1069,6 +1584,14 @@ export class PiRpcRuntime {
1069
1584
  const exists = await stat(cached).catch(() => undefined);
1070
1585
  if (exists?.isFile()) {
1071
1586
  const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
1587
+ // THE SESSION MUST GO. The truncation above changed the transcript ON DISK, and a live session
1588
+ // holds the OLD context in memory. Nothing in Pi would notice the difference: the model would
1589
+ // keep answering from history the user has just withdrawn, while `state.json` and the file on
1590
+ // disk both said it was gone. Three views of the conversation, two of them wrong, and no error
1591
+ // anywhere - so the session is destroyed here and the next turn rebuilds it from the truncated
1592
+ // file. This is the one place where eviction is a correctness requirement and not an
1593
+ // optimization.
1594
+ await this.sessionPool.evict(conversation.id);
1072
1595
  return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
1073
1596
  }
1074
1597
  }
@@ -1285,6 +1808,21 @@ export class PiRpcRuntime {
1285
1808
  reply(buildExtensionUiResponse(request, answer));
1286
1809
  }
1287
1810
  async stop(conversationId) {
1811
+ // A turn on a persistent session is stopped by aborting THAT SESSION, and the session survives - it
1812
+ // is the next turn's session. This is the whole reason an interrupt no longer costs a process
1813
+ // teardown and a process startup: there is no process to tear down.
1814
+ const turn = this.activeTurns.get(conversationId);
1815
+ if (turn) {
1816
+ turn.abort();
1817
+ // WAIT FOR PI TO ACTUALLY GO IDLE, not merely to have been asked. `AgentSession.abort()` resolves
1818
+ // when the session is idle, and idle is the fact the caller needs: an interrupt that starts the
1819
+ // next turn before this one has unwound is refused by Pi with "Agent is already processing". The
1820
+ // backstop keeps a wedged session from hanging a stop forever - the turn's own 5s safety net then
1821
+ // still ends it.
1822
+ await Promise.race([turn.abortDone, new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000))]);
1823
+ return;
1824
+ }
1825
+ // The one-shot path: a child process that has to be told to abort over its stdin.
1288
1826
  const run = this.runs.get(conversationId);
1289
1827
  if (!run)
1290
1828
  return;
@@ -1441,7 +1979,43 @@ export class PiRpcRuntime {
1441
1979
  await killTree(child.pid);
1442
1980
  }
1443
1981
  }
1982
+ /**
1983
+ * The live session for a conversation, if there is one.
1984
+ *
1985
+ * Throws when there is none, because every caller wants a session it can act on - a steer that has
1986
+ * nowhere to go is a refusal the caller turns into "this message could not be delivered", not a
1987
+ * silent success.
1988
+ */
1989
+ async sessionFor(conversationId) {
1990
+ const session = this.sessionPool.get(conversationId);
1991
+ if (!session)
1992
+ throw new SteerUnavailableError(`会话 ${conversationId} 还没有可用的 Pi 会话`);
1993
+ return session;
1994
+ }
1444
1995
  async steer(conversationId, message, images) {
1996
+ // THE SESSION PATH. A steer on a persistent session is a `prompt` with `streamingBehavior:
1997
+ // "steer"` - the same queueing semantics Pi's RPC `steer` command has, expressed the way the SDK
1998
+ // takes it. The session is NOT aborted and NOT given a second writer: the message joins the turn
1999
+ // that is already running, and Pi delivers it at the next turn boundary.
2000
+ //
2001
+ // THE DISPOSITION. The SDK does not answer a steer with a disposition the way the RPC command does,
2002
+ // so it is derived from what the SDK can tell us: a steer accepted while a turn is running is
2003
+ // `queued` (Pi will deliver it at the next boundary), and one accepted with nothing running is
2004
+ // `handled`. The distinction is the one the caller needs: a queued message is still cancellable
2005
+ // and has NOT yet reached the model.
2006
+ if (this.activeTurns.has(conversationId)) {
2007
+ const session = await this.sessionFor(conversationId);
2008
+ const imagePayloads = this.options.uploadsDir && images?.length
2009
+ ? await loadConversationImages(this.options.uploadsDir, conversationId, images)
2010
+ : undefined;
2011
+ try {
2012
+ await session.prompt(message, { ...(imagePayloads ? { images: imagePayloads } : {}), streamingBehavior: "steer" });
2013
+ }
2014
+ catch (error) {
2015
+ throw error instanceof Error ? error : new Error(String(error));
2016
+ }
2017
+ return "queued";
2018
+ }
1445
2019
  const run = this.runs.get(conversationId);
1446
2020
  if (!run || !this.canSteer(conversationId))
1447
2021
  throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 进程已结束`);