@evandu/super-pi 0.1.20 → 0.1.22

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.
Files changed (44) hide show
  1. package/README.md +24 -1
  2. package/agents/super-pi-memory/index.ts +34 -0
  3. package/dist/client/assets/{index-BY__2nV9.css → index-BndehVXj.css} +1 -1
  4. package/dist/client/assets/index-DkgwgGW6.js +49 -0
  5. package/dist/client/favicon.ico +0 -0
  6. package/dist/client/favicon.png +0 -0
  7. package/dist/client/favicon.svg +10 -0
  8. package/dist/client/index.html +5 -2
  9. package/dist/server/active-memory.d.ts +118 -0
  10. package/dist/server/active-memory.js +214 -0
  11. package/dist/server/application/conversation-service.js +1 -0
  12. package/dist/server/application/run-service.d.ts +12 -0
  13. package/dist/server/application/run-service.js +49 -4
  14. package/dist/server/files.d.ts +1 -0
  15. package/dist/server/files.js +77 -46
  16. package/dist/server/http-server.d.ts +17 -1
  17. package/dist/server/http-server.js +158 -20
  18. package/dist/server/index.js +54 -27
  19. package/dist/server/memory.d.ts +25 -0
  20. package/dist/server/memory.js +88 -0
  21. package/dist/server/persistence/app-state-repository.d.ts +27 -0
  22. package/dist/server/persistence/app-state-repository.js +1 -0
  23. package/dist/server/persistence/json-state-repository.d.ts +12 -0
  24. package/dist/server/persistence/json-state-repository.js +24 -0
  25. package/dist/server/persistence/open-state-store.d.ts +3 -0
  26. package/dist/server/persistence/open-state-store.js +35 -0
  27. package/dist/server/persistence/rename.d.ts +22 -0
  28. package/dist/server/persistence/rename.js +44 -0
  29. package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
  30. package/dist/server/persistence/sqlite-state-repository.js +89 -0
  31. package/dist/server/runtime.d.ts +7 -1
  32. package/dist/server/runtime.js +23 -10
  33. package/dist/server/server-context.d.ts +1 -0
  34. package/dist/server/store.d.ts +12 -3
  35. package/dist/server/store.js +55 -82
  36. package/dist/server/subagent-runs.d.ts +2 -0
  37. package/dist/server/subagent-runs.js +6 -0
  38. package/dist/server/subagents.d.ts +2 -0
  39. package/dist/server/subagents.js +11 -3
  40. package/dist/server/workflow-engine.d.ts +2 -0
  41. package/dist/server/workflow-engine.js +14 -0
  42. package/dist/server/ws-server.js +15 -0
  43. package/package.json +8 -3
  44. package/dist/client/assets/index-B43FWHQ6.js +0 -43
@@ -1,5 +1,4 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { JsonStateRepository, defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
2
  import { normalizeTodos } from "./task-tool.js";
4
3
  function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
5
4
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
@@ -427,15 +426,19 @@ function reconcileTodos(state) {
427
426
  return { changed: before !== fingerprint(state.todos) };
428
427
  }
429
428
  export class Store {
430
- filePath;
429
+ repository;
431
430
  state;
432
- constructor(filePath, state) {
433
- this.filePath = filePath;
431
+ constructor(repository, state) {
432
+ this.repository = repository;
434
433
  this.state = state;
435
434
  }
436
435
  static async open(filePath) {
436
+ return this.openRepository(new JsonStateRepository(filePath));
437
+ }
438
+ /** Opens an injected repository, retaining domain reconciliation here. */
439
+ static async openRepository(repository) {
437
440
  try {
438
- const state = JSON.parse(await readFile(filePath, "utf8"));
441
+ const state = await repository.load();
439
442
  if (!Array.isArray(state.backgroundTasks))
440
443
  state.backgroundTasks = [];
441
444
  // Every sweep runs before the store is constructed, so a single persist below publishes them together.
@@ -446,7 +449,7 @@ export class Store {
446
449
  const interrupted = reconcileConversations(state);
447
450
  const requeued = reconcileQueuedMessages(state);
448
451
  const todos = reconcileTodos(state);
449
- const store = new Store(filePath, normalizeMessageTimestamps(state));
452
+ const store = new Store(repository, normalizeMessageTimestamps(state));
450
453
  // Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
451
454
  // is only rewritten by a later mutation would still read `running` to anything that opened the file
452
455
  // in the meantime, and would be reconciled a SECOND time on the next restart.
@@ -470,7 +473,7 @@ export class Store {
470
473
  if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
471
474
  throw error;
472
475
  const createdAt = now();
473
- return new Store(filePath, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
476
+ return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
474
477
  }
475
478
  }
476
479
  async setModelFilterRules(rules) {
@@ -678,7 +681,7 @@ export class Store {
678
681
  if (conversation.status === "running")
679
682
  throw new Error("运行中的会话无法归档,请先停止运行");
680
683
  conversation.archivedAt = now();
681
- await this.persist();
684
+ await this.persistMetadata(conversationId);
682
685
  }
683
686
  /** Restoring deletes the key rather than setting it to null, so a fully restored conversation serializes
684
687
  * byte-identically to one that was never archived. `delete` is legal here because the field is optional. */
@@ -687,7 +690,7 @@ export class Store {
687
690
  if (!conversation.archivedAt)
688
691
  return;
689
692
  delete conversation.archivedAt;
690
- await this.persist();
693
+ await this.persistMetadata(conversationId);
691
694
  }
692
695
  /**
693
696
  * Rename edits the title and NOTHING else. In particular `updatedAt` is deliberately not touched,
@@ -709,7 +712,7 @@ export class Store {
709
712
  throw new Error("Conversation name must be 1-80 characters");
710
713
  const conversation = this.requireConversation(conversationId);
711
714
  conversation.title = cleanName;
712
- await this.persist();
715
+ await this.persistMetadata(conversationId);
713
716
  return structuredClone(conversation);
714
717
  }
715
718
  async addUserMessage(conversationId, content, images) {
@@ -767,7 +770,7 @@ export class Store {
767
770
  // which is the exact failure this whole note is about, one level up. scripts/session-picker.test.mjs
768
771
  // pins the real key set instead, and re-breaks whenever a field is added.
769
772
  conversation.model = { provider: model.provider, modelId: model.modelId, label: model.label };
770
- await this.persist();
773
+ await this.persistMetadata(conversationId);
771
774
  return structuredClone(conversation);
772
775
  }
773
776
  /** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
@@ -777,7 +780,7 @@ export class Store {
777
780
  delete conversation.thinkingLevel;
778
781
  else
779
782
  conversation.thinkingLevel = level;
780
- await this.persist();
783
+ await this.persistMetadata(conversationId);
781
784
  return structuredClone(conversation);
782
785
  }
783
786
  /**
@@ -792,7 +795,7 @@ export class Store {
792
795
  delete conversation.agentId;
793
796
  else
794
797
  conversation.agentId = agentId;
795
- await this.persist();
798
+ await this.persistMetadata(conversationId);
796
799
  return structuredClone(conversation);
797
800
  }
798
801
  async setConversationConfiguration(conversationId, settings) {
@@ -823,8 +826,15 @@ export class Store {
823
826
  return structuredClone(conversation);
824
827
  }
825
828
  async beginAssistantMessage(conversationId, agentIdentity) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
826
- async appendAssistantDelta(conversationId, messageId, delta) { const conversation = this.requireConversation(conversationId); const message = conversation.messages.find((item) => item.id === messageId); if (!message)
827
- throw new Error("Assistant message not found"); message.content += delta; conversation.updatedAt = now(); await this.persist(); }
829
+ async appendAssistantDelta(conversationId, messageId, delta) {
830
+ const conversation = this.requireConversation(conversationId);
831
+ const message = conversation.messages.find((item) => item.id === messageId);
832
+ if (!message)
833
+ throw new Error("Assistant message not found");
834
+ message.content += delta;
835
+ conversation.updatedAt = now();
836
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
837
+ }
828
838
  /**
829
839
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
830
840
  * array so the caller can broadcast it as one snapshot.
@@ -894,7 +904,7 @@ export class Store {
894
904
  await this.persist();
895
905
  return structuredClone(calls);
896
906
  }
897
- async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persist(); }
907
+ async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
898
908
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
899
909
  planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
900
910
  /**
@@ -977,7 +987,7 @@ export class Store {
977
987
  if (images && images.length > 0)
978
988
  message.images = structuredClone(images);
979
989
  this.state.queuedMessages.push(message);
980
- await this.persist();
990
+ await this.persistCollections("queuedMessages");
981
991
  return structuredClone(message);
982
992
  }
983
993
  /**
@@ -1001,7 +1011,7 @@ export class Store {
1001
1011
  if (this.state.queuedMessages[index].state === "sending")
1002
1012
  throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
1003
1013
  const [removed] = this.state.queuedMessages.splice(index, 1);
1004
- await this.persist();
1014
+ await this.persistCollections("queuedMessages");
1005
1015
  return structuredClone(removed);
1006
1016
  }
1007
1017
  /**
@@ -1025,7 +1035,7 @@ export class Store {
1025
1035
  if (message.state === "sending")
1026
1036
  throw new Error(`排队消息已经交给 Agent 了:${queueId}`);
1027
1037
  message.state = "sending";
1028
- await this.persist();
1038
+ await this.persistCollections("queuedMessages");
1029
1039
  return structuredClone(message);
1030
1040
  }
1031
1041
  /**
@@ -1044,7 +1054,7 @@ export class Store {
1044
1054
  if (!message || message.state !== "sending")
1045
1055
  return;
1046
1056
  message.state = "queued";
1047
- await this.persist();
1057
+ await this.persistCollections("queuedMessages");
1048
1058
  }
1049
1059
  /**
1050
1060
  * Removes the OLDEST `count` messages this conversation has in `sending`, and returns them.
@@ -1067,7 +1077,7 @@ export class Store {
1067
1077
  if (!doomed.length)
1068
1078
  return [];
1069
1079
  this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.includes(item.id));
1070
- await this.persist();
1080
+ await this.persistCollections("queuedMessages");
1071
1081
  return structuredClone(sending.slice(0, doomed.length));
1072
1082
  }
1073
1083
  /**
@@ -1085,7 +1095,7 @@ export class Store {
1085
1095
  if (index < 0)
1086
1096
  return undefined;
1087
1097
  const [taken] = this.state.queuedMessages.splice(index, 1);
1088
- await this.persist();
1098
+ await this.persistCollections("queuedMessages");
1089
1099
  return structuredClone(taken);
1090
1100
  }
1091
1101
  /**
@@ -1106,7 +1116,7 @@ export class Store {
1106
1116
  changed = true;
1107
1117
  }
1108
1118
  if (changed)
1109
- await this.persist();
1119
+ await this.persistCollections("queuedMessages");
1110
1120
  }
1111
1121
  // ------------------------------------------------------------- Sub-agent runs
1112
1122
  // Records, not transcripts: what lands here is the dispatch, the last assistant message and the
@@ -1157,7 +1167,7 @@ export class Store {
1157
1167
  throw new Error(`Workflow already recorded: ${plan.id}`);
1158
1168
  this.state.workflows.unshift(plan);
1159
1169
  trimWorkflows(this.state.workflows);
1160
- await this.persist();
1170
+ await this.persistCollections("workflows");
1161
1171
  return structuredClone(plan);
1162
1172
  }
1163
1173
  /**
@@ -1182,7 +1192,7 @@ export class Store {
1182
1192
  throw new Error(`Workflow not found: ${stored.id}`);
1183
1193
  this.state.workflows[index] = stored;
1184
1194
  trimWorkflows(this.state.workflows);
1185
- await this.persist();
1195
+ await this.persistCollections("workflows");
1186
1196
  return structuredClone(stored);
1187
1197
  }
1188
1198
  async setBackgroundTask(summary) {
@@ -1191,11 +1201,11 @@ export class Store {
1191
1201
  Object.assign(existing, summary);
1192
1202
  else
1193
1203
  this.state.backgroundTasks.unshift(summary);
1194
- await this.persist();
1204
+ await this.persistCollections("backgroundTasks");
1195
1205
  }
1196
1206
  async removeBackgroundTask(id) {
1197
1207
  this.state.backgroundTasks = this.state.backgroundTasks.filter((item) => item.id !== id);
1198
- await this.persist();
1208
+ await this.persistCollections("backgroundTasks");
1199
1209
  }
1200
1210
  /**
1201
1211
  * Applies the fields a run's life can change, and nothing else.
@@ -1215,7 +1225,7 @@ export class Store {
1215
1225
  if (!run)
1216
1226
  throw new Error(`Sub-agent run not found: ${runId}`);
1217
1227
  Object.assign(run, changes);
1218
- await this.persist();
1228
+ await this.persistTransaction({ kind: "subagent.replace", run: structuredClone(run) });
1219
1229
  return structuredClone(run);
1220
1230
  }
1221
1231
  /**
@@ -1276,59 +1286,22 @@ export class Store {
1276
1286
  *
1277
1287
  * A rejected write must not poison the queue, or one failure would reject every later persist as well.
1278
1288
  */
1279
- persistQueue = Promise.resolve();
1280
- async persist() {
1281
- const write = this.persistQueue.then(async () => {
1282
- await mkdir(dirname(this.filePath), { recursive: true });
1283
- const temporaryPath = `${this.filePath}.tmp`;
1284
- await writeFile(temporaryPath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
1285
- await replaceFile(temporaryPath, this.filePath);
1286
- });
1287
- this.persistQueue = write.catch(() => { });
1288
- await write;
1289
+ async flush() { await this.repository.flush(); }
1290
+ /** Releases persistence resources after all accepted writes settle. */
1291
+ async close() { await this.flush(); this.repository.close?.(); }
1292
+ async persistTransaction(transaction) {
1293
+ if (this.repository.commit)
1294
+ await this.repository.commit(transaction);
1295
+ else
1296
+ await this.persist();
1289
1297
  }
1290
- }
1291
- /**
1292
- * How long `replaceFile` keeps retrying a rename that lost a race with a reader. Two seconds is two
1293
- * orders of magnitude past the measured sub-50ms release, so it exists to absorb a loaded machine and a
1294
- * virus scanner mid-scan, not to wait out a lock that will never clear.
1295
- */
1296
- const RENAME_RETRY_MS = 2_000;
1297
- const RENAME_RETRY_STEP_MS = 5;
1298
- /**
1299
- * Renames `from` over `to`, retrying the WINDOWS SHARING VIOLATION.
1300
- *
1301
- * WHY THIS EXISTS, because the failure it fixes was a real defect and not a test artefact. On Windows a
1302
- * rename onto a file that ANY handle has open fails with EPERM/EACCES: the destination must be held with
1303
- * FILE_SHARE_DELETE, and a plain reader does not do that. Node's own `readFile` is such a reader, and so
1304
- * is every indexer, virus scanner and sync client on the machine. So `persist()` threw EPERM, the caller's
1305
- * mutation stayed only in memory, and the save was LOST - the settings dialog had already closed on send,
1306
- * so the person who pressed 保存 was told nothing and kept a default that had never been saved.
1307
- * scripts/workspace-settings-browser.test.mjs reproduced it at roughly 1 save in 5, and every occurrence
1308
- * read as "the save never arrived" because the refusal travelled to the browser as an error event the
1309
- * suite did not read, so a refused write and a slow write were indistinguishable for a whole day.
1310
- *
1311
- * MEASURED, so the bound is honest rather than hopeful: renaming onto a held-open `state.json` fails on
1312
- * the first attempt 40 times out of 40 and clears within one 5ms tick of the reader letting go (worst
1313
- * case 11 attempts). The lock lasts exactly as long as somebody is READING, which is why a bounded
1314
- * retry is the fix and not a longer sleep: the transient case is short, and a genuinely stuck file is
1315
- * not a sharing violation. A file that stays contended past the bound still throws, which is the honest
1316
- * outcome - the store reports a failed write instead of pretending it saved.
1317
- */
1318
- async function replaceFile(from, to) {
1319
- const deadline = Date.now() + RENAME_RETRY_MS;
1320
- for (;;) {
1321
- try {
1322
- await rename(from, to);
1323
- return;
1324
- }
1325
- catch (error) {
1326
- const code = error.code;
1327
- const contended = code === "EPERM" || code === "EACCES" || code === "EBUSY";
1328
- if (!contended || Date.now() >= deadline)
1329
- throw error;
1330
- await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_STEP_MS));
1331
- }
1298
+ async persistCollections(key) {
1299
+ await this.persistTransaction({ kind: "collections.replace", values: { [key]: structuredClone(this.state[key]) } });
1300
+ }
1301
+ async persistMetadata(conversationId) {
1302
+ const { messages: _messages, ...metadata } = this.requireConversation(conversationId);
1303
+ await this.persistTransaction({ kind: "conversation.metadata", conversationId, metadata: structuredClone(metadata) });
1332
1304
  }
1305
+ async persist() { await this.repository.save(this.state); }
1333
1306
  }
1334
- export function defaultStorePath(dataDir) { return join(dataDir, "state.json"); }
1307
+ export const defaultStorePath = defaultJsonStatePath;
@@ -127,6 +127,8 @@ export declare class SubagentRunRecorder {
127
127
  * reconciliation cleans up the RECORD of such a run; this stops the PROCESS from being one.
128
128
  */
129
129
  abortAll(): void;
130
+ /** Waits for every child that was live at shutdown to settle and record its outcome. */
131
+ waitForIdle(): Promise<void>;
130
132
  /**
131
133
  * Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
132
134
  * as it ended.
@@ -99,6 +99,12 @@ export class SubagentRunRecorder {
99
99
  for (const entry of this.controllers.values())
100
100
  entry.controller.abort();
101
101
  }
102
+ /** Waits for every child that was live at shutdown to settle and record its outcome. */
103
+ async waitForIdle() {
104
+ while (this.controllers.size > 0) {
105
+ await new Promise((resolve) => setTimeout(resolve, 10));
106
+ }
107
+ }
102
108
  /**
103
109
  * Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
104
110
  * as it ended.
@@ -137,6 +137,8 @@ export declare class SubagentCancelled extends Error {
137
137
  export declare class SubagentSupervisor {
138
138
  private readonly sessionDir;
139
139
  private agents;
140
+ private memory?;
141
+ setMemory(memory: import("./active-memory.js").ActiveMemory): void;
140
142
  private readonly pi;
141
143
  /** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
142
144
  private readonly children;
@@ -168,6 +168,8 @@ const addUsage = (total, usage) => {
168
168
  export class SubagentSupervisor {
169
169
  sessionDir;
170
170
  agents;
171
+ memory;
172
+ setMemory(memory) { this.memory = memory; }
171
173
  pi;
172
174
  /** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
173
175
  children = new Map();
@@ -244,10 +246,12 @@ export class SubagentSupervisor {
244
246
  const agent = await this.subagentDefinition(agentId);
245
247
  // The child's own `tools:` map, minus whatever the dispatching agent denied. Never the parent's
246
248
  // ALLOWLIST - see `SubagentRunInput.denyTools` for why that asymmetry is the reference's too.
247
- const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { denyTools: input.denyTools ?? [] });
249
+ const memoryEnabled = this.memory ? (await this.memory.configuration()).enabled : false;
250
+ const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { denyTools: input.denyTools ?? [], extraAllowedTools: memoryEnabled ? ["memory", "skill_manage", "session_search"] : [] });
248
251
  const sessionDirectory = join(this.sessionDir, "subagents");
249
252
  await mkdir(sessionDirectory, { recursive: true });
250
- const args = ["--mode", "rpc", "--session-dir", sessionDirectory, "--session-id", id, ...agentArgs];
253
+ const memoryToken = memoryEnabled ? this.memory.issue(conversationId, { readOnly: true }) : undefined;
254
+ const args = ["--mode", "rpc", "--session-dir", sessionDirectory, "--session-id", id, ...(memoryToken ? ["--extension", resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts")] : []), ...agentArgs];
251
255
  // Model inheritance: the definition wins, otherwise the parent's current model, otherwise Pi's own
252
256
  // default. "Omit the model and get what the parent is running on" is the default worth having, and it
253
257
  // is a spawn-time flag because there is no RPC command to change it later.
@@ -260,7 +264,9 @@ export class SubagentSupervisor {
260
264
  if (agent.thinkingLevel)
261
265
  args.push("--thinking", agent.thinkingLevel);
262
266
  // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
263
- const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" });
267
+ const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", ...(memoryToken ? { env: { ...process.env, SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT, SUPER_PI_MEMORY_TOKEN: memoryToken, SUPER_PI_MEMORY_READ_ONLY: "1" } } : {}) });
268
+ if (memoryToken)
269
+ child.once("close", () => this.memory?.release(memoryToken));
264
270
  this.children.set(id, { conversationId, child });
265
271
  const channel = new RpcChannel(child);
266
272
  // Pi is asked to stop first and killed only if that is not enough, so a child that can unwind its own
@@ -508,6 +514,8 @@ export class SubagentSupervisor {
508
514
  return { id, sessionId: id, finalText: finalText || streamed, usage };
509
515
  }
510
516
  finally {
517
+ if (memoryToken)
518
+ this.memory?.release(memoryToken);
511
519
  signal?.removeEventListener("abort", abort);
512
520
  this.children.delete(id);
513
521
  // The run is not over until the child is actually gone: a process still tearing down still holds a
@@ -77,6 +77,8 @@ export declare class WorkflowEngine {
77
77
  stopConversation(conversationId: string): number;
78
78
  /** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
79
79
  abortAll(): void;
80
+ /** Waits until aborted plans have committed their terminal state. */
81
+ waitForIdle(): Promise<void>;
80
82
  /** The conversation a plan belongs to, which only this map knows while the plan is running. */
81
83
  private conversationOf;
82
84
  /**
@@ -50,6 +50,12 @@ export class WorkflowEngine {
50
50
  for (const controller of this.controllers.values())
51
51
  controller.abort();
52
52
  }
53
+ /** Waits until aborted plans have committed their terminal state. */
54
+ async waitForIdle() {
55
+ while (this.controllers.size > 0) {
56
+ await new Promise((resolve) => setTimeout(resolve, 10));
57
+ }
58
+ }
53
59
  /** The conversation a plan belongs to, which only this map knows while the plan is running. */
54
60
  conversationOf(workflowId) {
55
61
  return this.owners.get(workflowId);
@@ -74,6 +80,8 @@ export class WorkflowEngine {
74
80
  const controller = new AbortController();
75
81
  const onCallerAbort = () => controller.abort();
76
82
  input.signal?.addEventListener("abort", onCallerAbort, { once: true });
83
+ if (input.signal?.aborted)
84
+ controller.abort();
77
85
  this.controllers.set(id, controller);
78
86
  this.owners.set(id, input.conversationId);
79
87
  // THE WHOLE PLAN, IN FULL, BEFORE STEP ONE RUNS. See `Store.beginWorkflow`: this is the write that
@@ -168,6 +176,12 @@ export class WorkflowEngine {
168
176
  // not acceptable, so handing the second attempt the same inputs would make the policy a no-op that
169
177
  // burns the same sub-agent's answer twice and reports it as progress.
170
178
  const composed = composeStepPrompt(step.prompt, plan.steps.slice(0, step.index));
179
+ // A retry receives the failed attempt's evidence, rather than repeating a blind prompt.
180
+ const previousAttempt = step.attempts.at(-1);
181
+ if (previousAttempt) {
182
+ const evidence = [previousAttempt.error, previousAttempt.output].filter(Boolean).join("\n").slice(-8000);
183
+ composed.prompt += `\n\n[Previous attempt ${previousAttempt.n} failed]\n${evidence}\nAddress the failure above before completing this step.`;
184
+ }
171
185
  await this.commit(plan);
172
186
  const run = await this.recorder.dispatch({
173
187
  conversationId: input.conversationId,
@@ -61,6 +61,21 @@ export class WsServer {
61
61
  socket.destroy();
62
62
  return;
63
63
  }
64
+ const origin = request.headers.origin;
65
+ if (origin) {
66
+ try {
67
+ const parsed = new URL(origin);
68
+ const host = request.headers.host;
69
+ if (!host || parsed.host !== host || !["http:", "https:"].includes(parsed.protocol)) {
70
+ socket.destroy();
71
+ return;
72
+ }
73
+ }
74
+ catch {
75
+ socket.destroy();
76
+ return;
77
+ }
78
+ }
64
79
  this.websocketServer.handleUpgrade(request, socket, head, (ws) => {
65
80
  this.websocketServer.emit("connection", ws, request);
66
81
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,9 @@
33
33
  "check": "node scripts/run-check.mjs"
34
34
  },
35
35
  "check:logic": [
36
+ "scripts/lan-access.test.mjs",
37
+ "scripts/persistence-repository.test.mjs",
38
+ "scripts/sqlite-repository.test.mjs",
36
39
  "scripts/dispatch-ancestry.test.mjs",
37
40
  "scripts/dispatch-endpoint.test.mjs",
38
41
  "scripts/dispatched-by.test.mjs",
@@ -75,7 +78,8 @@
75
78
  "scripts/styles-no-dangling.test.mjs",
76
79
  "scripts/theme-no-hardcoded-colours.test.mjs",
77
80
  "scripts/agent-config-regression.test.mjs",
78
- "scripts/model-filter.test.mjs"
81
+ "scripts/model-filter.test.mjs",
82
+ "scripts/active-memory.test.mjs"
79
83
  ],
80
84
  "check:browser": [
81
85
  "scripts/extension-ui-browser.test.mjs",
@@ -108,7 +112,8 @@
108
112
  "scripts/help-window.test.mjs",
109
113
  "scripts/composer-regression-browser.test.mjs",
110
114
  "scripts/workspace-picker-browser.test.mjs",
111
- "scripts/optimistic-browser.test.mjs"
115
+ "scripts/optimistic-browser.test.mjs",
116
+ "scripts/active-memory-browser.test.mjs"
112
117
  ],
113
118
  "dependencies": {
114
119
  "ws": "latest"