@evandu/super-pi 0.1.97 → 0.1.98

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.
@@ -24,5 +24,5 @@
24
24
  "src/client/types.ts": "6487df00c164370550b5977f9b23b17e22aff4bfb59dfe1e52200042340e3769",
25
25
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
26
26
  },
27
- "recordedAt": "2026-10-20T17:20:14.918Z"
27
+ "recordedAt": "2026-10-21T02:50:44.453Z"
28
28
  }
@@ -6,6 +6,7 @@ import { existsSync } from "node:fs";
6
6
  import { mkdir } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { join, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
9
10
  import { openStateStore } from "./persistence/open-state-store.js";
10
11
  import { PiRpcRuntime, WelcomeRuntime } from "./runtime.js";
11
12
  import { resolveAgentDirectories } from "./agents.js";
@@ -30,6 +31,49 @@ import { ToolLlmService } from "./tool-llm.js";
30
31
  // ------------------------------------------------------------- Environment & paths
31
32
  const rootDir = resolve(import.meta.dirname, "../..");
32
33
  initRunningVersion(rootDir);
34
+ // THE SECURITY PATCH, APPLIED WHERE IT CANNOT BREAK AN INSTALL.
35
+ //
36
+ // Pi's package pins a vulnerable `brace-expansion` in its own shrinkwrapped tree, and npm will not let
37
+ // anything move it - not `overrides`, not `npm audit fix`. So Super Pi replaces that one copy with the
38
+ // patched release itself.
39
+ //
40
+ // It runs HERE, at startup, and not as a `postinstall` script, because a postinstall that fails fails
41
+ // the INSTALL - and on 2026-10-21 it did exactly that on somebody else's machine, leaving them unable
42
+ // to install Super Pi at all. An install must not be breakable by a security patch.
43
+ //
44
+ // It is also deliberately ASYNCHRONOUS and off the startup path: the server is already serving by the
45
+ // time this finishes, and a registry that is slow or unreachable must not delay the panel. The copy is
46
+ // vulnerable for the few seconds until the patch lands, and the patched version is what every later
47
+ // turn uses.
48
+ void (async () => {
49
+ try {
50
+ // The specifier is built at runtime so the compiler does not need the file to exist: it lives in
51
+ // `scripts/`, which is not part of the compiled output.
52
+ const modulePath = resolve(rootDir, "scripts", "patch-brace-expansion.mjs");
53
+ const { patchBraceExpansion } = await import(pathToFileURL(modulePath).href);
54
+ await patchBraceExpansion();
55
+ }
56
+ catch (error) {
57
+ console.warn(`[super-pi] the brace-expansion patch did not run: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ })();
60
+ // PI'S SDK, LOADED WHILE THE SERVER IS STILL STARTING.
61
+ //
62
+ // Importing Pi's SDK costs about 1.5 seconds the first time and is free afterwards, and it was being
63
+ // paid by whichever user action happened to need it first - which, for most people, was opening the
64
+ // model picker and wondering why it stayed grey. Here it is paid during startup, in parallel with
65
+ // everything else the server does, so the first conversation does not pay for it.
66
+ //
67
+ // Warmup only: nothing here is required for the server to run, and a failure is one line.
68
+ void (async () => {
69
+ try {
70
+ const { warmPiSdk } = await import(pathToFileURL(resolve(rootDir, "dist", "server", "pi-session.js")).href);
71
+ await warmPiSdk();
72
+ }
73
+ catch (error) {
74
+ console.warn(`[super-pi] Pi's SDK did not load during warmup: ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ })();
33
77
  const cliArgs = parseServerArgs(process.argv);
34
78
  // A non-numeric SUPER_PI_PORT would parse to NaN and die later in `listen` with a confusing error;
35
79
  // like the other environment variables here, an unusable value falls back to the default port.
@@ -35,7 +35,12 @@ export async function validateConversationModel(store, runtime, conversationId,
35
35
  const workspace = conversationWorkspace(state, conversationId);
36
36
  if (!applicableRules(state.modelFilterRules, workspace).length)
37
37
  return;
38
+ const startedAt = performance.now();
39
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
40
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.begin selection=${selection ? `${selection.provider}/${selection.modelId}` : "default"}`);
38
41
  const catalog = await runtime.catalog();
39
42
  const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
40
43
  assertModelAllowed(store.snapshot().modelFilterRules, conversationWorkspace(store.snapshot(), conversationId), model);
44
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
45
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.end elapsedMs=${Math.round(performance.now() - startedAt)}`);
41
46
  }
@@ -21,7 +21,8 @@ export type PiCommandResolution = {
21
21
  } | {
22
22
  found: false;
23
23
  reason: string;
24
- searched: string[];
24
+ searched: string[]; /** Whether a command was NAMED and did not resolve. A named Pi is a statement about which Pi runs a turn, and it stays true when the file is missing. */
25
+ explicit?: boolean;
25
26
  };
26
27
  /** Overridable so the resolution can be exercised for both platforms and both outcomes without a Pi install. */
27
28
  export interface PiCommandOptions {
@@ -113,8 +113,12 @@ export function resolvePiCommand(options = {}) {
113
113
  return { found: true, command: full, via: label, explicit: true };
114
114
  return {
115
115
  found: false,
116
- reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect in the agent mechanism.`,
116
+ reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect of the agent mechanism.`,
117
117
  searched: [full],
118
+ // STILL EXPLICIT. A Pi was NAMED, and naming one is a statement about which Pi runs a turn -
119
+ // so the fact survives the file being missing, and the honest answer to "that Pi cannot be
120
+ // asked" is "unavailable", never a list belonging to some other Pi.
121
+ explicit: true,
118
122
  };
119
123
  }
120
124
  const hit = firstHit([explicit], [...onPath, ...globalDirs], exists, platform);
@@ -122,8 +126,9 @@ export function resolvePiCommand(options = {}) {
122
126
  return { found: true, command: hit.command, via: `${label} (${hit.dir})`, explicit: true };
123
127
  return {
124
128
  found: false,
125
- reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect in the agent mechanism.`,
129
+ reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect of the agent mechanism.`,
126
130
  searched: [...onPath, ...globalDirs],
131
+ explicit: true,
127
132
  };
128
133
  }
129
134
  // THE PI THAT IS HOSTING SUPER PI, when Super Pi was started from inside Pi itself.
@@ -114,6 +114,18 @@ export declare class PiSessionPool {
114
114
  */
115
115
  acquire(request: SessionRequest): Promise<PiSessionLike>;
116
116
  private build;
117
+ /**
118
+ * Puts a live session on the model and level the conversation is asking for.
119
+ *
120
+ * Best-effort in the way that matters: a session that cannot change its model is still a working
121
+ * session, and refusing the message because Pi declined a model switch would be a worse answer than
122
+ * running on the model that is loaded. So the failure is a warning, and the turn goes on.
123
+ *
124
+ * The comparison is against what the session is ALREADY running, because Pi can report its model in
125
+ * two shapes (an id or a provider/id pair) and a mismatch on shape alone would mean re-setting the
126
+ * model on every single turn.
127
+ */
128
+ private applyModelAndLevel;
117
129
  private fingerprintOf;
118
130
  /**
119
131
  * The live session for a conversation, without building one.
@@ -94,18 +94,28 @@ export class PiSessionPool {
94
94
  const existing = this.sessions.get(request.conversationId);
95
95
  if (existing) {
96
96
  if (sameFingerprint(existing.fingerprint, fingerprint)) {
97
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
98
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.reused`);
97
99
  existing.lastUsedAt = Date.now();
98
100
  // A session that is still being built is joined, not rebuilt: two sends arriving together must
99
101
  // not produce two sessions, and the second one would be a duplicate model client talking to the
100
102
  // same conversation.
101
103
  if (existing.building)
102
104
  return existing.building;
105
+ // THE MODEL AND THE LEVEL ARE APPLIED HERE, TO THE LIVE SESSION. They are not in the
106
+ // fingerprint because Pi can change them in place, and rebuilding the session to honour a
107
+ // change would replay the whole transcript - the single most expensive thing this runtime does.
108
+ // A person who picks a different model in the picker pays a few milliseconds here instead of
109
+ // waiting for their entire history to be re-read on the next message.
110
+ await this.applyModelAndLevel(existing.session, request);
103
111
  return existing.session;
104
112
  }
105
113
  // STALE. The conversation's requirements changed underneath the live session, so it is thrown away
106
114
  // BEFORE the new one is built, and not after - a session that is being disposed of while the new
107
115
  // one starts would have two contexts for one conversation.
108
116
  await this.evict(request.conversationId);
117
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
118
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.rebuild`);
109
119
  }
110
120
  // THE CREDENTIAL IS MINTED HERE, WHERE THE SESSION IS BORN. The capability travels in the
111
121
  // extension's environment, and the extension reads its environment when it is LOADED - which happens
@@ -119,9 +129,13 @@ export class PiSessionPool {
119
129
  const capability = this.mintCapability(request.conversationId);
120
130
  const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability };
121
131
  const building = this.build({ ...request, extensionEnv }, fingerprint);
132
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
133
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.begin`);
122
134
  this.sessions.set(request.conversationId, { session: undefined, fingerprint, lastUsedAt: Date.now(), building });
123
135
  try {
124
136
  const session = await building;
137
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
138
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.end`);
125
139
  const entry = this.sessions.get(request.conversationId);
126
140
  if (entry) {
127
141
  entry.session = session;
@@ -156,15 +170,53 @@ export class PiSessionPool {
156
170
  };
157
171
  return this.factory(creation);
158
172
  }
173
+ /**
174
+ * Puts a live session on the model and level the conversation is asking for.
175
+ *
176
+ * Best-effort in the way that matters: a session that cannot change its model is still a working
177
+ * session, and refusing the message because Pi declined a model switch would be a worse answer than
178
+ * running on the model that is loaded. So the failure is a warning, and the turn goes on.
179
+ *
180
+ * The comparison is against what the session is ALREADY running, because Pi can report its model in
181
+ * two shapes (an id or a provider/id pair) and a mismatch on shape alone would mean re-setting the
182
+ * model on every single turn.
183
+ */
184
+ async applyModelAndLevel(session, request) {
185
+ const wanted = request.model;
186
+ if (wanted && session.setModel) {
187
+ const running = session.model;
188
+ const same = running?.provider === wanted.provider && (running?.id ?? running?.id) === wanted.modelId;
189
+ if (!same) {
190
+ try {
191
+ await session.setModel({ provider: wanted.provider, id: wanted.modelId });
192
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
193
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId}`);
194
+ }
195
+ catch (error) {
196
+ console.warn(`PiSessionPool: conversation ${request.conversationId} asked for ${wanted.provider}/${wanted.modelId} and Pi declined: ${error instanceof Error ? error.message : String(error)}. The turn will run on the model already loaded.`);
197
+ }
198
+ }
199
+ }
200
+ if (request.thinkingLevel && session.setThinkingLevel) {
201
+ try {
202
+ session.setThinkingLevel(request.thinkingLevel);
203
+ }
204
+ catch (error) {
205
+ console.warn(`PiSessionPool: conversation ${request.conversationId} asked for thinking level ${request.thinkingLevel} and Pi declined: ${error instanceof Error ? error.message : String(error)}.`);
206
+ }
207
+ }
208
+ }
159
209
  fingerprintOf(request) {
210
+ // The model and the thinking level are NOT here, and that is deliberate: Pi changes both on a
211
+ // live session, so they are applied to it rather than forcing a rebuild that would replay the whole
212
+ // transcript. The session is still CREATED with them (see `build`) - it just is not thrown away
213
+ // when they change.
160
214
  return {
161
215
  cwd: request.cwd,
162
216
  agentId: request.agentId,
163
217
  tools: request.tools,
164
218
  excludeTools: request.excludeTools,
165
219
  extensions: superPiExtensionPaths(this.repositoryRoot),
166
- model: request.model,
167
- thinkingLevel: request.thinkingLevel,
168
220
  };
169
221
  }
170
222
  /**
@@ -23,6 +23,83 @@
23
23
  * the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
24
24
  * would notice the difference.
25
25
  */
26
+ /**
27
+ * Loads Pi's SDK while the server is still starting.
28
+ *
29
+ * Importing Pi costs about 1.5 seconds once and is free afterwards, and it used to be paid by whatever
30
+ * user action needed it FIRST - which for most people was opening the model picker and watching it stay
31
+ * grey. Paying it here, during startup, moves it off the path a person can see.
32
+ *
33
+ * Safe to call more than once, and safe to call on a server that never needs the SDK: it loads the
34
+ * module and returns, and the caller decides what to do with a failure.
35
+ */
36
+ export declare function warmPiSdk(): Promise<boolean>;
37
+ /**
38
+ * A model catalog read from Pi's SDK, with NO PROCESS.
39
+ *
40
+ * THE PROBLEM THIS SOLVES. The model picker used to be answered by starting a `pi --mode rpc` child,
41
+ * asking it three questions and killing it. That is a process start - measured at 866ms on a fast
42
+ * machine, and seconds on a slow one - paid every time the cache missed, and the cache key is the
43
+ * SELECTED MODEL, so it missed again for every model a person picked. A user who opened the picker and
44
+ * then created a conversation waited for a process to start.
45
+ *
46
+ * WHY IT IS SAFE. Pi's SDK is the same code Pi's RPC mode is built on, so the answers are the same:
47
+ * `ModelRuntime.getAvailable()` returned exactly the model list a spawned Pi reports, and a session's
48
+ * `getAvailableThinkingLevels()` returned exactly the level list. Both are checked field by field in
49
+ * `scripts/probe-catalog-equivalence.mjs` and `scripts/probe-levels.mjs`.
50
+ *
51
+ * WHAT IS STILL NEEDED FROM PI ITSELF. The thinking levels are a property of the SELECTED model, and a
52
+ * model that is not running has no session to ask. So the levels come from a short-lived in-memory
53
+ * session rather than from the model list - which costs a session, not a process, and is only needed
54
+ * when the caller actually wants levels for a model.
55
+ */
56
+ export interface SdkCatalog {
57
+ /** Pi's own model objects, for the caller to reduce with the same helpers the RPC path used. */
58
+ models: unknown[];
59
+ /** The model Pi would use by default, if settings name one. */
60
+ defaultModel?: {
61
+ provider: string;
62
+ modelId: string;
63
+ };
64
+ /** The thinking level Pi would use by default, if settings name one. */
65
+ defaultThinkingLevel?: string;
66
+ /**
67
+ * The levels a model supports, read from a session running that model.
68
+ *
69
+ * Separate from the models because it is the expensive half: it needs a session, so a caller that
70
+ * only wants the list does not pay for it.
71
+ */
72
+ thinkingLevelsFor(model: {
73
+ provider: string;
74
+ modelId: string;
75
+ }): Promise<string[]>;
76
+ }
77
+ /**
78
+ * Builds a zero-process catalog reader.
79
+ *
80
+ * The ModelRuntime and the SettingsManager are created ONCE and reused: both read the auth store and
81
+ * the model store from disk, so a fresh one per call would put the same I/O back that this is here to
82
+ * remove. They are safe to share - they are read-mostly views of the same files.
83
+ *
84
+ * The MODEL LIST is read once, at build time, and the LEVELS are cached per model - both because
85
+ * re-reading them is the cost this exists to remove, and because the answer cannot change while the
86
+ * process runs: a person who edits `auth.json` or adds a model restarts Super Pi, exactly as they
87
+ * would for anything else in Pi's own configuration.
88
+ */
89
+ export declare function createSdkCatalog(cwd: string): Promise<SdkCatalog>;
90
+ /**
91
+ * The transcript file a conversation's session writes to, found WITHOUT starting a Pi process.
92
+ *
93
+ * THIS REPLACES A SPAWN. Revert and fork need to know which file holds a conversation's history, and
94
+ * they used to start a child purely to be told. Pi's SDK answers it from the session
95
+ * manager's own state - and it answers it the same way Pi's RPC mode does, which is the only reason
96
+ * this is safe: Pi looks a session up BY ID and by WORKING DIRECTORY, so asking the library that
97
+ * resolves the session gives the same file the child would have named.
98
+ *
99
+ * Returns undefined when there is no such session, which is the caller's cue to fall back rather than
100
+ * to guess: a wrong path here would truncate somebody else's conversation.
101
+ */
102
+ export declare function resolveTranscriptFile(cwd: string, conversationId: string, sessionDir: string): Promise<string | undefined>;
26
103
  /** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
27
104
  export declare function piSdkAvailable(): Promise<{
28
105
  available: true;
@@ -48,12 +125,6 @@ export interface SessionFingerprint {
48
125
  excludeTools: string[] | undefined;
49
126
  /** Which of Super Pi's own extensions were mounted. */
50
127
  extensions: string[];
51
- /** The model and thinking level, when they were pinned at creation. */
52
- model?: {
53
- provider: string;
54
- modelId: string;
55
- };
56
- thinkingLevel?: string;
57
128
  }
58
129
  /**
59
130
  * Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
@@ -113,6 +184,31 @@ export interface PiSessionLike {
113
184
  readonly sessionFile?: string | undefined;
114
185
  /** The id of the session. */
115
186
  readonly sessionId?: string | undefined;
187
+ /**
188
+ * The model the session is running on.
189
+ *
190
+ * READ TO COMPARE, not to set: a model is chosen when the session is created, and a LATER change goes
191
+ * through `setModel`.
192
+ */
193
+ readonly model?: {
194
+ provider?: string;
195
+ id?: string;
196
+ } | undefined;
197
+ /**
198
+ * Switches the model on a LIVE session.
199
+ *
200
+ * THIS IS WHY THE MODEL IS NOT PART OF THE CONFIGURATION FINGERPRINT. Pi can change the model of a
201
+ * running session, so a model change does not need a new session - and making one would cost a
202
+ * transcript replay, which is the most expensive thing this runtime does. A user who picks a different
203
+ * model in the picker would otherwise pay for the whole conversation's history to be re-read.
204
+ */
205
+ setModel?(model: {
206
+ provider: string;
207
+ id: string;
208
+ }): Promise<void>;
209
+ /** The thinking levels the session's current model supports. Pi's own answer, not a derivation. */
210
+ getAvailableThinkingLevels?(): string[];
211
+ setThinkingLevel?(level: string): void;
116
212
  /**
117
213
  * The session's transcript, for naming a position in it.
118
214
  *
@@ -23,8 +23,32 @@
23
23
  * the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
24
24
  * would notice the difference.
25
25
  */
26
- import { existsSync } from "node:fs";
26
+ import { existsSync, statSync } from "node:fs";
27
27
  import { join } from "node:path";
28
+ const traceSession = (conversationId, phase, startedAt, extra = "") => {
29
+ if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
30
+ return;
31
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=${phase} elapsedMs=${Math.round(performance.now() - startedAt)}${extra ? ` ${extra}` : ""}`);
32
+ };
33
+ /**
34
+ * Loads Pi's SDK while the server is still starting.
35
+ *
36
+ * Importing Pi costs about 1.5 seconds once and is free afterwards, and it used to be paid by whatever
37
+ * user action needed it FIRST - which for most people was opening the model picker and watching it stay
38
+ * grey. Paying it here, during startup, moves it off the path a person can see.
39
+ *
40
+ * Safe to call more than once, and safe to call on a server that never needs the SDK: it loads the
41
+ * module and returns, and the caller decides what to do with a failure.
42
+ */
43
+ export async function warmPiSdk() {
44
+ try {
45
+ await loadSdk();
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
28
52
  let sdkPromise;
29
53
  let sdkFailure;
30
54
  async function loadSdk() {
@@ -44,6 +68,84 @@ async function loadSdk() {
44
68
  }
45
69
  return sdkPromise;
46
70
  }
71
+ /**
72
+ * Builds a zero-process catalog reader.
73
+ *
74
+ * The ModelRuntime and the SettingsManager are created ONCE and reused: both read the auth store and
75
+ * the model store from disk, so a fresh one per call would put the same I/O back that this is here to
76
+ * remove. They are safe to share - they are read-mostly views of the same files.
77
+ *
78
+ * The MODEL LIST is read once, at build time, and the LEVELS are cached per model - both because
79
+ * re-reading them is the cost this exists to remove, and because the answer cannot change while the
80
+ * process runs: a person who edits `auth.json` or adds a model restarts Super Pi, exactly as they
81
+ * would for anything else in Pi's own configuration.
82
+ */
83
+ export async function createSdkCatalog(cwd) {
84
+ const sdk = await loadSdk();
85
+ const agentDir = defaultAgentDir();
86
+ const modelRuntime = await sdk.ModelRuntime.create();
87
+ const settingsManager = sdk.SettingsManager.create(cwd, agentDir);
88
+ const models = (await modelRuntime.getAvailable?.()) ?? modelRuntime.getModels?.() ?? [];
89
+ const levelsByModel = new Map();
90
+ return {
91
+ models,
92
+ ...(settingsManager.getDefaultProvider?.() && settingsManager.getDefaultModel?.()
93
+ ? {
94
+ defaultModel: {
95
+ provider: settingsManager.getDefaultProvider(),
96
+ modelId: settingsManager.getDefaultModel(),
97
+ },
98
+ }
99
+ : {}),
100
+ ...(settingsManager.getDefaultThinkingLevel?.() ? { defaultThinkingLevel: settingsManager.getDefaultThinkingLevel() } : {}),
101
+ thinkingLevelsFor: async (model) => {
102
+ const key = `${model.provider}/${model.modelId}`;
103
+ const cached = levelsByModel.get(key);
104
+ if (cached)
105
+ return cached;
106
+ // A model that is not running has no levels of its own, so a short-lived in-memory session is
107
+ // built to ask Pi. It costs a session - measured at ~30ms warm, and no process at all - and it is
108
+ // paid once per model rather than once per picker open.
109
+ const { session } = await sdk.createAgentSession({
110
+ cwd,
111
+ agentDir,
112
+ modelRuntime,
113
+ settingsManager,
114
+ sessionManager: sdk.SessionManager.inMemory(cwd, { id: "catalog-levels" }),
115
+ model: modelRuntime.getModel?.(model.provider, model.modelId),
116
+ });
117
+ try {
118
+ const levels = sessionLevels(session);
119
+ levelsByModel.set(key, levels);
120
+ return levels;
121
+ }
122
+ finally {
123
+ session.dispose?.();
124
+ }
125
+ },
126
+ };
127
+ }
128
+ /** Reads the thinking levels off a session, tolerating a session that does not implement the call. */
129
+ function sessionLevels(session) {
130
+ const levels = session.getAvailableThinkingLevels?.();
131
+ return Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [];
132
+ }
133
+ /**
134
+ * The transcript file a conversation's session writes to, found WITHOUT starting a Pi process.
135
+ *
136
+ * THIS REPLACES A SPAWN. Revert and fork need to know which file holds a conversation's history, and
137
+ * they used to start a child purely to be told. Pi's SDK answers it from the session
138
+ * manager's own state - and it answers it the same way Pi's RPC mode does, which is the only reason
139
+ * this is safe: Pi looks a session up BY ID and by WORKING DIRECTORY, so asking the library that
140
+ * resolves the session gives the same file the child would have named.
141
+ *
142
+ * Returns undefined when there is no such session, which is the caller's cue to fall back rather than
143
+ * to guess: a wrong path here would truncate somebody else's conversation.
144
+ */
145
+ export async function resolveTranscriptFile(cwd, conversationId, sessionDir) {
146
+ const sdk = await loadSdk();
147
+ return sdk.SessionManager.findById(cwd, conversationId, sessionDir);
148
+ }
47
149
  /** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
48
150
  export async function piSdkAvailable() {
49
151
  try {
@@ -75,10 +177,6 @@ export function sameFingerprint(a, b) {
75
177
  return false;
76
178
  if (!sameSet(a.extensions, b.extensions))
77
179
  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
180
  return true;
83
181
  }
84
182
  function sameSet(a, b) {
@@ -116,7 +214,10 @@ export async function withEnvironment(values, work) {
116
214
  }
117
215
  }
118
216
  export async function createPiSession(creation) {
217
+ const startedAt = performance.now();
218
+ traceSession(creation.sessionId, "sdk.load.begin", startedAt);
119
219
  const sdk = await loadSdk();
220
+ traceSession(creation.sessionId, "sdk.load.end", startedAt);
120
221
  const agentDir = creation.agentDir ?? defaultAgentDir();
121
222
  return withEnvironment(creation.extensionEnv, async () => {
122
223
  // THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
@@ -136,10 +237,13 @@ export async function createPiSession(creation) {
136
237
  : {}),
137
238
  });
138
239
  await loader.reload();
240
+ traceSession(creation.sessionId, "sdk.loader.reload", startedAt, `extensions=${creation.extensionPaths.length}`);
139
241
  const sessionManager = creation.persist === false
140
242
  ? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
141
243
  : await resolveSessionManager(sdk, creation);
244
+ traceSession(creation.sessionId, "sdk.transcript.ready", startedAt, `bytes=${transcriptBytes(sessionManager)}`);
142
245
  const modelRuntime = await sdk.ModelRuntime.create();
246
+ traceSession(creation.sessionId, "sdk.model-runtime", startedAt);
143
247
  // THE MODEL IS RESOLVED, NOT PASSED THROUGH. Pi's SDK takes a whole Model object - the provider's
144
248
  // configuration, the endpoint, the capability list - and Super Pi only knows the pair
145
249
  // (provider, modelId) that the picker shows. The runtime is what turns that pair into the object
@@ -164,9 +268,21 @@ export async function createPiSession(creation) {
164
268
  ...(creation.thinkingLevel ? { thinkingLevel: creation.thinkingLevel } : {}),
165
269
  };
166
270
  const { session } = await sdk.createAgentSession(options);
271
+ traceSession(creation.sessionId, "sdk.session.created", startedAt);
167
272
  return session;
168
273
  });
169
274
  }
275
+ function transcriptBytes(sessionManager) {
276
+ const file = sessionManager.getSessionFile?.();
277
+ if (!file)
278
+ return 0;
279
+ try {
280
+ return existsSync(file) ? statSync(file).size : 0;
281
+ }
282
+ catch {
283
+ return 0;
284
+ }
285
+ }
170
286
  /**
171
287
  * Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
172
288
  * otherwise.
@@ -402,7 +402,18 @@ export declare function killTree(pid: number): Promise<void>;
402
402
  * caller drop the message from the dock on the strength of a payload shape we did not recognise.
403
403
  */
404
404
  export declare function describeSteerDisposition(data: unknown): SteerDisposition | undefined;
405
- /** Keys Pi's model answers by. Exported so the store and the tests agree with the runtime. */
405
+ /**
406
+ * Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
407
+ *
408
+ * THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
409
+ * process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
410
+ * list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
411
+ * selection is the THINKING LEVELS, which belong to the model in use.
412
+ *
413
+ * So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
414
+ * cache miss for every model a person ever picked, and every miss was a process start - so the picker
415
+ * went grey again after every model change, which is exactly when a person is moving between models.
416
+ */
406
417
  export declare const catalogKey: (selection?: ModelSelection) => string;
407
418
  /**
408
419
  * Where the `task` extension lives, as an ABSOLUTE path.
@@ -467,6 +478,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
467
478
  * in the pool, and a turn is a run ON that session rather than a process OF its own.
468
479
  */
469
480
  private readonly sessionPool;
481
+ /** Where Super Pi itself is installed, which is also where its own extensions are found. */
482
+ private readonly repositoryRoot;
470
483
  /**
471
484
  * The turn currently running on each conversation's session.
472
485
  *
@@ -509,8 +522,25 @@ export declare class PiRpcRuntime implements AgentRuntime {
509
522
  /** Distinguishes steer command ids. Never reused, so a late response cannot settle a later steer. */
510
523
  private steerSeq;
511
524
  private readonly catalogs;
512
- /** In-flight reads, so N clients asking at once cost ONE Pi spawn rather than N. */
525
+ /**
526
+ * In-flight reads, so N clients asking at once cost ONE read rather than N.
527
+ *
528
+ * "One read", not "one Pi spawn": the catalog is answered out of Pi's SDK and no process is started
529
+ * at all. The wording here used to promise a shared spawn, which is what the old implementation did.
530
+ */
513
531
  private readonly pendingCatalogs;
532
+ /**
533
+ * Pi's SDK catalog reader, built once and reused.
534
+ *
535
+ * The ModelRuntime and SettingsManager it holds read the auth store and the model store from disk, so
536
+ * building a new one per call would put back the I/O this exists to avoid. They are read-mostly
537
+ * views of files that change rarely, and Pi's own long-lived session holds the same objects for its
538
+ * whole life.
539
+ *
540
+ * A FAILED build is remembered as a failure rather than retried per call: a broken SDK would
541
+ * otherwise be re-imported on every picker open.
542
+ */
543
+ private sdkCatalogPromise?;
514
544
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
515
545
  private warnedMissingExtension;
516
546
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
@@ -946,15 +976,47 @@ export declare class PiRpcRuntime implements AgentRuntime {
946
976
  * killed as soon as it has answered - no long-lived process to keep warm.
947
977
  */
948
978
  catalog(selection?: ModelSelection): Promise<ModelCatalog>;
979
+ /**
980
+ * Reads the model catalog WITHOUT starting a Pi process, and falls back to a spawned Pi only when
981
+ * the SDK is not available.
982
+ *
983
+ * WHY THIS IS THE DEFAULT. The picker used to be answered by starting a `pi --mode rpc` child,
984
+ * asking it three questions and killing it: a process start, measured at 866ms on a fast machine and
985
+ * seconds on a slow one. And the cache key is the SELECTED MODEL, so it started again for every model
986
+ * a person picked - which is why "the picker stays grey for ten seconds" followed choosing a model,
987
+ * and not only the first time.
988
+ *
989
+ * Pi's SDK is the same code Pi's RPC mode is built on, so the answers are the same, and the two are
990
+ * compared field by field in `scripts/probe-catalog-equivalence.mjs`.
991
+ */
992
+ private readCatalog;
993
+ /**
994
+ * The transcript file for a conversation, out of Pi's SDK, or undefined when the SDK cannot answer.
995
+ *
996
+ * Undefined means "ask someone else", not "no such file": a missing SDK and a missing transcript
997
+ * are different facts, and only the first one should send the caller to the spawning path.
998
+ *
999
+ * For the same reason as the catalog, a NAMED Pi is not asked here: when something else will run the
1000
+ * conversation, it is also the only one that knows which file that conversation's transcript is in.
1001
+ */
1002
+ private resolveTranscriptFileWithoutSpawn;
1003
+ private sdkCatalog;
1004
+ /**
1005
+ * The catalog, out of Pi's SDK. Returns undefined when the SDK is not available, which is the only
1006
+ * case in which the spawning path is reached.
1007
+ */
1008
+ private readCatalogFromSdk;
949
1009
  /**
950
1010
  * Asks one throwaway Pi for its model list, its thinking levels and its own current selection.
951
1011
  *
1012
+ * Only reached when Pi's SDK is not available - which is a broken installation, not a normal one.
1013
+ *
952
1014
  * The three commands are written back to back and only then awaited, so Pi works on them in
953
1015
  * parallel - measured at 394ms for all three, against 714ms for the slowest one alone. A Pi
954
1016
  * scoped to one conversation's model needs `set_model` FIRST and alone, because thinking levels are
955
1017
  * a property of the selected model, not of Pi's default.
956
1018
  */
957
- private readCatalog;
1019
+ private readCatalogBySpawningPi;
958
1020
  }
959
1021
  /**
960
1022
  * Normalizes Pi's `Model[]`, keeping only entries that can actually be sent back as a `set_model`.