@evandu/super-pi 0.1.13 → 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, conversationWorkspace } from "../model-filter.js";
1
2
  import { newConversationId } from "../store.js";
2
3
  /**
3
4
  * Service managing conversation lifecycle: creation, selection, archiving,
@@ -12,6 +13,8 @@ export class ConversationService {
12
13
  runService;
13
14
  extensionUiService;
14
15
  agentsDir;
16
+ /** Conversations whose Pi transcript is being cut RIGHT NOW. Read by the send path, below. */
17
+ reverting = new Set();
15
18
  constructor(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
16
19
  this.store = store;
17
20
  this.runtime = runtime;
@@ -35,8 +38,9 @@ export class ConversationService {
35
38
  const defaults = workspace.defaultAgentId
36
39
  ? await this.resolveAgentConfiguration({ id: workspaceId, model: workspace.defaultModel, thinkingLevel: workspace.defaultThinkingLevel }, workspace.defaultAgentId)
37
40
  : undefined;
38
- await this.store.createConversation(workspaceId, defaults);
41
+ const created = await this.store.createConversation(workspaceId, defaults);
39
42
  this.broadcaster.broadcastState();
43
+ return created.id;
40
44
  }
41
45
  /**
42
46
  * Selects a conversation and broadcasts the catalog for that conversation's model.
@@ -45,11 +49,13 @@ export class ConversationService {
45
49
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
46
50
  if (!conversation)
47
51
  throw new Error("Conversation not found");
48
- return this.catalogService.getCatalog(conversation.model);
52
+ const state = this.store.snapshot();
53
+ const catalog = await this.runtime.catalog();
54
+ assertModelAllowed(state.modelFilterRules, conversationWorkspace(state, conversationId), conversation.model ?? catalog.defaultModel);
55
+ return this.catalogService.getCatalog(conversation.model, conversationId);
49
56
  }
50
57
  async selectConversation(conversationId) {
51
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
52
- await this.catalogService.broadcastCatalog(conversation?.model, conversationId);
58
+ this.broadcaster.broadcast({ type: "model.catalog", conversationId, catalog: await this.getConversationCatalog(conversationId) });
53
59
  }
54
60
  /**
55
61
  * Archives a conversation.
@@ -86,6 +92,8 @@ export class ConversationService {
86
92
  const selection = option
87
93
  ? { provider: option.provider, modelId: option.modelId, label: option.label }
88
94
  : { provider, modelId, label: modelId };
95
+ const state = this.store.snapshot();
96
+ assertModelAllowed(state.modelFilterRules, conversationWorkspace(state, conversationId), selection);
89
97
  await this.store.setConversationModel(conversationId, selection);
90
98
  this.broadcaster.broadcastState();
91
99
  await this.catalogService.broadcastCatalog(selection, conversationId);
@@ -142,6 +150,9 @@ export class ConversationService {
142
150
  throw this.catalogService.notOffered(`Agent ${agent.name} 指定的思考等级 ${agent.thinkingLevel}`, catalog);
143
151
  }
144
152
  }
153
+ const state = this.store.snapshot();
154
+ const workspace = state.workspaces.find(item => item.id === conversation.id) ?? conversationWorkspace(state, conversation.id);
155
+ assertModelAllowed(state.modelFilterRules, workspace, selection ?? (await this.runtime.catalog()).defaultModel);
145
156
  return { agentId: agent.id, model: selection, thinkingLevel: agent.thinkingLevel ?? conversation.thinkingLevel };
146
157
  }
147
158
  /**
@@ -149,6 +160,28 @@ export class ConversationService {
149
160
  * Pi's transcript and state.json.
150
161
  */
151
162
  async revertMessage(conversationId, messageId, withdraw = false) {
163
+ // CLAIMED FOR THE WHOLE CUT, and released in a `finally` however this ends - a revert that threw
164
+ // halfway must not leave the conversation permanently unable to send.
165
+ //
166
+ // WHY THE CLAIM EXISTS AT ALL. Truncating Pi's transcript is not instantaneous: it spawns a Pi to ask
167
+ // which file the session lives in, and only then writes. A message sent in that window would be
168
+ // appended to a history the model still reads in full - the very turn the person just withdrew would
169
+ // still be in its context, and the new answer would be built on top of it. So the send is what waits,
170
+ // NOT the text coming back: the browser restores the message optimistically and the person can edit
171
+ // it immediately, and what is refused is the next SEND until the cut has landed.
172
+ this.reverting.add(conversationId);
173
+ try {
174
+ return await this.cutTo(conversationId, messageId, withdraw);
175
+ }
176
+ finally {
177
+ this.reverting.delete(conversationId);
178
+ }
179
+ }
180
+ /** Whether a revert is currently cutting this conversation's history. The send path asks. */
181
+ isReverting(conversationId) {
182
+ return this.reverting.has(conversationId);
183
+ }
184
+ async cutTo(conversationId, messageId, withdraw) {
152
185
  let plan = this.store.planRevert(conversationId, messageId, withdraw);
153
186
  let conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
154
187
  if (!conversation)
@@ -29,7 +29,7 @@ export declare class WorkspaceService {
29
29
  /**
30
30
  * Creates a new workspace.
31
31
  */
32
- createWorkspace(name: string, path?: string): Promise<void>;
32
+ createWorkspace(name: string, path?: string): Promise<string>;
33
33
  /**
34
34
  * Renames an existing workspace.
35
35
  */
@@ -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;
@@ -85,6 +95,12 @@ export class CommandHandler {
85
95
  return;
86
96
  }
87
97
  if (command.type === "message.send") {
98
+ // A send that races a revert would be appended to a history the model is still reading in full - the
99
+ // turn the person just withdrew would still be in its context, and the new answer would be built on
100
+ // top of it. Refusing with a sentence beats accepting a turn built on a cut that has not landed yet.
101
+ if (this.conversationService.isReverting(command.conversationId)) {
102
+ throw new Error("正在撤回上一条消息,Pi 的历史还没截断完成,请等撤回结束后再发送。");
103
+ }
88
104
  await this.runService.enqueueAndRun(command.conversationId, command.content, command.images);
89
105
  return;
90
106
  }
@@ -137,9 +153,9 @@ export class CommandHandler {
137
153
  }
138
154
  if (command.type === "catalog.fetch") {
139
155
  if (sendToSocket)
140
- 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) });
141
157
  else
142
- await this.catalogService.broadcastCatalog();
158
+ await this.catalogService.broadcastCatalog(undefined, command.conversationId);
143
159
  return;
144
160
  }
145
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.13",
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"