@evandu/super-pi 0.1.14 → 0.1.15

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.
@@ -1,3 +1,4 @@
1
+ import { assertModelAllowed } from "../model-filter.js";
1
2
  import { deleteConversationUploads } from "../upload.js";
2
3
  /**
3
4
  * Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
@@ -28,8 +29,9 @@ export class WorkspaceService {
28
29
  * Creates a new workspace.
29
30
  */
30
31
  async createWorkspace(name, path) {
31
- await this.store.createWorkspace(name, path);
32
+ const created = await this.store.createWorkspace(name, path);
32
33
  this.broadcaster.broadcastState();
34
+ return created.id;
33
35
  }
34
36
  /**
35
37
  * Renames an existing workspace.
@@ -97,9 +99,15 @@ export class WorkspaceService {
97
99
  const dirsLabel = Array.isArray(this.agentsDir) ? this.agentsDir.join(", ") : this.agentsDir;
98
100
  throw new Error(`Agent ${JSON.stringify(settings.agentId)} 不存在或不是可用的 primary agent(目录:${dirsLabel})`);
99
101
  }
102
+ if (agent.model) {
103
+ const catalog = await this.runtime.catalog();
104
+ const model = catalog.models.find(item => item.provider === agent.model?.provider && item.modelId === agent.model?.modelId);
105
+ assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, model ?? { ...agent.model, label: agent.model.modelId });
106
+ }
100
107
  agentToStore = agent.id;
101
108
  }
102
109
  }
110
+ assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, selection ?? (await this.runtime.catalog()).defaultModel);
103
111
  await this.store.setWorkspaceDefaults(workspaceId, {
104
112
  model: selection,
105
113
  thinkingLevel: settings.thinkingLevel ?? undefined,
@@ -18,5 +18,5 @@ export declare class CommandHandler {
18
18
  /**
19
19
  * Processes a client command.
20
20
  */
21
- handleCommand(command: ClientCommand, sendToSocket?: SocketSender): Promise<void>;
21
+ handleCommand(command: ClientCommand, sendToSocket?: SocketSender): Promise<string | void>;
22
22
  }
@@ -18,10 +18,13 @@ export class CommandHandler {
18
18
  * Processes a client command.
19
19
  */
20
20
  async handleCommand(command, sendToSocket) {
21
- if (command.type === "workspace.create") {
22
- await this.workspaceService.createWorkspace(command.name, command.path);
21
+ if (command.type === "model.rules.set") {
22
+ await this.catalogService.setModelRules(command.rules);
23
23
  return;
24
24
  }
25
+ if (command.type === "workspace.create") {
26
+ return await this.workspaceService.createWorkspace(command.name, command.path);
27
+ }
25
28
  if (command.type === "workspace.rename") {
26
29
  await this.workspaceService.renameWorkspace(command.workspaceId, command.name);
27
30
  return;
@@ -39,12 +42,19 @@ export class CommandHandler {
39
42
  return;
40
43
  }
41
44
  if (command.type === "conversation.create") {
42
- await this.conversationService.createConversation(command.workspaceId);
43
- return;
45
+ return await this.conversationService.createConversation(command.workspaceId);
44
46
  }
45
47
  if (command.type === "conversation.select") {
46
- if (sendToSocket)
47
- sendToSocket({ type: "model.catalog", catalog: await this.conversationService.getConversationCatalog(command.conversationId), conversationId: command.conversationId });
48
+ if (sendToSocket) {
49
+ try {
50
+ sendToSocket({ type: "model.catalog", catalog: await this.conversationService.getConversationCatalog(command.conversationId), conversationId: command.conversationId });
51
+ }
52
+ catch (error) {
53
+ // Keep the permitted choices available so an existing invalid session can be corrected.
54
+ sendToSocket({ type: "model.catalog", catalog: await this.catalogService.getCatalog(undefined, command.conversationId), conversationId: command.conversationId });
55
+ throw error;
56
+ }
57
+ }
48
58
  else
49
59
  await this.conversationService.selectConversation(command.conversationId);
50
60
  return;
@@ -143,9 +153,9 @@ export class CommandHandler {
143
153
  }
144
154
  if (command.type === "catalog.fetch") {
145
155
  if (sendToSocket)
146
- sendToSocket({ type: "model.catalog", catalog: await this.catalogService.getCatalog() });
156
+ sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(undefined, command.conversationId) });
147
157
  else
148
- await this.catalogService.broadcastCatalog();
158
+ await this.catalogService.broadcastCatalog(undefined, command.conversationId);
149
159
  return;
150
160
  }
151
161
  if (command.type === "agent.catalog.fetch") {
@@ -1,3 +1,4 @@
1
+ import { validateConversationModel } from "./model-filter.js";
1
2
  import { existsSync } from "node:fs";
2
3
  import { mkdir } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -60,6 +61,9 @@ const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefine
60
61
  dir: initialAgentDirs,
61
62
  promptDir: agentPromptDir,
62
63
  });
64
+ const validateModel = (conversationId, selection) => validateConversationModel(store, runtime, conversationId, selection);
65
+ runtime.setModelValidator?.(validateModel);
66
+ supervisor.setModelValidator(validateModel);
63
67
  const subagents = new SubagentRunRecorder(supervisor, {
64
68
  store,
65
69
  broadcast: (event) => {
@@ -0,0 +1,13 @@
1
+ import type { AppState, ModelFilterRule, ModelSelection, Workspace } from "./types.js";
2
+ export declare function applicableRules(rules: ModelFilterRule[] | undefined, workspace: Workspace): {
3
+ rule: ModelFilterRule;
4
+ modelRegex: RegExp;
5
+ }[];
6
+ export declare function assertModelAllowed(rules: ModelFilterRule[] | undefined, workspace: Workspace, model: ModelSelection | undefined): void;
7
+ export declare function conversationWorkspace(state: AppState, conversationId: string): Workspace;
8
+ /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
9
+ export declare function validateConversationModel(store: {
10
+ snapshot(): AppState;
11
+ }, runtime: {
12
+ catalog(selection?: ModelSelection): Promise<import("./types.js").ModelCatalog>;
13
+ }, conversationId: string, selection?: ModelSelection): Promise<void>;
@@ -0,0 +1,40 @@
1
+ // Workspace names match case-insensitively. A model is allowed when either its label
2
+ // or provider/modelId matches. All matching rules must allow it (intersection).
3
+ // Invalid regexes are skipped with a diagnostic, never interpreted as deny-all.
4
+ export function applicableRules(rules, workspace) {
5
+ return (rules ?? []).flatMap(rule => {
6
+ try {
7
+ const workspaceRegex = new RegExp(rule.workspacePattern, "i");
8
+ const modelRegex = new RegExp(rule.modelPattern, "i");
9
+ return workspaceRegex.test(workspace.name) ? [{ rule, modelRegex }] : [];
10
+ }
11
+ catch (error) {
12
+ console.warn(`模型过滤规则 ${rule.id} 无效,已跳过:${rule.workspacePattern} → ${rule.modelPattern}:${String(error)}`);
13
+ return [];
14
+ }
15
+ });
16
+ }
17
+ export function assertModelAllowed(rules, workspace, model) {
18
+ for (const { rule, modelRegex } of applicableRules(rules, workspace)) {
19
+ if (!model || (!modelRegex.test(model.label ?? "") && !modelRegex.test(`${model.provider}/${model.modelId}`))) {
20
+ throw new Error(`工作区「${workspace.name}」命中模型规则「${rule.label ?? rule.id}」:/${rule.workspacePattern}/i → /${rule.modelPattern}/i;当前模型「${model ? `${model.label} (${model.provider}/${model.modelId})` : "Pi 默认模型无法解析"}」不匹配允许的模型正则,拒绝使用。`);
21
+ }
22
+ }
23
+ }
24
+ export function conversationWorkspace(state, conversationId) {
25
+ const conversation = state.conversations.find(item => item.id === conversationId);
26
+ const workspace = state.workspaces.find(item => item.id === conversation?.workspaceId);
27
+ if (!workspace)
28
+ throw new Error("会话所属工作区不存在");
29
+ return workspace;
30
+ }
31
+ /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
32
+ export async function validateConversationModel(store, runtime, conversationId, selection) {
33
+ const state = store.snapshot();
34
+ const workspace = conversationWorkspace(state, conversationId);
35
+ if (!applicableRules(state.modelFilterRules, workspace).length)
36
+ return;
37
+ const catalog = await runtime.catalog();
38
+ const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
39
+ assertModelAllowed(store.snapshot().modelFilterRules, conversationWorkspace(store.snapshot(), conversationId), model);
40
+ }
@@ -201,6 +201,7 @@ export interface AgentRuntime {
201
201
  */
202
202
  fork?(request: ForkRequest): Promise<ForkOutcome>;
203
203
  /** Updates the active agent directory or directories to search. */
204
+ setModelValidator?(validator: (conversationId: string, model: ModelSelection | undefined) => Promise<void>): void;
204
205
  setAgentsDir?(dir: string | string[]): void;
205
206
  }
206
207
  /** Deterministic offline runtime used by tests and local UI development. */
@@ -625,6 +626,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
625
626
  * spawning Pi so their prompt and tool restrictions cannot silently disappear.
626
627
  */
627
628
  agentFor(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
629
+ private modelValidator?;
630
+ setModelValidator(validator: (conversationId: string, model: ModelSelection | undefined) => Promise<void>): void;
628
631
  setAgentsDir(dir: string | string[]): void;
629
632
  /**
630
633
  * The `--extension` argv for this turn, or `[]` when the task tool is not available.
@@ -635,6 +635,8 @@ export class PiRpcRuntime {
635
635
  const dirLabel = Array.isArray(this.agents.dir) ? this.agents.dir.join(", ") : this.agents.dir;
636
636
  throw new Error(`Agent ${JSON.stringify(conversation.agentId)} 已失效或不是可用的 primary agent(目录:${dirLabel})。请重新选择 Agent,或选择“跟随 Pi 默认”后重试。`);
637
637
  }
638
+ modelValidator;
639
+ setModelValidator(validator) { this.modelValidator = validator; }
638
640
  setAgentsDir(dir) {
639
641
  this.agents.dir = dir;
640
642
  }
@@ -710,6 +712,9 @@ export class PiRpcRuntime {
710
712
  // nothing to send after the process is already running. Fetched before the spawn so a definition
711
713
  // error fails the turn loudly instead of starting a Pi that silently ignores it.
712
714
  const agent = await this.agentFor(conversation);
715
+ if (agent?.model)
716
+ await this.modelValidator?.(conversation.id, { ...agent.model, label: agent.model.modelId });
717
+ await this.modelValidator?.(conversation.id, conversation.model);
713
718
  // Whether the `task` tool EXISTS is decided first, because that is what decides whether it belongs
714
719
  // on the allowlist. Naming a tool Pi has not registered is meaningless, and doing it anyway would
715
720
  // change the spawn line of a runtime that has no task tool - which is what
@@ -784,6 +789,13 @@ export class PiRpcRuntime {
784
789
  if (response.success === false)
785
790
  throw new Error(`Pi rejected ${command.type}: ${response.error ?? "no reason given"}`);
786
791
  }
792
+ // Resumed Pi sessions can carry a different model than the global default.
793
+ // Check the real get_state result as well, before any prompt reaches the model.
794
+ if (this.modelValidator) {
795
+ const actual = await channel.request({ id: "policy_state", type: "get_state" }, SESSION_COMMAND_TIMEOUT_MS);
796
+ const model = actual.data?.model;
797
+ await this.modelValidator(conversation.id, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
798
+ }
787
799
  const imagePayloads = this.options.uploadsDir && images?.length
788
800
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
789
801
  : undefined;
@@ -1405,9 +1417,8 @@ export class PiRpcRuntime {
1405
1417
  source: "pi",
1406
1418
  models: options,
1407
1419
  levels: levelNames,
1408
- // Pi's own answer wins. The fallbacks only fire if get_state is missing the model, and they
1409
- // fall back to Pi's OWN ordering rather than to anything invented here.
1410
- defaultModel: pick(current) ?? pick(options[0]),
1420
+ // Only get_state identifies Pi's default. Catalog order must never invent a default.
1421
+ defaultModel: pick(current),
1411
1422
  defaultThinkingLevel: text(state.data?.thinkingLevel) ?? levelNames[0],
1412
1423
  fetchedAt: new Date().toISOString(),
1413
1424
  };
@@ -73,6 +73,7 @@ export declare class Store {
73
73
  private state;
74
74
  private constructor();
75
75
  static open(filePath: string): Promise<Store>;
76
+ setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
76
77
  snapshot(): AppState;
77
78
  createWorkspace(name: string, path?: string): Promise<Workspace>;
78
79
  renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
@@ -473,6 +473,19 @@ export class Store {
473
473
  return new Store(filePath, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
474
474
  }
475
475
  }
476
+ async setModelFilterRules(rules) {
477
+ if (!Array.isArray(rules) || rules.some(rule => !rule || typeof rule.id !== "string" || typeof rule.workspacePattern !== "string" || typeof rule.modelPattern !== "string" || (rule.label !== undefined && typeof rule.label !== "string")))
478
+ throw new Error("模型过滤规则格式不合法");
479
+ const previous = this.state.modelFilterRules;
480
+ this.state.modelFilterRules = structuredClone(rules);
481
+ try {
482
+ await this.persist();
483
+ }
484
+ catch (error) {
485
+ this.state.modelFilterRules = previous;
486
+ throw error;
487
+ }
488
+ }
476
489
  snapshot() { return structuredClone(this.state); }
477
490
  async createWorkspace(name, path) {
478
491
  const cleanName = name.trim();
@@ -146,6 +146,8 @@ export declare class SubagentSupervisor {
146
146
  * reachable. `Pi` is resolved once, because a resolution that could change mid-run would make one
147
147
  * child's spawn line depend on when it was started.
148
148
  */
149
+ private modelValidator?;
150
+ setModelValidator(validator: (conversationId: string, model: import("./types.js").ModelSelection | undefined) => Promise<void>): void;
149
151
  constructor(sessionDir: string, command?: string, agents?: {
150
152
  dir: string | string[];
151
153
  promptDir: string;
@@ -177,6 +177,8 @@ export class SubagentSupervisor {
177
177
  * reachable. `Pi` is resolved once, because a resolution that could change mid-run would make one
178
178
  * child's spawn line depend on when it was started.
179
179
  */
180
+ modelValidator;
181
+ setModelValidator(validator) { this.modelValidator = validator; }
180
182
  constructor(sessionDir, command, agents = { dir: join(sessionDir, "agents"), promptDir: join(sessionDir, "agent-prompts") }) {
181
183
  this.sessionDir = sessionDir;
182
184
  this.agents = agents;
@@ -250,6 +252,9 @@ export class SubagentSupervisor {
250
252
  // default. "Omit the model and get what the parent is running on" is the default worth having, and it
251
253
  // is a spawn-time flag because there is no RPC command to change it later.
252
254
  const chosen = agent.model ?? (model?.provider && model.modelId ? { provider: model.provider, modelId: model.modelId } : undefined);
255
+ if (model)
256
+ await this.modelValidator?.(conversationId, model);
257
+ await this.modelValidator?.(conversationId, chosen ? { ...chosen, label: chosen.modelId } : undefined);
253
258
  if (chosen)
254
259
  args.push("--provider", chosen.provider, "--model", chosen.modelId);
255
260
  if (agent.thinkingLevel)
@@ -1,3 +1,9 @@
1
+ export type ModelFilterRule = {
2
+ id: string;
3
+ workspacePattern: string;
4
+ modelPattern: string;
5
+ label?: string;
6
+ };
1
7
  import type { WorkflowRun } from "./workflow.js";
2
8
  export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
3
9
  export type MessageRole = "user" | "assistant" | "tool" | "system";
@@ -499,6 +505,7 @@ export type BackgroundTaskSummary = {
499
505
  * construct them - the same relationship `SubagentRun` already has with `subagent-runs.ts`.
500
506
  */
501
507
  export type AppState = {
508
+ modelFilterRules?: ModelFilterRule[];
502
509
  workspaces: Workspace[];
503
510
  conversations: Conversation[];
504
511
  subagentRuns: SubagentRun[];
@@ -614,7 +621,13 @@ export type ExtensionUiAnswer = {
614
621
  confirmed?: boolean;
615
622
  cancelled?: true;
616
623
  };
617
- export type ClientCommand = {
624
+ export type ClientCommand = ClientCommandBody & {
625
+ operationRequestId?: string;
626
+ };
627
+ export type ClientCommandBody = {
628
+ type: "model.rules.set";
629
+ rules: ModelFilterRule[];
630
+ } | {
618
631
  type: "workspace.create";
619
632
  name: string;
620
633
  path?: string;
@@ -713,6 +726,7 @@ export type ClientCommand = {
713
726
  /** Asks for the model/thinking catalog. Answered by `model.catalog`, never by an `error`. */
714
727
  | {
715
728
  type: "catalog.fetch";
729
+ conversationId?: string;
716
730
  }
717
731
  /** Asks for the agent catalog read from disk. Answered by `agent.catalog`. */
718
732
  | {
@@ -839,6 +853,12 @@ export type ServerVersionInfo = {
839
853
  needsRestart: boolean;
840
854
  };
841
855
  export type ServerEvent = {
856
+ type: "operation.result";
857
+ requestId: string;
858
+ ok: boolean;
859
+ message?: string;
860
+ entityId?: string;
861
+ } | {
842
862
  type: "server.version";
843
863
  version: ServerVersionInfo;
844
864
  } | {
@@ -99,15 +99,22 @@ export class WsServer {
99
99
  this.broadcaster.unregister(socket);
100
100
  });
101
101
  socket.on("message", async (raw) => {
102
+ let command;
102
103
  try {
103
- const command = JSON.parse(raw.toString());
104
- await this.commandHandler.handleCommand(command, (event) => {
104
+ command = JSON.parse(raw.toString());
105
+ const entityId = await this.commandHandler.handleCommand(command, (event) => {
105
106
  if (socket.readyState === WebSocket.OPEN) {
106
107
  socket.send(JSON.stringify(event));
107
108
  }
108
109
  });
110
+ if (command.operationRequestId)
111
+ socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: true, entityId }));
109
112
  }
110
113
  catch (error) {
114
+ if (command?.operationRequestId) {
115
+ socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: error instanceof Error ? error.message : String(error) }));
116
+ return;
117
+ }
111
118
  socket.send(JSON.stringify({
112
119
  type: "error",
113
120
  message: error instanceof Error ? error.message : "command failed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -74,7 +74,8 @@
74
74
  "scripts/fork-server.test.mjs",
75
75
  "scripts/styles-no-dangling.test.mjs",
76
76
  "scripts/theme-no-hardcoded-colours.test.mjs",
77
- "scripts/agent-config-regression.test.mjs"
77
+ "scripts/agent-config-regression.test.mjs",
78
+ "scripts/model-filter.test.mjs"
78
79
  ],
79
80
  "check:browser": [
80
81
  "scripts/extension-ui-browser.test.mjs",
@@ -106,7 +107,8 @@
106
107
  "scripts/file-tab-selected-contrast.test.mjs",
107
108
  "scripts/help-window.test.mjs",
108
109
  "scripts/composer-regression-browser.test.mjs",
109
- "scripts/workspace-picker-browser.test.mjs"
110
+ "scripts/workspace-picker-browser.test.mjs",
111
+ "scripts/optimistic-browser.test.mjs"
110
112
  ],
111
113
  "dependencies": {
112
114
  "ws": "latest"