@evandu/super-pi 0.1.97 → 0.1.99

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.
package/bin/super-pi.js CHANGED
@@ -5,6 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { pathToFileURL } from "node:url";
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const root = resolve(import.meta.dirname, "..");
@@ -62,7 +63,7 @@ async function running(port) {
62
63
  }
63
64
  async function stop(port) { try { const headers = process.env.SUPER_PI_ACCESS_TOKEN ? { authorization: `Bearer ${process.env.SUPER_PI_ACCESS_TOKEN}` } : {}; const response = await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: "POST", headers, signal: AbortSignal.timeout(3000) }); return response.ok || response.status === 202; } catch { return false; } }
64
65
  function openBrowser(url) { const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open"; const args = platform === "darwin" ? [url] : platform === "win32" ? ["/c", "start", "", url] : [url]; spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }).unref(); }
65
- function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
66
+ function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|diagnose|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" diagnose Measure where a message spends its time"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
66
67
  function paths() { if (platform === "win32") return { task: "Super Pi" }; if (platform === "darwin") return { file: join(home, "Library", "LaunchAgents", "com.evandu.super-pi.plist") }; return { file: join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "systemd", "user", "super-pi.service") }; }
67
68
  async function system(command, args) { if (process.env.SUPER_PI_AUTOSTART_DRY_RUN === "1") { console.log(`[dry-run] ${command} ${args.join(" ")}`); return; } await execFileAsync(command, args, { windowsHide: true }); }
68
69
  async function enable(value) {
@@ -84,6 +85,25 @@ try {
84
85
  if (command === "help" || command === "--help" || command === "-h") { usage(); process.exit(0); }
85
86
  if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url)); process.exit(0); }
86
87
  if (command === "autostart" || command === "startup") { const action = args.shift()?.toLowerCase() ?? "status"; const value = options(args); if (action === "enable") await enable(value); else if (action === "disable") await disable(); else if (action === "status") await status(); else throw new Error("Autostart action must be enable, disable, or status."); process.exit(0); }
88
+ if (command === "diagnose" || command === "diagnose-latency") {
89
+ // MEASURE WHERE THE TIME GOES, ON THE MACHINE THAT IS COMPLAINING.
90
+ //
91
+ // It runs a throwaway server on its own port with its own data directory, so it never disturbs a
92
+ // session someone is in the middle of and never writes into their real conversations. What it prints
93
+ // is three spans that belong to DIFFERENT OWNERS - Super Pi, Super Pi, and the model - because
94
+ // conflating them is how two wrong root causes got written down before.
95
+ const turnsArg = args.find((a) => /^\d+$/.test(a));
96
+ const { diagnose, renderReport } = await import(pathToFileURL(join(root, "scripts", "diagnose.mjs")).href);
97
+ console.log(`Super Pi v${ownVersion().version} — diagnosing on ${process.platform}, node ${process.versions.node}`);
98
+ const result = await diagnose({
99
+ repositoryRoot: root,
100
+ turns: turnsArg ? Number.parseInt(turnsArg, 10) : 2,
101
+ onProgress: (line) => console.log(` ${line}`),
102
+ });
103
+ console.log("");
104
+ console.log(renderReport(result));
105
+ process.exit(result.error ? 1 : 0);
106
+ }
87
107
  if (!["start", "stop", "restart", "update"].includes(command)) throw new Error(`Unknown command: ${command}`);
88
108
  if (command === "update") { const npm = platform === "win32" ? "npm.cmd" : "npm"; const args = prefix ? ["install", "--prefix", prefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"]; const child = spawn(npm, args, { stdio: "inherit", shell: platform === "win32", env: process.env }); const code = await new Promise((resolve) => child.on("close", resolve)); if (code !== 0) process.exit(code ?? 1); }
89
109
  const value = options(args);
@@ -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-21T05:09:03.121Z"
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.