@evandu/super-pi 0.1.21 → 0.1.23

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 (51) hide show
  1. package/README.md +58 -1
  2. package/agents/super-pi-memory/index.ts +34 -0
  3. package/dist/client/assets/index-BKKZnCis.css +1 -0
  4. package/dist/client/assets/index-LWO_0omC.js +50 -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 +48 -4
  14. package/dist/server/command-handler.d.ts +16 -2
  15. package/dist/server/command-handler.js +24 -2
  16. package/dist/server/files.d.ts +1 -0
  17. package/dist/server/files.js +77 -46
  18. package/dist/server/http-server.d.ts +17 -1
  19. package/dist/server/http-server.js +185 -20
  20. package/dist/server/index.js +97 -29
  21. package/dist/server/memory.d.ts +25 -0
  22. package/dist/server/memory.js +88 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +27 -0
  24. package/dist/server/persistence/app-state-repository.js +1 -0
  25. package/dist/server/persistence/json-state-repository.d.ts +12 -0
  26. package/dist/server/persistence/json-state-repository.js +24 -0
  27. package/dist/server/persistence/open-state-store.d.ts +3 -0
  28. package/dist/server/persistence/open-state-store.js +35 -0
  29. package/dist/server/persistence/rename.d.ts +22 -0
  30. package/dist/server/persistence/rename.js +44 -0
  31. package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
  32. package/dist/server/persistence/sqlite-state-repository.js +114 -0
  33. package/dist/server/runtime.d.ts +7 -1
  34. package/dist/server/runtime.js +23 -10
  35. package/dist/server/server-context.d.ts +12 -0
  36. package/dist/server/store.d.ts +12 -3
  37. package/dist/server/store.js +55 -82
  38. package/dist/server/subagent-runs.d.ts +2 -0
  39. package/dist/server/subagent-runs.js +6 -0
  40. package/dist/server/subagents.d.ts +2 -0
  41. package/dist/server/subagents.js +11 -3
  42. package/dist/server/terminal.d.ts +139 -0
  43. package/dist/server/terminal.js +366 -0
  44. package/dist/server/types.d.ts +69 -2
  45. package/dist/server/workflow-engine.d.ts +2 -0
  46. package/dist/server/workflow-engine.js +6 -0
  47. package/dist/server/ws-server.d.ts +11 -0
  48. package/dist/server/ws-server.js +42 -2
  49. package/package.json +2 -129
  50. package/dist/client/assets/index-B43FWHQ6.js +0 -43
  51. package/dist/client/assets/index-BY__2nV9.css +0 -1
@@ -553,6 +553,8 @@ export class PiRpcRuntime {
553
553
  sessionDir;
554
554
  agents;
555
555
  options;
556
+ memory;
557
+ setMemory(memory) { this.memory = memory; }
556
558
  runs = new Map();
557
559
  /**
558
560
  * `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
@@ -698,7 +700,7 @@ export class PiRpcRuntime {
698
700
  [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
699
701
  };
700
702
  }
701
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }) {
703
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images, memoryReview, memorySourceConversationId }) {
702
704
  await mkdir(this.sessionDir, { recursive: true });
703
705
  // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
704
706
  // turns both into one absolute native path. The directory must exist on disk before spawn - a
@@ -711,10 +713,11 @@ export class PiRpcRuntime {
711
713
  // them: its RPC command union (rpc-types.ts:20-74) has no prompt and no tools command, so there is
712
714
  // nothing to send after the process is already running. Fetched before the spawn so a definition
713
715
  // error fails the turn loudly instead of starting a Pi that silently ignores it.
714
- const agent = await this.agentFor(conversation);
716
+ const agent = memoryReview ? undefined : await this.agentFor(conversation);
717
+ const validationId = memorySourceConversationId ?? conversation.id;
715
718
  if (agent?.model)
716
- await this.modelValidator?.(conversation.id, { ...agent.model, label: agent.model.modelId });
717
- await this.modelValidator?.(conversation.id, conversation.model);
719
+ await this.modelValidator?.(validationId, { ...agent.model, label: agent.model.modelId });
720
+ await this.modelValidator?.(validationId, conversation.model);
718
721
  // Whether the `task` tool EXISTS is decided first, because that is what decides whether it belongs
719
722
  // on the allowlist. Naming a tool Pi has not registered is meaningless, and doing it anyway would
720
723
  // change the spawn line of a runtime that has no task tool - which is what
@@ -727,20 +730,24 @@ export class PiRpcRuntime {
727
730
  // declared - broken with no error. An agent that says `task: false` still wins: `toolFlags` treats an
728
731
  // explicit false as outranking a host extra, which is what makes "this agent does not delegate" true
729
732
  // rather than advisory.
730
- const extensionArgs = await this.taskExtensionArgs();
733
+ const extensionArgs = memoryReview ? [] : await this.taskExtensionArgs();
734
+ const memoryEnabled = this.memory ? (await this.memory.configuration()).enabled : false;
735
+ const memoryToken = memoryEnabled ? this.memory.issue(validationId, { review: memoryReview }) : undefined;
736
+ const memoryExtension = resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts");
737
+ const memoryArgs = memoryEnabled ? ["--extension", memoryExtension] : [];
731
738
  // BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
732
739
  // different direction: Pi filters its registry through `--tools`, so an agent that declares
733
740
  // `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
734
741
  // other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
735
742
  // `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
736
743
  // the other is not.
737
- const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : [] });
744
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : [])] });
738
745
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
739
746
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
740
747
  if (!this.pi.found)
741
748
  throw new Error(this.pi.reason);
742
749
  // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
743
- const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...agentArgs], {
750
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...agentArgs], {
744
751
  cwd: childCwd,
745
752
  stdio: ["pipe", "pipe", "pipe"],
746
753
  shell: process.platform === "win32",
@@ -748,7 +755,11 @@ export class PiRpcRuntime {
748
755
  // every turn would silently replace the child's environment with whatever THIS process has at
749
756
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
750
757
  // but the four documented additions.
751
- ...(extensionArgs.length ? { env: { ...process.env, ...await this.taskExtensionEnv(conversation.id) } } : {}),
758
+ ...((extensionArgs.length || memoryEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.taskExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
759
+ SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
760
+ SUPER_PI_MEMORY_TOKEN: memoryToken,
761
+ SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
762
+ } : {}) } } : {}),
752
763
  });
753
764
  const channel = new RpcChannel(child);
754
765
  this.runs.set(conversation.id, { child, channel });
@@ -794,7 +805,7 @@ export class PiRpcRuntime {
794
805
  if (this.modelValidator) {
795
806
  const actual = await channel.request({ id: "policy_state", type: "get_state" }, SESSION_COMMAND_TIMEOUT_MS);
796
807
  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);
808
+ await this.modelValidator(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
798
809
  }
799
810
  const imagePayloads = this.options.uploadsDir && images?.length
800
811
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
@@ -957,12 +968,14 @@ export class PiRpcRuntime {
957
968
  // exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
958
969
  // credential whose turn has ended is a credential with no owner.
959
970
  releaseCapability(conversation.id);
971
+ if (memoryToken)
972
+ this.memory?.release(memoryToken);
960
973
  // A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
961
974
  // is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
962
975
  // promise: the caller would wait for a timeout to learn something this process already knows. The
963
976
  // message itself is not lost - Pi drains steering before its loop can end, so anything unanswered
964
977
  // at this point was in practice not delivered, and the caller returns it to its queue.
965
- if (this.pendingSteers.size) {
978
+ if (!memoryReview && this.pendingSteers.size) {
966
979
  for (const [id, pending] of [...this.pendingSteers]) {
967
980
  this.pendingSteers.delete(id);
968
981
  pending.reject(new Error(`Pi never answered the steer: the turn ended first.`));
@@ -33,6 +33,7 @@ export interface EventBroadcaster {
33
33
  * Central server context containing infrastructure dependencies and application services.
34
34
  */
35
35
  export interface ServerContext {
36
+ memory?: import("./active-memory.js").ActiveMemory;
36
37
  paths: ServerPaths;
37
38
  store: Store;
38
39
  runtime: AgentRuntime;
@@ -44,4 +45,15 @@ export interface ServerContext {
44
45
  runService: RunService;
45
46
  extensionUiService: ExtensionUiService;
46
47
  catalogService: CatalogService;
48
+ /**
49
+ * Whether the right rail's terminal tab should exist, and why not when it should not.
50
+ *
51
+ * TWO PREDICATES AND NOT A `TerminalService` HANDLE, and that is the load-bearing part. `/api/terminal`
52
+ * only ever needs to answer "is it on"; handing the HTTP layer the service itself would put a
53
+ * `spawn` within reach of a GET request, and the terminal is the one part of this server that is not
54
+ * confined to a workspace. Keeping the handle on `CommandHandler` alone means the only route to a
55
+ * shell is a WebSocket message that `TerminalService.assertUsable` has already vetted.
56
+ */
57
+ terminalEnabled?: () => boolean;
58
+ terminalDisabledReason?: () => string;
47
59
  }
@@ -1,3 +1,5 @@
1
+ import type { AppStateRepository } from "./persistence/app-state-repository.js";
2
+ import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
1
3
  import type { WorkflowRun } from "./workflow.js";
2
4
  import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
3
5
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
@@ -69,10 +71,12 @@ export type RevertPlan = {
69
71
  doomedRunIds: string[];
70
72
  };
71
73
  export declare class Store {
72
- private readonly filePath;
74
+ private readonly repository;
73
75
  private state;
74
76
  private constructor();
75
77
  static open(filePath: string): Promise<Store>;
78
+ /** Opens an injected repository, retaining domain reconciliation here. */
79
+ static openRepository(repository: AppStateRepository): Promise<Store>;
76
80
  setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
77
81
  snapshot(): AppState;
78
82
  createWorkspace(name: string, path?: string): Promise<Workspace>;
@@ -476,7 +480,12 @@ export declare class Store {
476
480
  *
477
481
  * A rejected write must not poison the queue, or one failure would reject every later persist as well.
478
482
  */
479
- private persistQueue;
483
+ flush(): Promise<void>;
484
+ /** Releases persistence resources after all accepted writes settle. */
485
+ close(): Promise<void>;
486
+ private persistTransaction;
487
+ private persistCollections;
488
+ private persistMetadata;
480
489
  private persist;
481
490
  }
482
- export declare function defaultStorePath(dataDir: string): string;
491
+ export declare const defaultStorePath: typeof defaultJsonStatePath;
@@ -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