@evandu/super-pi 0.1.94 → 0.1.96

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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * THE SESSION LAYER: one Pi session per Super Pi conversation, alive across turns.
3
+ *
4
+ * WHY THIS FILE EXISTS. Super Pi used to spawn a fresh `pi --mode rpc` process for every turn and kill
5
+ * it when the turn ended. That cost ~400ms of spawn plus ~380ms of handshake plus a full replay of the
6
+ * transcript on EVERY message, and it is why an interrupt had to kill a process and start another one:
7
+ * the turn and the process were the same thing. Pi's own `abort` takes 147ms and leaves the session
8
+ * usable, so the process churn was Super Pi's choice, not a property of interrupting.
9
+ *
10
+ * Pi's SDK (`@earendil-works/pi-coding-agent`) is the supported way to embed it - Pi's own RPC docs
11
+ * recommend it for Node applications. A session here is a Pi `AgentSession`: it keeps the conversation
12
+ * context in memory across turns, and `abort()` returns as soon as the agent is idle.
13
+ *
14
+ * WHAT THIS FILE IS NOT. It is not a second runtime. The one-shot paths (memory review, knowledge
15
+ * rerank, autotitle, tool LLM, catalog probe) keep spawning their own throwaway children, because their
16
+ * identity, tool set and lifecycle are deliberately different from the primary agent's and sharing a
17
+ * session with them would be a correctness bug, not an optimization. See `runtime.ts`.
18
+ *
19
+ * THE ONE HARD CONSTRAINT. Pi's agent prompt, its tool allowlist and the extensions it mounts are all
20
+ * decided when the session is CREATED. None of them can be changed on a live session. So a session carries
21
+ * a FINGERPRINT of everything that was decided at creation, and the caller compares fingerprints: a
22
+ * change means the session is stale and must be replaced. `revert` is the sharpest case - it truncates
23
+ * the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
24
+ * would notice the difference.
25
+ */
26
+ import { existsSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ let sdkPromise;
29
+ let sdkFailure;
30
+ async function loadSdk() {
31
+ if (sdkFailure)
32
+ throw new Error(sdkFailure);
33
+ if (!sdkPromise) {
34
+ // The specifier is assembled at runtime on purpose: a static `import("...")` would be resolved by
35
+ // the compiler, which would turn a BROKEN INSTALL into a COMPILE ERROR - and the compiler runs
36
+ // before anything can report the real reason. The name is written out in full here so a reader
37
+ // can see exactly which package is being loaded.
38
+ const specifier = ["@earendil-works", "pi-coding-agent"].join("/");
39
+ sdkPromise = import(/* @vite-ignore */ specifier).then((mod) => mod, (error) => {
40
+ const reason = error instanceof Error ? error.message : String(error);
41
+ sdkFailure = `Super Pi could not load Pi's SDK (@earendil-works/pi-coding-agent): ${reason}. Reinstall Super Pi (npm i -g @evandu/super-pi) so that Pi comes with it, or install it next to Super Pi with \`npm install @earendil-works/pi-coding-agent\`.`;
42
+ throw new Error(sdkFailure);
43
+ });
44
+ }
45
+ return sdkPromise;
46
+ }
47
+ /** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
48
+ export async function piSdkAvailable() {
49
+ try {
50
+ await loadSdk();
51
+ return { available: true };
52
+ }
53
+ catch (error) {
54
+ return { available: false, reason: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ }
57
+ /**
58
+ * Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
59
+ *
60
+ * The array fields are compared as SETS, not sequences: the tool allowlist is a set of names, and a
61
+ * caller that happens to list them in a different order is asking for the same session, not a new one.
62
+ * Getting this wrong in the strict direction would throw away a perfectly good session on every turn
63
+ * (a latency bug that looks like nothing at all), and getting it wrong in the loose direction would keep
64
+ * a session whose allowlist had genuinely changed (a permission bug). So: order-insensitive, and every
65
+ * element compared exactly.
66
+ */
67
+ export function sameFingerprint(a, b) {
68
+ if (a.cwd !== b.cwd)
69
+ return false;
70
+ if (a.agentId !== b.agentId)
71
+ return false;
72
+ if (!sameSet(a.tools, b.tools))
73
+ return false;
74
+ if (!sameSet(a.excludeTools, b.excludeTools))
75
+ return false;
76
+ if (!sameSet(a.extensions, b.extensions))
77
+ return false;
78
+ if (a.model?.provider !== b.model?.provider || a.model?.modelId !== b.model?.modelId)
79
+ return false;
80
+ if (a.thinkingLevel !== b.thinkingLevel)
81
+ return false;
82
+ return true;
83
+ }
84
+ function sameSet(a, b) {
85
+ if (a === undefined || b === undefined)
86
+ return a === b;
87
+ if (a.length !== b.length)
88
+ return false;
89
+ const left = [...a].sort();
90
+ const right = [...b].sort();
91
+ return left.every((item, index) => item === right[index]);
92
+ }
93
+ /**
94
+ * Runs `work` with the given environment applied, and restores the previous values afterwards.
95
+ *
96
+ * The values are applied SYNCHRONOUSLY and the whole of `work` is awaited inside the window, because
97
+ * the extension factory runs during the loader's reload and reads the environment at that moment. A
98
+ * concurrent session's environment would otherwise land in the middle of this one.
99
+ */
100
+ export async function withEnvironment(values, work) {
101
+ const previous = new Map();
102
+ for (const [key, value] of Object.entries(values)) {
103
+ previous.set(key, process.env[key]);
104
+ process.env[key] = value;
105
+ }
106
+ try {
107
+ return await work();
108
+ }
109
+ finally {
110
+ for (const [key, value] of previous) {
111
+ if (value === undefined)
112
+ delete process.env[key];
113
+ else
114
+ process.env[key] = value;
115
+ }
116
+ }
117
+ }
118
+ export async function createPiSession(creation) {
119
+ const sdk = await loadSdk();
120
+ const agentDir = creation.agentDir ?? defaultAgentDir();
121
+ return withEnvironment(creation.extensionEnv, async () => {
122
+ // THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
123
+ // that REPLACES Pi's own, and an `appendSystemPrompt` list of files that are ADDED to it. An agent
124
+ // that says `promptMode: replace` gets the first, and one that says nothing gets the second - the
125
+ // same two meanings the `--system-prompt` and `--append-system-prompt` flags carried, and the same
126
+ // distinction: an append keeps Pi's own coding-agent baseline (its tool guidance, its environment
127
+ // facts) and adds the role on top.
128
+ const loader = new sdk.DefaultResourceLoader({
129
+ cwd: creation.cwd,
130
+ agentDir,
131
+ ...(creation.extensionPaths.length ? { additionalExtensionPaths: creation.extensionPaths } : {}),
132
+ ...(creation.systemPromptFile && creation.systemPromptMode === "replace"
133
+ ? { systemPrompt: creation.systemPromptFile }
134
+ : creation.systemPromptFile
135
+ ? { appendSystemPrompt: [creation.systemPromptFile] }
136
+ : {}),
137
+ });
138
+ await loader.reload();
139
+ const sessionManager = creation.persist === false
140
+ ? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
141
+ : await resolveSessionManager(sdk, creation);
142
+ const modelRuntime = await sdk.ModelRuntime.create();
143
+ // THE MODEL IS RESOLVED, NOT PASSED THROUGH. Pi's SDK takes a whole Model object - the provider's
144
+ // configuration, the endpoint, the capability list - and Super Pi only knows the pair
145
+ // (provider, modelId) that the picker shows. The runtime is what turns that pair into the object
146
+ // Pi needs, and it is the same runtime the catalog probe uses, so the model the session runs on is
147
+ // the model the picker listed.
148
+ //
149
+ // A model that cannot be resolved is NOT a reason to fail the session: Pi falls back to its own
150
+ // default, and the turn still runs. Failing here would turn "the picker showed a model this Pi no
151
+ // longer has" into a conversation that cannot be sent at all.
152
+ const model = creation.model
153
+ ? modelRuntime.getModel?.(creation.model.provider, creation.model.modelId)
154
+ : undefined;
155
+ const options = {
156
+ cwd: creation.cwd,
157
+ agentDir,
158
+ modelRuntime,
159
+ resourceLoader: loader,
160
+ sessionManager,
161
+ ...(creation.tools ? { tools: creation.tools } : {}),
162
+ ...(creation.excludeTools ? { excludeTools: creation.excludeTools } : {}),
163
+ ...(model ? { model } : {}),
164
+ ...(creation.thinkingLevel ? { thinkingLevel: creation.thinkingLevel } : {}),
165
+ };
166
+ const { session } = await sdk.createAgentSession(options);
167
+ return session;
168
+ });
169
+ }
170
+ /**
171
+ * Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
172
+ * otherwise.
173
+ *
174
+ * WHY THIS IS NOT JUST `SessionManager.create`. A conversation's transcript outlives its session - Pi
175
+ * writes it to a file, and a session that was evicted (idle, or thrown away by a revert) is rebuilt from
176
+ * that file. So building a session is "reopen the conversation's transcript", and the id is what says
177
+ * which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
178
+ * `fork` able to find the file without guessing at a filename.
179
+ *
180
+ * The existing file is found by EXACT id, not by listing the directory and matching a name. Pi's own
181
+ * lookup (`SessionManager.findById`) is the authority for that, and it also enforces the rule that
182
+ * matters here: a session is only found when it was recorded for the SAME working directory. A
183
+ * conversation whose workspace changed therefore gets a fresh transcript rather than somebody else's.
184
+ */
185
+ async function resolveSessionManager(sdk, creation) {
186
+ const existing = sdk.SessionManager.findById(creation.cwd, creation.sessionId, creation.sessionDir);
187
+ if (existing) {
188
+ try {
189
+ return sdk.SessionManager.open(existing, creation.sessionDir, creation.cwd);
190
+ }
191
+ catch {
192
+ // A file that exists but cannot be opened is not a reason to fail the turn: falling through
193
+ // creates a fresh session, and the reader sees an empty conversation rather than an error. The
194
+ // alternative - refusing - would make a corrupt file permanently break the conversation.
195
+ }
196
+ }
197
+ return sdk.SessionManager.create(creation.cwd, creation.sessionDir, { id: creation.sessionId });
198
+ }
199
+ /**
200
+ * Pi's own global configuration directory, when it can be worked out without importing the SDK.
201
+ *
202
+ * The SDK's `getAgentDir()` is the authority, but asking it would mean importing the SDK - which this
203
+ * file goes out of its way not to do for a path it can derive. The derivation is Pi's own default:
204
+ * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`,
205
+ * because on Windows the two disagree often enough to matter.
206
+ */
207
+ function defaultAgentDir() {
208
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
209
+ return home ? join(home, ".pi", "agent") : join(".pi", "agent");
210
+ }
211
+ /**
212
+ * The Super Pi extension files, as absolute paths.
213
+ *
214
+ * Resolved from the repository root, exactly as `runtime.ts` resolves the `--extension` argument it
215
+ * passes to a child process: Pi resolves a relative `--extension` against the CONVERSATION's workspace
216
+ * rather than against the server's directory, so a relative path would find a different file for every
217
+ * workspace. The SDK takes an absolute path, and this keeps the two paths of the same file.
218
+ */
219
+ export function superPiExtensionPaths(repositoryRoot) {
220
+ const paths = [
221
+ join(repositoryRoot, "agents", "super-pi-task", "index.ts"),
222
+ join(repositoryRoot, "agents", "super-pi-memory", "index.ts"),
223
+ join(repositoryRoot, "agents", "super-pi-knowledge", "index.ts"),
224
+ ];
225
+ return paths.filter((path) => existsSync(path));
226
+ }
@@ -3,6 +3,8 @@ export { RpcChannel, type RpcRecord } from "./rpc-channel.js";
3
3
  import type { ActiveMemory } from "./active-memory.js";
4
4
  import { type KnowledgeBase } from "./knowledge.js";
5
5
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
6
+ export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
7
+ import { type PiSessionFactory } from "./pi-session.js";
6
8
  import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice, type ToolCallPreparing } from "./types.js";
7
9
  /**
8
10
  * Who answers Pi's Extension UI questions. The runtime never invents an answer: a blocking method
@@ -79,18 +81,24 @@ export interface AnswerInput {
79
81
  /**
80
82
  * Called ONCE per turn with the Pi entry id of the user message that started it, or never.
81
83
  *
82
- * This is the whole of Super Pi's ability to name a position inside Pi's transcript, and the window is
83
- * one turn long: `answer()` spawns Pi here and kills it in its `finally`, so an id not taken before the
84
- * loop ends is not obtainable at all. It is taken from Pi's own `get_entries` (`rpc-mode.ts:636-647`),
85
- * not by counting, because Pi writes entry types that are not messages (`model_change`,
86
- * `thinking_level_change`, `compaction`, ...) and only Pi knows which line the user message is.
84
+ * This is the whole of Super Pi's ability to name a position inside Pi's transcript. The one-shot path
85
+ * reads it over RPC (`get_entries` before the prompt, `get_entries(since: leaf)` after the turn); the
86
+ * session path reads the same facts from the live session's own transcript (`getLeafId` before,
87
+ * `getBranch` after). Both read Pi's own entries rather than counting, because Pi writes entry types
88
+ * that are not messages (`model_change`, `thinking_level_change`, `compaction`, ...) and only Pi knows
89
+ * which line the user message is.
87
90
  *
88
91
  * Failing to produce one is NOT a turn failure - the answer has already streamed by then - so it is
89
92
  * never awaited on the critical path and never thrown. A message with no entry id simply cannot be a
90
93
  * revert boundary, which the revert handler reports in words.
91
94
  */
92
95
  onUserEntry?: (entryId: string) => void | Promise<void>;
93
- /** Session file Pi resolved for the live worker. A host may cache it as a locator for a later revert. */
96
+ /**
97
+ * The session file Pi resolved for this conversation. A host caches it as a locator for a later revert
98
+ * or fork. The one-shot path learns it from `get_state`; the session path reads it off the live session
99
+ * and reports it when it CHANGES - a session rebuild (revert, eviction, fingerprint change) is exactly
100
+ * when the cached locator needs refreshing.
101
+ */
94
102
  onSessionFile?: (path: string) => void | Promise<void>;
95
103
  /**
96
104
  * Called once per `tool_execution_start`, with the tool's name and the bounded label naming what it was
@@ -276,6 +284,17 @@ export interface AgentRuntime {
276
284
  * directory themselves, so the picker and the spawn can never disagree about which definition is live.
277
285
  */
278
286
  agentFor?(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
287
+ /**
288
+ * Throws away the live sessions this runtime holds.
289
+ *
290
+ * A runtime that keeps a session alive between turns has to be told, at shutdown, that those
291
+ * sessions should go: a stopped TURN is not a stopped SESSION, and a session nobody stops holds a
292
+ * model client and - on Windows - a lock on the conversation's working directory.
293
+ *
294
+ * Optional, because a runtime with no sessions behind it (the welcome runtime) has nothing to
295
+ * throw away.
296
+ */
297
+ disposeSessions?(conversationId?: string): Promise<void>;
279
298
  /**
280
299
  * The model/thinking catalog a picker lists. `selection` scopes the answer to one model's
281
300
  * capabilities, because Pi's thinking levels are per model (`getSupportedThinkingLevels`,
@@ -326,28 +345,6 @@ export declare class WelcomeRuntime implements AgentRuntime {
326
345
  * through the same channel instead of writing a second JSONL reader. Every field below is optional, so
327
346
  * a reader must check before using one.
328
347
  */
329
- /**
330
- * Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
331
- *
332
- * Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
333
- * objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
334
- * `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
335
- * drain loop can keep using it as the recogniser.
336
- */
337
- export declare function describePiQueue(record: RpcRecord): PiQueueSnapshot | undefined;
338
- /**
339
- * Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
340
- *
341
- * A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
342
- * opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
343
- *
344
- * Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
345
- * back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
346
- * only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
347
- * cannot tell two identical messages apart either, which is why the caller resolves a delivery by
348
- * position and not by comparing this.
349
- */
350
- export declare function describeDeliveredUserMessage(record: RpcRecord): string | undefined;
351
348
  /** A line to write back to Pi's stdin. Mirrors Pi's RpcExtensionUIResponse union (rpc-types.ts:294-297). */
352
349
  export type ExtensionUiResponse = {
353
350
  type: "extension_ui_response";
@@ -450,6 +447,10 @@ export declare class PiRpcRuntime implements AgentRuntime {
450
447
  * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
451
448
  * from "configured wrong". When it is absent the tool still registers and the extension refuses in
452
449
  * words, which is what `refuseTodo` in core.ts is for.
450
+ *
451
+ * `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
452
+ * of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
453
+ * what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
453
454
  */
454
455
  private readonly options;
455
456
  private memory?;
@@ -457,6 +458,43 @@ export declare class PiRpcRuntime implements AgentRuntime {
457
458
  private knowledge?;
458
459
  setKnowledge(knowledge: KnowledgeBase): void;
459
460
  private readonly runs;
461
+ /**
462
+ * THE LIVE SESSIONS, one per conversation, alive across turns.
463
+ *
464
+ * `runs` is the OLD registry - a child that exists only while a turn is running - and it still
465
+ * describes the one-shot paths (memory review, knowledge rerank, catalog probe), which keep their
466
+ * own throwaway children. The main conversation path does NOT use it: a conversation's session lives
467
+ * in the pool, and a turn is a run ON that session rather than a process OF its own.
468
+ */
469
+ private readonly sessionPool;
470
+ /**
471
+ * The turn currently running on each conversation's session.
472
+ *
473
+ * This is the piece the old design did not need, because a turn WAS a process and the process
474
+ * registry said everything. With a session that outlives its turns, the host has to know which turn
475
+ * is on the session RIGHT NOW - that is what an abort targets, and what a steer is delivered into.
476
+ */
477
+ private readonly activeTurns;
478
+ /**
479
+ * THE CURRENT TURN'S EXTENSION-UI HOST, per conversation.
480
+ *
481
+ * Pi's blocking UI questions (`select`/`confirm`/`input`/`editor`) are answered by a host, and the
482
+ * host is a per-TURN thing - `run-service` builds one per `answer()` call - while the UI context the
483
+ * session's extensions talk to is bound ONCE per session. This map is the bridge between the two
484
+ * lifetimes: a turn registers its host here for exactly its own duration, and the session's bound
485
+ * context reads through it at call time. A question asked while no turn is registered gets the honest
486
+ * answer - `cancelled` - never a default, which is the same rule the RPC path's no-host branch follows.
487
+ */
488
+ private readonly sessionHosts;
489
+ /** The sessions whose extension runner already has the UI bridge bound. */
490
+ private readonly uiBoundSessions;
491
+ /**
492
+ * The session-file path last reported for a conversation, so `onSessionFile` fires when it CHANGES
493
+ * rather than once per turn. The path is stable for the life of a session; what changes it is a
494
+ * rebuild (a revert, an eviction, a fingerprint change), which is exactly when the cached locator
495
+ * needs refreshing.
496
+ */
497
+ private readonly reportedSessionFiles;
460
498
  /**
461
499
  * `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
462
500
  *
@@ -475,6 +513,18 @@ export declare class PiRpcRuntime implements AgentRuntime {
475
513
  private readonly pendingCatalogs;
476
514
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
477
515
  private warnedMissingExtension;
516
+ /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
517
+ private warnedMissingSdk;
518
+ /**
519
+ * How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
520
+ *
521
+ * is the slower one, and it exists for the test suite: those suites drive a fake Pi that
522
+ * speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
523
+ * it. It is an explicit request rather than something inferred from which Pi happens to be named,
524
+ * because inferring it is what made the Pi extension start conversations on the slow path without
525
+ * saying why.
526
+ */
527
+ private readonly sessionMode;
478
528
  /**
479
529
  * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
480
530
  * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
@@ -517,6 +567,10 @@ export declare class PiRpcRuntime implements AgentRuntime {
517
567
  * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
518
568
  * from "configured wrong". When it is absent the tool still registers and the extension refuses in
519
569
  * words, which is what `refuseTodo` in core.ts is for.
570
+ *
571
+ * `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
572
+ * of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
573
+ * what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
520
574
  */
521
575
  options?: {
522
576
  endpoint?: string;
@@ -526,7 +580,66 @@ export declare class PiRpcRuntime implements AgentRuntime {
526
580
  questionEndpoint?: string;
527
581
  toolsEndpoint?: string;
528
582
  uploadsDir?: string;
583
+ sessionFactory?: PiSessionFactory;
584
+ sessionMode?: "sdk" | "oneshot";
529
585
  });
586
+ /**
587
+ * The configuration of the session that was built for a conversation, for tests to inspect.
588
+ *
589
+ * WHY THIS EXISTS. The permission boundary - an agent's tool allowlist, its prompt file, the
590
+ * extensions it mounts - used to be proved by reading the ARGV of a spawned child. A session is not
591
+ * a child and has no argv, so the evidence moved: this is what the session was actually BUILT with,
592
+ * which is the same fact stated in the new architecture's terms.
593
+ *
594
+ * It is a RECORD, not a control channel: nothing in the runtime reads it back, and a test that
595
+ * asserts on it is asserting on the real decision the real code made.
596
+ */
597
+ private readonly builtSessions;
598
+ /** The configuration a conversation's session was built with, if one was built. */
599
+ builtSessionConfig(conversationId: string): {
600
+ tools?: string[];
601
+ excludeTools?: string[];
602
+ systemPromptFile?: string;
603
+ systemPromptMode?: "replace" | "append";
604
+ extensionPaths: string[];
605
+ extensionEnv: Record<string, string>;
606
+ model?: {
607
+ provider: string;
608
+ modelId: string;
609
+ };
610
+ thinkingLevel?: string;
611
+ } | undefined;
612
+ /**
613
+ * Which path a CONVERSATION on this runtime takes.
614
+ *
615
+ * Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
616
+ * the version number said 0.1.95 while every conversation was still spawning a process per turn,
617
+ * and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
618
+ */
619
+ get conversationPath(): "sdk" | "oneshot";
620
+ /**
621
+ * Whether the SDK-backed session layer can be used at all.
622
+ *
623
+ * The SDK is a LIBRARY and it is optional: Super Pi also supports running against a Pi installed as a
624
+ * COMMAND, and a server that cannot load the library still has a working one-shot path for the
625
+ * conversations it can serve. The answer is reported rather than thrown, so the caller can say what is
626
+ * missing instead of failing a turn with a stack trace.
627
+ */
628
+ sessionSupport(): Promise<{
629
+ available: true;
630
+ } | {
631
+ available: false;
632
+ reason: string;
633
+ }>;
634
+ /**
635
+ * Throws away every live session.
636
+ *
637
+ * Called at shutdown, and after a `revert` - the transcript on disk changed, and a session holding
638
+ * the old context in memory would keep answering from history the user has withdrawn.
639
+ */
640
+ disposeSessions(conversationId?: string): Promise<void>;
641
+ /** Throws away the sessions that have been idle for a while. */
642
+ evictIdleSessions(): Promise<void>;
530
643
  /**
531
644
  * The agent a conversation runs as, or undefined for Pi's own defaults.
532
645
  *
@@ -565,8 +678,120 @@ export declare class PiRpcRuntime implements AgentRuntime {
565
678
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
566
679
  * own address, so it is the same string on every turn and is read once from the constructor's options.
567
680
  */
681
+ /**
682
+ * The environment Super Pi's extensions read when they are loaded.
683
+ *
684
+ * IT DOES NOT INCLUDE THE CREDENTIAL. The credential is minted by the session pool, when the session
685
+ * is created, and added there - because the credential belongs to the SESSION (the extension reads
686
+ * its environment once, when it is loaded) and a token minted per turn would be replaced before the
687
+ * second turn of the conversation, leaving the extension holding a value the server had forgotten.
688
+ */
568
689
  private taskExtensionEnv;
569
- answer({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }: AnswerInput): AsyncIterable<string>;
690
+ /**
691
+ * The environment for a ONE-SHOT child, which is the same as the session's except that it carries
692
+ * the credential.
693
+ *
694
+ * THE DIFFERENCE IS THE LIFETIME. A one-shot child lives for one turn, so its credential is minted
695
+ * for that turn and dies with the child - which is the original rule, and it is still exactly right
696
+ * here. A session lives for many turns, so its credential is minted when the session is created (see
697
+ * `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
698
+ */
699
+ private oneShotExtensionEnv;
700
+ /**
701
+ * RUNS A TURN ON A PERSISTENT SESSION.
702
+ *
703
+ * This is the path every conversation takes now, and it is where the latency win comes from. There is
704
+ * no process to spawn and none to kill: the conversation's session already exists, its context is
705
+ * already in memory, and a turn is a `prompt()` on it. An interrupt is `abort()` on the same session -
706
+ * which is why an interrupt no longer costs a process teardown and a process startup.
707
+ *
708
+ * THE EVENT ROUTING. Pi pushes events through a subscription; this method pulls them, one at a time,
709
+ * through a queue. The generator yields text deltas to its caller, and every non-text event is
710
+ * delivered to the turn's callbacks as it arrives, so the transcript shows tool calls and reasoning
711
+ * WHILE the model is producing them rather than after the turn ends.
712
+ *
713
+ * THE ABORT. The caller's `signal` is the turn's abort. When it fires, the session is aborted - Pi
714
+ * stops the current operation and waits for the session to go idle - and the turn ends. The session
715
+ * ITSELF survives: it is the next turn's session, and it is the reason the next turn costs no startup.
716
+ */
717
+ /**
718
+ * The configuration a conversation's session is built with.
719
+ *
720
+ * SEPARATED FROM THE BUILD ON PURPOSE, because this is a decision and the build is its consequence.
721
+ * Everything that decides what Pi is allowed to do for a conversation - the agent's allowlist, its
722
+ * prompt file, the extensions it mounts, the model it runs on - is decided HERE, in one place, and
723
+ * the session is then built from it.
724
+ *
725
+ * Two things want that separation. The FIRST is the fingerprint: the decision is what the session is
726
+ * compared against, so a changed decision means the session is stale. The SECOND is the tests: the
727
+ * permission boundary is the most important thing this runtime does, and asserting on the decision
728
+ * is a DIRECT way to check it - a test that has to start a real Pi to learn what Pi was told is a
729
+ * test that only runs on a machine with a Pi.
730
+ */
731
+ sessionConfiguration(conversation: Conversation, cwd: string): Promise<{
732
+ tools?: string[];
733
+ excludeTools?: string[];
734
+ systemPromptFile?: string;
735
+ systemPromptMode?: "replace" | "append";
736
+ extensionPaths: string[];
737
+ extensionEnv: Record<string, string>;
738
+ model?: {
739
+ provider: string;
740
+ modelId: string;
741
+ };
742
+ thinkingLevel?: string;
743
+ }>;
744
+ private answerOnSession;
745
+ /**
746
+ * The Extension UI context a session's extensions talk to, bridged to the host of the turn that is
747
+ * running right now.
748
+ *
749
+ * THE SHAPES MIRROR PI'S OWN RPC UI CONTEXT (rpc-mode.ts), method for method, because the extensions
750
+ * were written against that contract: `select`/`input`/`editor` resolve to the chosen text or
751
+ * `undefined` on cancel, `confirm` resolves to a boolean with cancel reading as `false` (Pi's own
752
+ * mapping, not an invention), and the one-way methods are fire-and-forget. The TUI-only methods
753
+ * (`setWorkingMessage`, footers, headers, `custom`, ...) are the same no-ops they are in RPC mode.
754
+ *
755
+ * A BLOCKING CALL WITH NO HOST IS CANCELLED, NEVER DEFAULTED. The no-op context Pi starts with answers
756
+ * `confirm` with `false`, which is indistinguishable from the user clicking "no" - the exact failure
757
+ * the "cancelled, never a default" rule exists to prevent. So an idle session (no turn, no host) and a
758
+ * failed host both answer `cancelled`, and the reason is logged.
759
+ */
760
+ private extensionUiBridge;
761
+ /**
762
+ * Records the id of the user message this turn wrote, read from the live session's own transcript.
763
+ *
764
+ * THE SAME CONTRACT AS `recordTurnEntryId` ON THE ONE-SHOT PATH, over memory instead of RPC: the
765
+ * captured pre-turn leaf is what makes the read exact, because the entries written after it belong to
766
+ * THIS turn, and the first USER message among them is the one Super Pi just pushed. The three outcomes
767
+ * are the same three:
768
+ *
769
+ * - leaf `null` - the session was empty, so the whole branch is this turn's.
770
+ * - leaf found in the branch - everything after it is this turn's.
771
+ * - leaf NOT found - the transcript moved underneath us (a compaction rewrite, a branch switch), so
772
+ * nothing can be attributed to this turn with certainty and nothing is recorded. Guessing here would
773
+ * hand back an EARLIER turn's id, and a revert to it would silently cut more than the user asked for.
774
+ *
775
+ * Every failure is a warning, never a throw: the answer has already streamed by this point, and "this
776
+ * message cannot be a revert boundary" is not "your message failed".
777
+ */
778
+ private recordSessionTurnEntryId;
779
+ /**
780
+ * The tool names Super Pi's own extensions contribute, for the agent's allowlist.
781
+ *
782
+ * The same list the spawn line used to carry, so an agent that declares `tools: {"*": false, "read":
783
+ * true}` gets exactly the same tools it used to get - and an agent that says `task: false` still wins,
784
+ * because `toolFlags` treats an explicit false as outranking a host extra.
785
+ */
786
+ private extensionToolNames;
787
+ answer(input: AnswerInput): AsyncIterable<string>;
788
+ /**
789
+ * The one-shot path: a fresh child process for a single turn.
790
+ *
791
+ * Still used for the memory review, the knowledge reranker, and as the fallback when Pi's SDK is not
792
+ * installed. The main conversation path does NOT come here - see `answer`.
793
+ */
794
+ private answerOneShot;
570
795
  /**
571
796
  * Reads back the id of the user message this turn just created, and hands it to the caller.
572
797
  *
@@ -703,6 +928,14 @@ export declare class PiRpcRuntime implements AgentRuntime {
703
928
  cwd: string;
704
929
  signal?: AbortSignal;
705
930
  }): Promise<string>;
931
+ /**
932
+ * The live session for a conversation, if there is one.
933
+ *
934
+ * Throws when there is none, because every caller wants a session it can act on - a steer that has
935
+ * nowhere to go is a refusal the caller turns into "this message could not be delivered", not a
936
+ * silent success.
937
+ */
938
+ private sessionFor;
706
939
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
707
940
  /**
708
941
  * The model/thinking catalog, from Pi, cached.