@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.
- package/README.md +24 -1
- package/agents/super-pi-memory/index.ts +34 -0
- package/dist/client/assets/{index-BY__2nV9.css → index-BndehVXj.css} +1 -1
- package/dist/client/assets/index-DkgwgGW6.js +49 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/favicon.svg +10 -0
- package/dist/client/index.html +5 -2
- package/dist/server/active-memory.d.ts +118 -0
- package/dist/server/active-memory.js +214 -0
- package/dist/server/application/conversation-service.js +1 -0
- package/dist/server/application/run-service.d.ts +12 -0
- package/dist/server/application/run-service.js +49 -4
- package/dist/server/files.d.ts +1 -0
- package/dist/server/files.js +77 -46
- package/dist/server/http-server.d.ts +17 -1
- package/dist/server/http-server.js +158 -20
- package/dist/server/index.js +54 -27
- package/dist/server/memory.d.ts +25 -0
- package/dist/server/memory.js +88 -0
- package/dist/server/persistence/app-state-repository.d.ts +27 -0
- package/dist/server/persistence/app-state-repository.js +1 -0
- package/dist/server/persistence/json-state-repository.d.ts +12 -0
- package/dist/server/persistence/json-state-repository.js +24 -0
- package/dist/server/persistence/open-state-store.d.ts +3 -0
- package/dist/server/persistence/open-state-store.js +35 -0
- package/dist/server/persistence/rename.d.ts +22 -0
- package/dist/server/persistence/rename.js +44 -0
- package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
- package/dist/server/persistence/sqlite-state-repository.js +89 -0
- package/dist/server/runtime.d.ts +7 -1
- package/dist/server/runtime.js +23 -10
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +12 -3
- package/dist/server/store.js +55 -82
- package/dist/server/subagent-runs.d.ts +2 -0
- package/dist/server/subagent-runs.js +6 -0
- package/dist/server/subagents.d.ts +2 -0
- package/dist/server/subagents.js +11 -3
- package/dist/server/workflow-engine.d.ts +2 -0
- package/dist/server/workflow-engine.js +14 -0
- package/dist/server/ws-server.js +15 -0
- package/package.json +8 -3
- package/dist/client/assets/index-B43FWHQ6.js +0 -43
package/dist/server/store.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
429
|
+
repository;
|
|
431
430
|
state;
|
|
432
|
-
constructor(
|
|
433
|
-
this.
|
|
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 =
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|
|
827
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
await
|
|
1285
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
|
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;
|
package/dist/server/subagents.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/dist/server/ws-server.js
CHANGED
|
@@ -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.
|
|
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"
|