@evandu/super-pi 0.1.104 → 0.1.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-mAu39Bra.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DDkbPYLz.css">
11
+ <script type="module" crossorigin src="/assets/index-tBfBUY_8.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-UiqTcZca.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,6 +37,15 @@ export declare class CatalogService {
37
37
  * Broadcasts the model catalog to connected clients.
38
38
  */
39
39
  broadcastCatalog(selection?: ModelSelection, conversationId?: string): Promise<void>;
40
+ /**
41
+ * Broadcasts the UNFILTERED catalog, for the settings window's model panel.
42
+ *
43
+ * A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
44
+ * `model.catalog.all` a separate event in the first place: a `model.catalog` without a
45
+ * conversation is the unfiltered list, and a client that received it as its conversation's list
46
+ * would be offered the models that workspace's rules forbid. The panel gets its own event.
47
+ */
48
+ broadcastAllCatalog(): Promise<void>;
40
49
  setModelRules(rules: import("../types.js").ModelFilterRule[]): Promise<void>;
41
50
  /**
42
51
  * Reads one catalog filtered by mode at source.
@@ -90,6 +90,17 @@ export class CatalogService {
90
90
  const catalog = await this.getCatalog(selection, conversationId);
91
91
  this.broadcaster.broadcast({ type: "model.catalog", catalog, conversationId });
92
92
  }
93
+ /**
94
+ * Broadcasts the UNFILTERED catalog, for the settings window's model panel.
95
+ *
96
+ * A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
97
+ * `model.catalog.all` a separate event in the first place: a `model.catalog` without a
98
+ * conversation is the unfiltered list, and a client that received it as its conversation's list
99
+ * would be offered the models that workspace's rules forbid. The panel gets its own event.
100
+ */
101
+ async broadcastAllCatalog() {
102
+ this.broadcaster.broadcast({ type: "model.catalog.all", catalog: await this.getAllCatalog() });
103
+ }
93
104
  async setModelRules(rules) {
94
105
  if (!this.store)
95
106
  throw new Error("Store unavailable");
@@ -0,0 +1,63 @@
1
+ import { ProviderRegistry } from "../providers.js";
2
+ import type { AgentRuntime } from "../runtime.js";
3
+ import type { Store } from "../store.js";
4
+ import type { PublicProvider } from "../types.js";
5
+ import type { EventBroadcaster } from "../server-context.js";
6
+ import type { CatalogService } from "./catalog-service.js";
7
+ /**
8
+ * THE PROVIDERS A PERSON ADDED, as a service.
9
+ *
10
+ * The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
11
+ * when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
12
+ * the same four steps, in this order:
13
+ *
14
+ * 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
15
+ * `auth.json`, merged into whatever is already there.
16
+ * 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
17
+ * that a failure to write them leaves Super Pi claiming nothing it has not done.
18
+ * 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
19
+ * the process and the runtime's has a ten-minute TTL - so a provider added now would not
20
+ * appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
21
+ * model the person just added is exactly the "decorative control" this project refuses to ship.
22
+ * 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
23
+ * session was built, and that runtime does not know a provider added a minute ago. Pi's
24
+ * refusal to switch to an unknown model is swallowed by the session pool into a warning - the
25
+ * turn then runs on the OLD model while the picker says the new one. So the sessions are made
26
+ * stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
27
+ * session away costs nothing.
28
+ */
29
+ export declare class ProviderService {
30
+ private readonly registry;
31
+ private readonly runtime;
32
+ private readonly broadcaster;
33
+ private readonly catalogService;
34
+ private readonly store;
35
+ constructor(registry: ProviderRegistry, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, store: Store);
36
+ /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
37
+ list(): Promise<PublicProvider[]>;
38
+ /**
39
+ * Adds a provider, or adds a model to one, or updates one.
40
+ *
41
+ * ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
42
+ * saving a second model under the same provider should not have to retype the API address. The
43
+ * merge is by id: the provider's fields are replaced by what the form said, and the model with
44
+ * the same id is replaced while the provider's other models stay.
45
+ *
46
+ * THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
47
+ * one", not "remove the key": the key is never shown again once it is stored, so an empty field
48
+ * is the normal state of a form for a provider that already has one. `clearKey` is how a person
49
+ * asks for it to go.
50
+ */
51
+ set(input: unknown): Promise<PublicProvider[]>;
52
+ /** Removes a provider Super Pi manages, and only one it manages. */
53
+ remove(providerId: string): Promise<PublicProvider[]>;
54
+ /**
55
+ * What has to happen after the files are written, for the change to be REAL rather than pending.
56
+ *
57
+ * The catalog is re-read for every live conversation - the same loop the model rules use - rather
58
+ * than broadcast once without a conversation, because a catalog without a conversation is the
59
+ * UNFILTERED one, and handing that to a conversation that has model rules would offer it models
60
+ * those rules forbid.
61
+ */
62
+ private afterChange;
63
+ }
@@ -0,0 +1,136 @@
1
+ import { KNOWN_APIS, publicProviders, validateProvider } from "../providers.js";
2
+ /**
3
+ * THE PROVIDERS A PERSON ADDED, as a service.
4
+ *
5
+ * The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
6
+ * when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
7
+ * the same four steps, in this order:
8
+ *
9
+ * 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
10
+ * `auth.json`, merged into whatever is already there.
11
+ * 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
12
+ * that a failure to write them leaves Super Pi claiming nothing it has not done.
13
+ * 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
14
+ * the process and the runtime's has a ten-minute TTL - so a provider added now would not
15
+ * appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
16
+ * model the person just added is exactly the "decorative control" this project refuses to ship.
17
+ * 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
18
+ * session was built, and that runtime does not know a provider added a minute ago. Pi's
19
+ * refusal to switch to an unknown model is swallowed by the session pool into a warning - the
20
+ * turn then runs on the OLD model while the picker says the new one. So the sessions are made
21
+ * stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
22
+ * session away costs nothing.
23
+ */
24
+ export class ProviderService {
25
+ registry;
26
+ runtime;
27
+ broadcaster;
28
+ catalogService;
29
+ store;
30
+ constructor(registry, runtime, broadcaster, catalogService, store) {
31
+ this.registry = registry;
32
+ this.runtime = runtime;
33
+ this.broadcaster = broadcaster;
34
+ this.catalogService = catalogService;
35
+ this.store = store;
36
+ }
37
+ /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
38
+ async list() {
39
+ return publicProviders(await this.registry.load());
40
+ }
41
+ /**
42
+ * Adds a provider, or adds a model to one, or updates one.
43
+ *
44
+ * ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
45
+ * saving a second model under the same provider should not have to retype the API address. The
46
+ * merge is by id: the provider's fields are replaced by what the form said, and the model with
47
+ * the same id is replaced while the provider's other models stay.
48
+ *
49
+ * THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
50
+ * one", not "remove the key": the key is never shown again once it is stored, so an empty field
51
+ * is the normal state of a form for a provider that already has one. `clearKey` is how a person
52
+ * asks for it to go.
53
+ */
54
+ async set(input) {
55
+ const requested = validateProvider(input);
56
+ const raw = (input ?? {});
57
+ const previous = await this.registry.load();
58
+ const existing = previous.find((provider) => provider.id === requested.id);
59
+ const apiKey = requested.apiKey
60
+ ? requested.apiKey
61
+ : (raw.clearKey === true ? undefined : existing?.apiKey);
62
+ // A MODEL WITH THE SAME ID IS REPLACED WHERE IT STANDS, not moved to the end: the list a person
63
+ // sees in the panel is the list they added, and re-ordering it because they edited its first
64
+ // entry would be a change nobody asked for. A model with a NEW id is appended.
65
+ const incoming = requested.models[0];
66
+ const models = !existing
67
+ ? requested.models
68
+ : (existing.models.some((model) => model.id === incoming.id)
69
+ ? existing.models.map((model) => (model.id === incoming.id ? incoming : model))
70
+ : [...existing.models, incoming]);
71
+ const merged = {
72
+ id: requested.id,
73
+ ...(requested.name ? { name: requested.name } : {}),
74
+ baseUrl: requested.baseUrl,
75
+ api: requested.api,
76
+ ...(apiKey ? { apiKey } : {}),
77
+ models,
78
+ };
79
+ const next = existing
80
+ ? [...previous.filter((provider) => provider.id !== requested.id), merged]
81
+ : [...previous, merged];
82
+ await this.registry.applyToPi(previous, next);
83
+ try {
84
+ await this.registry.save(next);
85
+ }
86
+ catch (error) {
87
+ // The registry is the truth source, so a failure to write it means Super Pi no longer knows
88
+ // what it put into Pi's files. Pi's files are put back the way they were, and the failure is
89
+ // reported rather than swallowed: a registry that disagrees with Pi's files is a state the
90
+ // next write would trip over.
91
+ await this.registry.applyToPi(next, previous).catch(() => undefined);
92
+ throw error;
93
+ }
94
+ await this.afterChange();
95
+ return publicProviders(next);
96
+ }
97
+ /** Removes a provider Super Pi manages, and only one it manages. */
98
+ async remove(providerId) {
99
+ const previous = await this.registry.load();
100
+ const existing = previous.find((provider) => provider.id === providerId);
101
+ if (!existing)
102
+ throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
103
+ const next = previous.filter((provider) => provider.id !== providerId);
104
+ await this.registry.applyToPi(previous, next);
105
+ await this.registry.save(next);
106
+ await this.afterChange();
107
+ return publicProviders(next);
108
+ }
109
+ /**
110
+ * What has to happen after the files are written, for the change to be REAL rather than pending.
111
+ *
112
+ * The catalog is re-read for every live conversation - the same loop the model rules use - rather
113
+ * than broadcast once without a conversation, because a catalog without a conversation is the
114
+ * UNFILTERED one, and handing that to a conversation that has model rules would offer it models
115
+ * those rules forbid.
116
+ */
117
+ async afterChange() {
118
+ this.runtime.invalidateCatalog?.();
119
+ this.runtime.bumpModelEpoch?.();
120
+ for (const conversation of this.store.snapshot().conversations) {
121
+ if (conversation.archivedAt)
122
+ continue;
123
+ try {
124
+ await this.catalogService.broadcastCatalog(conversation.model, conversation.id);
125
+ }
126
+ catch (error) {
127
+ console.warn(`供应商已保存,刷新会话 ${conversation.id} 的模型目录失败:${String(error)}`);
128
+ }
129
+ }
130
+ // The settings panel's OWN list, which is unfiltered and therefore has to be asked for
131
+ // separately. Without this the person who just added a provider would not see their own model
132
+ // in the default-model dropdown until they closed the window and opened it again.
133
+ await this.catalogService.broadcastAllCatalog?.();
134
+ this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
135
+ }
136
+ }
@@ -4,6 +4,7 @@ import type { ConversationService } from "./application/conversation-service.js"
4
4
  import type { RunService } from "./application/run-service.js";
5
5
  import type { ExtensionUiService } from "./application/extension-ui-service.js";
6
6
  import type { CatalogService } from "./application/catalog-service.js";
7
+ import type { ProviderService } from "./application/provider-service.js";
7
8
  import type { TerminalService } from "./terminal.js";
8
9
  export type SocketSender = (event: ServerEvent) => void;
9
10
  /**
@@ -17,6 +18,13 @@ export declare class CommandHandler {
17
18
  private readonly catalogService;
18
19
  /** Path to the Super Pi repository root, used to spawn the restart script. */
19
20
  private readonly rootDir?;
21
+ /**
22
+ * The providers a person added through the settings window.
23
+ *
24
+ * Optional because the command handler is constructed by tests that do not care about providers,
25
+ * and a missing service has to say so rather than take the whole handler down.
26
+ */
27
+ private readonly providerService?;
20
28
  /**
21
29
  * The built-in terminal service.
22
30
  *
@@ -25,7 +33,14 @@ export declare class CommandHandler {
25
33
  private readonly terminalService?;
26
34
  constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService, terminalService?: TerminalService,
27
35
  /** Path to the Super Pi repository root, used to spawn the restart script. */
28
- rootDir?: string | undefined);
36
+ rootDir?: string | undefined,
37
+ /**
38
+ * The providers a person added through the settings window.
39
+ *
40
+ * Optional because the command handler is constructed by tests that do not care about providers,
41
+ * and a missing service has to say so rather than take the whole handler down.
42
+ */
43
+ providerService?: ProviderService | undefined);
29
44
  /**
30
45
  * Processes a client command.
31
46
  *
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { join } from "node:path";
3
+ import { KNOWN_APIS } from "./providers.js";
3
4
  import { packageUpdateArgs } from "./update.js";
4
5
  /**
5
6
  * Routes client commands to appropriate application services and converts results or errors.
@@ -11,6 +12,7 @@ export class CommandHandler {
11
12
  extensionUiService;
12
13
  catalogService;
13
14
  rootDir;
15
+ providerService;
14
16
  /**
15
17
  * The built-in terminal service.
16
18
  *
@@ -19,13 +21,21 @@ export class CommandHandler {
19
21
  terminalService;
20
22
  constructor(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService,
21
23
  /** Path to the Super Pi repository root, used to spawn the restart script. */
22
- rootDir) {
24
+ rootDir,
25
+ /**
26
+ * The providers a person added through the settings window.
27
+ *
28
+ * Optional because the command handler is constructed by tests that do not care about providers,
29
+ * and a missing service has to say so rather than take the whole handler down.
30
+ */
31
+ providerService) {
23
32
  this.workspaceService = workspaceService;
24
33
  this.conversationService = conversationService;
25
34
  this.runService = runService;
26
35
  this.extensionUiService = extensionUiService;
27
36
  this.catalogService = catalogService;
28
37
  this.rootDir = rootDir;
38
+ this.providerService = providerService;
29
39
  this.terminalService = terminalService;
30
40
  }
31
41
  /**
@@ -69,6 +79,29 @@ export class CommandHandler {
69
79
  await this.catalogService.setDefaultModel(command.model);
70
80
  return;
71
81
  }
82
+ // THE PROVIDERS A PERSON ADDED. The list is answered to the ASKING socket only - it is a panel's
83
+ // private list, and broadcasting it would put a provider's API address in every other tab's state
84
+ // for no gain. The set and the remove broadcast the new list themselves, because everybody who
85
+ // has the settings window open has to see the change.
86
+ if (command.type === "providers.set") {
87
+ if (!this.providerService)
88
+ throw new Error("供应商功能不可用");
89
+ await this.providerService.set(command.provider);
90
+ return;
91
+ }
92
+ if (command.type === "providers.remove") {
93
+ if (!this.providerService)
94
+ throw new Error("供应商功能不可用");
95
+ await this.providerService.remove(command.providerId);
96
+ return;
97
+ }
98
+ if (command.type === "providers.fetch") {
99
+ if (!this.providerService)
100
+ throw new Error("供应商功能不可用");
101
+ if (sendToSocket)
102
+ sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
103
+ return;
104
+ }
72
105
  if (command.type === "workspace.create") {
73
106
  return await this.workspaceService.createWorkspace(command.name, command.path);
74
107
  }
@@ -16,6 +16,8 @@ import { SubagentRunRecorder } from "./subagent-runs.js";
16
16
  import { WorkflowEngine } from "./workflow-engine.js";
17
17
  import { ExtensionUiService } from "./application/extension-ui-service.js";
18
18
  import { CatalogService } from "./application/catalog-service.js";
19
+ import { ProviderService } from "./application/provider-service.js";
20
+ import { ProviderRegistry } from "./providers.js";
19
21
  import { RunService } from "./application/run-service.js";
20
22
  import { ConversationService } from "./application/conversation-service.js";
21
23
  import { WorkspaceService } from "./application/workspace-service.js";
@@ -178,6 +180,15 @@ const onDirsChanged = (dirs) => {
178
180
  workspaceService.setAgentsDir(dirs);
179
181
  };
180
182
  const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs, store, rootDir, onDirsChanged);
183
+ /**
184
+ * The providers a person added through the settings window.
185
+ *
186
+ * The registry lives in Super Pi's own data directory and holds the API keys; Pi's `models.json` and
187
+ * `auth.json` are written from it, so a provider added here is a provider Pi can use - in a session,
188
+ * in a spawned Pi, and in the terminal.
189
+ */
190
+ const providerRegistry = new ProviderRegistry(dataDir);
191
+ const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
181
192
  const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
182
193
  runService.setMemory(memory);
183
194
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
@@ -216,6 +227,7 @@ const serverContext = {
216
227
  runService,
217
228
  extensionUiService,
218
229
  catalogService,
230
+ providerService,
219
231
  execService,
220
232
  toolRegistry,
221
233
  toolRunner,
@@ -223,7 +235,7 @@ const serverContext = {
223
235
  terminalEnabled: () => terminalService.enabled,
224
236
  terminalDisabledReason: () => terminalDisabledReason(),
225
237
  };
226
- const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir);
238
+ const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService);
227
239
  // ------------------------------------------------------------- Lifecycle & shutdown
228
240
  let shutdownPromise;
229
241
  async function closeHttpServer() {
@@ -78,6 +78,15 @@ export declare class PiSessionPool {
78
78
  * knows what one looks like.
79
79
  */
80
80
  private readonly mintCapability;
81
+ /**
82
+ * How many times the set of models Pi knows about has changed.
83
+ *
84
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
85
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
86
+ * settings window makes every live session unable to reach the new model, and the next turn is
87
+ * where that gets fixed.
88
+ */
89
+ private readonly modelEpoch;
81
90
  private readonly sessions;
82
91
  constructor(
83
92
  /** Where Pi keeps its session files. */
@@ -104,7 +113,16 @@ export declare class PiSessionPool {
104
113
  * does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
105
114
  * knows what one looks like.
106
115
  */
107
- mintCapability?: (conversationId: string) => string);
116
+ mintCapability?: (conversationId: string) => string,
117
+ /**
118
+ * How many times the set of models Pi knows about has changed.
119
+ *
120
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
121
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
122
+ * settings window makes every live session unable to reach the new model, and the next turn is
123
+ * where that gets fixed.
124
+ */
125
+ modelEpoch?: () => number);
108
126
  /**
109
127
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
110
128
  *
@@ -48,6 +48,7 @@ export class PiSessionPool {
48
48
  onSessionDestroyed;
49
49
  factory;
50
50
  mintCapability;
51
+ modelEpoch;
51
52
  sessions = new Map();
52
53
  constructor(
53
54
  /** Where Pi keeps its session files. */
@@ -74,13 +75,23 @@ export class PiSessionPool {
74
75
  * does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
75
76
  * knows what one looks like.
76
77
  */
77
- mintCapability = () => "") {
78
+ mintCapability = () => "",
79
+ /**
80
+ * How many times the set of models Pi knows about has changed.
81
+ *
82
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
83
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
84
+ * settings window makes every live session unable to reach the new model, and the next turn is
85
+ * where that gets fixed.
86
+ */
87
+ modelEpoch = () => 0) {
78
88
  this.sessionDir = sessionDir;
79
89
  this.repositoryRoot = repositoryRoot;
80
90
  this.agentDir = agentDir;
81
91
  this.onSessionDestroyed = onSessionDestroyed;
82
92
  this.factory = factory;
83
93
  this.mintCapability = mintCapability;
94
+ this.modelEpoch = modelEpoch;
84
95
  }
85
96
  /**
86
97
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
@@ -227,6 +238,7 @@ export class PiSessionPool {
227
238
  tools: request.tools,
228
239
  excludeTools: request.excludeTools,
229
240
  extensions: superPiExtensionPaths(this.repositoryRoot),
241
+ modelEpoch: this.modelEpoch(),
230
242
  };
231
243
  }
232
244
  /**
@@ -129,6 +129,16 @@ export interface SessionFingerprint {
129
129
  excludeTools: string[] | undefined;
130
130
  /** Which of Super Pi's own extensions were mounted. */
131
131
  extensions: string[];
132
+ /**
133
+ * How many times the set of models Pi knows about has changed.
134
+ *
135
+ * NOT THE MODEL, BUT THE WORLD THE MODEL LIVES IN. A session's `ModelRuntime` is built with it and
136
+ * does not learn about a provider added afterwards, so a session that keeps running on the old
137
+ * runtime cannot switch to a model that only exists in the new one - and Pi's refusal to switch is
138
+ * swallowed into a warning, which would leave the turn running a model the picker does not name.
139
+ * The counter makes such a session stale, and it is rebuilt at its next turn.
140
+ */
141
+ modelEpoch: number;
132
142
  }
133
143
  /**
134
144
  * Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
@@ -185,6 +185,8 @@ export function sameFingerprint(a, b) {
185
185
  return false;
186
186
  if (!sameSet(a.extensions, b.extensions))
187
187
  return false;
188
+ if (a.modelEpoch !== b.modelEpoch)
189
+ return false;
188
190
  return true;
189
191
  }
190
192
  function sameSet(a, b) {
@@ -343,8 +345,17 @@ async function resolveSessionManager(sdk, creation) {
343
345
  * file goes out of its way not to do for a path it can derive. The derivation is Pi's own default:
344
346
  * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`,
345
347
  * because on Windows the two disagree often enough to matter.
348
+ *
349
+ * `PI_CODING_AGENT_DIR` WINS, exactly as it wins in Pi (`core/config.ts:getAgentDir`). A person who
350
+ * set it expects Pi to read its whole configuration from there, and this function used to disagree
351
+ * with Pi about it: Pi's model runtime read `models.json` from that directory while the session's
352
+ * resource loader read the skills from `~/.pi/agent`. Two halves of one Pi pointed at two
353
+ * directories, and only one of them was the one the person had configured.
346
354
  */
347
355
  function defaultAgentDir() {
356
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
357
+ if (configured)
358
+ return configured;
348
359
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
349
360
  return home ? join(home, ".pi", "agent") : join(".pi", "agent");
350
361
  }