@evandu/super-pi 0.0.0-stage → 0.1.1
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 +79 -2
- package/agents/coder.md +10 -0
- package/agents/critic.md +20 -0
- package/agents/patcher.md +23 -0
- package/agents/planner.md +18 -0
- package/agents/reviewer.md +13 -0
- package/agents/scout.md +23 -0
- package/agents/super-pi-task/core.ts +485 -0
- package/agents/super-pi-task/index.ts +141 -0
- package/agents/verifier.md +21 -0
- package/bin/super-pi.js +17 -0
- package/dist/client/assets/index-BMQsEdio.js +24 -0
- package/dist/client/assets/index-CsbZzuMy.css +1 -0
- package/dist/client/index.html +14 -0
- package/dist/extension.d.ts +15 -0
- package/dist/extension.js +111 -0
- package/dist/server/agents.d.ts +177 -0
- package/dist/server/agents.js +484 -0
- package/dist/server/application/catalog-service.d.ts +44 -0
- package/dist/server/application/catalog-service.js +94 -0
- package/dist/server/application/conversation-service.d.ts +73 -0
- package/dist/server/application/conversation-service.js +202 -0
- package/dist/server/application/extension-ui-service.d.ts +28 -0
- package/dist/server/application/extension-ui-service.js +73 -0
- package/dist/server/application/run-service.d.ts +115 -0
- package/dist/server/application/run-service.js +341 -0
- package/dist/server/application/workspace-service.d.ts +45 -0
- package/dist/server/application/workspace-service.js +106 -0
- package/dist/server/command-handler.d.ts +22 -0
- package/dist/server/command-handler.js +137 -0
- package/dist/server/files.d.ts +202 -0
- package/dist/server/files.js +629 -0
- package/dist/server/http-server.d.ts +20 -0
- package/dist/server/http-server.js +313 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/pi-command.d.ts +65 -0
- package/dist/server/pi-command.js +183 -0
- package/dist/server/runtime.d.ts +819 -0
- package/dist/server/runtime.js +1470 -0
- package/dist/server/server-context.d.ts +46 -0
- package/dist/server/server-context.js +1 -0
- package/dist/server/store.d.ts +424 -0
- package/dist/server/store.js +1121 -0
- package/dist/server/subagent-runs.d.ts +142 -0
- package/dist/server/subagent-runs.js +315 -0
- package/dist/server/subagents.d.ts +194 -0
- package/dist/server/subagents.js +542 -0
- package/dist/server/task-tool.d.ts +196 -0
- package/dist/server/task-tool.js +285 -0
- package/dist/server/tool-calls.d.ts +62 -0
- package/dist/server/tool-calls.js +141 -0
- package/dist/server/types.d.ts +920 -0
- package/dist/server/types.js +11 -0
- package/dist/server/upload.d.ts +37 -0
- package/dist/server/upload.js +169 -0
- package/dist/server/ws-server.d.ts +45 -0
- package/dist/server/ws-server.js +116 -0
- package/package.json +115 -4
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, searchByName, toRelative, writeWorkspaceFile, } from "./files.js";
|
|
6
|
+
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
7
|
+
const MIME_TYPES = {
|
|
8
|
+
".css": "text/css; charset=utf-8",
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".svg": "image/svg+xml",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".ico": "image/x-icon",
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Checks if the remote address is a local loopback interface.
|
|
17
|
+
*/
|
|
18
|
+
export function isLoopbackAddress(remoteAddress) {
|
|
19
|
+
if (!remoteAddress)
|
|
20
|
+
return false;
|
|
21
|
+
return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remoteAddress);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Checks whether an incoming HTTP request satisfies access token authorization.
|
|
25
|
+
*/
|
|
26
|
+
export function isAuthorized(request, accessToken) {
|
|
27
|
+
return !accessToken || request.headers.authorization === `Bearer ${accessToken}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates the HTTP server providing static client assets and all `/api/*` endpoints.
|
|
31
|
+
*/
|
|
32
|
+
export function createHttpServer(options) {
|
|
33
|
+
const { context, onShutdown } = options;
|
|
34
|
+
const { store, paths, accessToken, broadcaster, runService, catalogService } = context;
|
|
35
|
+
const { uploadsDir, clientDir, rootDir } = paths;
|
|
36
|
+
return createServer(async (request, response) => {
|
|
37
|
+
if (!isAuthorized(request, accessToken)) {
|
|
38
|
+
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
42
|
+
const remote = request.socket.remoteAddress ?? "";
|
|
43
|
+
if (url.pathname === "/api/shutdown") {
|
|
44
|
+
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
45
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback POST");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
|
|
49
|
+
.end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
|
|
50
|
+
setTimeout(() => onShutdown("api-shutdown"), 50);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (url.pathname === "/api/health") {
|
|
54
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
55
|
+
.end(JSON.stringify({ status: "ok", service: "super-pi" }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname === "/api/subagent/dispatch") {
|
|
59
|
+
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
60
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback POST");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let raw = "";
|
|
64
|
+
for await (const chunk of request)
|
|
65
|
+
raw += chunk;
|
|
66
|
+
const json = (status, body) => {
|
|
67
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const body = JSON.parse(raw);
|
|
71
|
+
const result = await runService.dispatchTask(body);
|
|
72
|
+
json(result.status, result.body);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
json(400, { error: error instanceof Error ? error.message : "task dispatch failed" });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (url.pathname === "/api/todos") {
|
|
80
|
+
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
81
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback POST");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let raw = "";
|
|
85
|
+
for await (const chunk of request)
|
|
86
|
+
raw += chunk;
|
|
87
|
+
const json = (status, body) => {
|
|
88
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
const body = JSON.parse(raw);
|
|
92
|
+
const conversationId = body.conversationId?.trim();
|
|
93
|
+
if (!conversationId)
|
|
94
|
+
throw new Error("todowrite requires conversationId");
|
|
95
|
+
const { todos, dropped } = await store.setTodos(body.todos);
|
|
96
|
+
broadcaster.broadcastState();
|
|
97
|
+
json(200, { todos, dropped, conversationId });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
json(400, { error: error instanceof Error ? error.message : "todo write failed" });
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (url.pathname === "/api/files" || url.pathname === "/api/file") {
|
|
105
|
+
const json = (status, body) => {
|
|
106
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
110
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
111
|
+
if (!workspaceId || !workspace) {
|
|
112
|
+
json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const root = workspace.path;
|
|
116
|
+
if (url.pathname === "/api/files") {
|
|
117
|
+
if (request.method !== "GET") {
|
|
118
|
+
json(405, { error: "Directory listing requires GET", code: "method" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const query = url.searchParams.get("q");
|
|
122
|
+
if (query !== null && query.trim() !== "") {
|
|
123
|
+
const result = await searchByName(root, query);
|
|
124
|
+
json(200, { mode: "search", query: query.trim(), ...result, maxResults: 500, maxDepth: 12 });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const listing = await listDirectory(root, url.searchParams.get("path"));
|
|
128
|
+
json(200, { mode: "list", ...listing, maxEntries: 2000 });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (request.method === "GET") {
|
|
132
|
+
const rel = url.searchParams.get("path") ?? "";
|
|
133
|
+
if (url.searchParams.get("meta") === "1") {
|
|
134
|
+
const resolved = await resolveInsideRoot(root, rel);
|
|
135
|
+
json(200, { path: toRelative(root, resolved), ...(await fileMeta(resolved)) });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const file = await readWorkspaceFile(root, rel);
|
|
139
|
+
json(200, { path: file.path, content: file.content, eol: file.eol, meta: file.meta, maxBytes: MAX_FILE_BYTES });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (request.method === "PUT") {
|
|
143
|
+
const remoteAddr = request.socket.remoteAddress ?? "";
|
|
144
|
+
if (!isLoopbackAddress(remoteAddr)) {
|
|
145
|
+
json(403, { error: "File writes require a loopback connection", code: "not_loopback" });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let raw = "";
|
|
149
|
+
for await (const chunk of request)
|
|
150
|
+
raw += chunk;
|
|
151
|
+
const declared = Number(request.headers["content-length"] ?? "0");
|
|
152
|
+
if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
|
|
153
|
+
json(413, { error: "请求体过大", code: "too_large" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const parsed = JSON.parse(raw);
|
|
157
|
+
if (typeof parsed.path !== "string" || typeof parsed.content !== "string") {
|
|
158
|
+
json(400, { error: "写入需要 path 与 content 两个字符串字段", code: "bad_request" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
|
|
162
|
+
json(200, outcome);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
json(405, { error: `${request.method} is not supported on /api/file`, code: "method" });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const described = describeFilesError(error);
|
|
169
|
+
json(described.status, described.body);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (url.pathname === "/api/fs/dirs") {
|
|
174
|
+
const fail = (status, message) => {
|
|
175
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: message }));
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
const target = resolve(url.searchParams.get("path")?.trim() || rootDir);
|
|
179
|
+
const info = await stat(target).catch((error) => {
|
|
180
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR")
|
|
181
|
+
return undefined;
|
|
182
|
+
throw error;
|
|
183
|
+
});
|
|
184
|
+
if (!info) {
|
|
185
|
+
fail(404, `Directory does not exist: ${target}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!info.isDirectory()) {
|
|
189
|
+
fail(400, `Not a directory: ${target}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const dirents = await readdir(target, { withFileTypes: true });
|
|
193
|
+
const entries = dirents
|
|
194
|
+
.filter((item) => item.isDirectory())
|
|
195
|
+
.map((item) => ({ name: item.name, path: join(target, item.name) }))
|
|
196
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
197
|
+
const truncated = entries.length > 2000;
|
|
198
|
+
const parent = dirname(target);
|
|
199
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
|
|
200
|
+
path: target,
|
|
201
|
+
parent: parent === target ? null : parent,
|
|
202
|
+
suggestedName: basename(target) || target,
|
|
203
|
+
entries: truncated ? entries.slice(0, 2000) : entries,
|
|
204
|
+
truncated,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const code = error.code;
|
|
209
|
+
if (code === "EACCES" || code === "EPERM")
|
|
210
|
+
fail(403, `Permission denied: ${url.searchParams.get("path") ?? rootDir}`);
|
|
211
|
+
else
|
|
212
|
+
fail(500, `Failed to list directories: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (url.pathname === "/api/upload") {
|
|
217
|
+
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
218
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload requires a loopback POST" }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const json = (status, body) => {
|
|
222
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
223
|
+
};
|
|
224
|
+
try {
|
|
225
|
+
let totalBytes = 0;
|
|
226
|
+
const chunks = [];
|
|
227
|
+
for await (const chunk of request) {
|
|
228
|
+
totalBytes += chunk.length;
|
|
229
|
+
if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
|
|
230
|
+
json(413, { error: "Upload payload exceeds size limit" });
|
|
231
|
+
request.destroy();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
}
|
|
236
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
237
|
+
const body = JSON.parse(raw);
|
|
238
|
+
const conversationId = body.conversationId?.trim();
|
|
239
|
+
if (!conversationId) {
|
|
240
|
+
json(400, { error: "conversationId is required" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const conversation = store.snapshot().conversations.find((c) => c.id === conversationId);
|
|
244
|
+
if (!conversation) {
|
|
245
|
+
json(404, { error: `Conversation not found: ${conversationId}` });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!body.mimeType || typeof body.mimeType !== "string") {
|
|
249
|
+
json(400, { error: "mimeType is required" });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!body.data || typeof body.data !== "string") {
|
|
253
|
+
json(400, { error: "data (base64) is required" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const catalog = await catalogService.getCatalog(conversation.model);
|
|
257
|
+
const currentModel = catalog.models.find((m) => m.provider === conversation.model?.provider && m.modelId === conversation.model?.modelId)
|
|
258
|
+
?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
|
|
259
|
+
if (currentModel && !currentModel.imageInput) {
|
|
260
|
+
json(400, { error: `当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入` });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const record = await saveUpload(uploadsDir, conversationId, body.name, body.mimeType, body.data);
|
|
264
|
+
json(200, record);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
json(400, { error: error instanceof Error ? error.message : "Upload failed" });
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (url.pathname.startsWith("/api/upload/")) {
|
|
272
|
+
if (request.method !== "GET" || !isLoopbackAddress(remote)) {
|
|
273
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload access requires a loopback GET" }));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
|
|
277
|
+
if (!pathParts.length || pathParts.length > 2) {
|
|
278
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Invalid upload path" }));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const convId = pathParts.length === 2 ? pathParts[0] : undefined;
|
|
282
|
+
const fileOrId = pathParts.length === 2 ? pathParts[1] : pathParts[0];
|
|
283
|
+
const found = await findUploadFile(uploadsDir, convId, fileOrId);
|
|
284
|
+
if (!found) {
|
|
285
|
+
response.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Image not found" }));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
response.writeHead(200, {
|
|
289
|
+
"content-type": found.mimeType,
|
|
290
|
+
"x-content-type-options": "nosniff",
|
|
291
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
292
|
+
"content-length": found.size,
|
|
293
|
+
});
|
|
294
|
+
createReadStream(found.filePath).pipe(response);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Static client asset handling
|
|
298
|
+
const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
299
|
+
const requestedPath = resolve(clientDir, requested);
|
|
300
|
+
const safe = !relative(clientDir, requestedPath).startsWith("..") && !relative(clientDir, requestedPath).includes(":");
|
|
301
|
+
if (!safe) {
|
|
302
|
+
response.writeHead(403).end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const candidate = existsSync(requestedPath) ? requestedPath : join(clientDir, "index.html");
|
|
306
|
+
if (!existsSync(candidate)) {
|
|
307
|
+
response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
response.writeHead(200, { "content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream" });
|
|
311
|
+
createReadStream(candidate).pipe(response);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { defaultStorePath, Store } from "./store.js";
|
|
6
|
+
import { PiRpcRuntime, WelcomeRuntime } from "./runtime.js";
|
|
7
|
+
import { SubagentSupervisor } from "./subagents.js";
|
|
8
|
+
import { SubagentRunRecorder } from "./subagent-runs.js";
|
|
9
|
+
import { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
10
|
+
import { CatalogService } from "./application/catalog-service.js";
|
|
11
|
+
import { RunService } from "./application/run-service.js";
|
|
12
|
+
import { ConversationService } from "./application/conversation-service.js";
|
|
13
|
+
import { WorkspaceService } from "./application/workspace-service.js";
|
|
14
|
+
import { CommandHandler } from "./command-handler.js";
|
|
15
|
+
import { createHttpServer } from "./http-server.js";
|
|
16
|
+
import { WsBroadcaster, WsServer } from "./ws-server.js";
|
|
17
|
+
// ------------------------------------------------------------- Environment & paths
|
|
18
|
+
const rootDir = resolve(import.meta.dirname, "../..");
|
|
19
|
+
const port = Number.parseInt(process.env.SUPER_PI_PORT ?? "3080", 10);
|
|
20
|
+
const host = process.env.SUPER_PI_HOST ?? "127.0.0.1";
|
|
21
|
+
const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(rootDir, ".super-pi"))
|
|
22
|
+
? join(rootDir, ".super-pi")
|
|
23
|
+
: join(homedir(), ".super-pi");
|
|
24
|
+
const dataDir = resolve(process.env.SUPER_PI_DATA_DIR ?? defaultDataDir);
|
|
25
|
+
const agentsDir = resolve(process.env.SUPER_PI_AGENTS_DIR ?? join(rootDir, "agents"));
|
|
26
|
+
const agentPromptDir = join(dataDir, "agent-prompts");
|
|
27
|
+
const clientDir = join(rootDir, "dist", "client");
|
|
28
|
+
const accessToken = process.env.SUPER_PI_ACCESS_TOKEN;
|
|
29
|
+
const uploadsDir = join(dataDir, "uploads");
|
|
30
|
+
await mkdir(uploadsDir, { recursive: true }).catch(() => { });
|
|
31
|
+
const taskEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/subagent/dispatch`;
|
|
32
|
+
const todoEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/todos`;
|
|
33
|
+
const paths = {
|
|
34
|
+
dataDir,
|
|
35
|
+
rootDir,
|
|
36
|
+
agentsDir,
|
|
37
|
+
agentPromptDir,
|
|
38
|
+
uploadsDir,
|
|
39
|
+
clientDir,
|
|
40
|
+
};
|
|
41
|
+
// ------------------------------------------------------------- Core singletons
|
|
42
|
+
const store = await Store.open(defaultStorePath(dataDir));
|
|
43
|
+
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
44
|
+
? new WelcomeRuntime()
|
|
45
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: agentsDir, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, uploadsDir });
|
|
46
|
+
const broadcaster = new WsBroadcaster(() => store.snapshot());
|
|
47
|
+
const extensionUiService = new ExtensionUiService(broadcaster);
|
|
48
|
+
const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
|
|
49
|
+
dir: agentsDir,
|
|
50
|
+
promptDir: agentPromptDir,
|
|
51
|
+
});
|
|
52
|
+
const subagents = new SubagentRunRecorder(supervisor, {
|
|
53
|
+
store,
|
|
54
|
+
broadcast: (event) => {
|
|
55
|
+
broadcaster.broadcast(event);
|
|
56
|
+
if (event.type === "subagent.event")
|
|
57
|
+
broadcaster.broadcastState();
|
|
58
|
+
},
|
|
59
|
+
host: (conversationId, runId) => extensionUiService.createHost(conversationId, runId),
|
|
60
|
+
});
|
|
61
|
+
// ------------------------------------------------------------- Application services
|
|
62
|
+
const catalogService = new CatalogService(runtime, broadcaster, agentsDir);
|
|
63
|
+
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService);
|
|
64
|
+
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, agentsDir);
|
|
65
|
+
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, agentsDir);
|
|
66
|
+
const serverContext = {
|
|
67
|
+
paths,
|
|
68
|
+
store,
|
|
69
|
+
runtime,
|
|
70
|
+
subagents,
|
|
71
|
+
broadcaster,
|
|
72
|
+
accessToken,
|
|
73
|
+
workspaceService,
|
|
74
|
+
conversationService,
|
|
75
|
+
runService,
|
|
76
|
+
extensionUiService,
|
|
77
|
+
catalogService,
|
|
78
|
+
};
|
|
79
|
+
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService);
|
|
80
|
+
// ------------------------------------------------------------- Lifecycle & shutdown
|
|
81
|
+
let shuttingDown = false;
|
|
82
|
+
function shutdown(reason) {
|
|
83
|
+
if (shuttingDown)
|
|
84
|
+
return;
|
|
85
|
+
shuttingDown = true;
|
|
86
|
+
console.log(`Super Pi is shutting down (${reason}).`);
|
|
87
|
+
runService.abortAll();
|
|
88
|
+
for (const c of store.snapshot().conversations) {
|
|
89
|
+
extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
90
|
+
}
|
|
91
|
+
wsServer.close();
|
|
92
|
+
httpServer.close(() => process.exit(0));
|
|
93
|
+
httpServer.closeAllConnections();
|
|
94
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
95
|
+
}
|
|
96
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
97
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
98
|
+
// ------------------------------------------------------------- Transports & startup
|
|
99
|
+
const httpServer = createHttpServer({ context: serverContext, onShutdown: shutdown });
|
|
100
|
+
const wsServer = new WsServer({ httpServer, broadcaster, commandHandler, catalogService, accessToken });
|
|
101
|
+
httpServer.listen(port, host, () => {
|
|
102
|
+
console.log(`Super Pi is running at http://${host}:${port}`);
|
|
103
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Pi is, or a plain statement that it is not here.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT `process.env.SUPER_PI_PI_COMMAND ?? "pi.cmd"`. npm installs global CLIs into a
|
|
5
|
+
* directory that is NOT on many shells' PATH - on Windows `%APPDATA%\npm` is missing from a cron or
|
|
6
|
+
* service shell by default - so a bare `pi.cmd` spawn fails there while succeeding in an interactive
|
|
7
|
+
* shell. Same commit, same machine, opposite `npm run check` verdicts, and the failing line was
|
|
8
|
+
* cmd.exe's own message in the console's code page (`'pi.cmd' 不是内部或外部命令`), which reads as an
|
|
9
|
+
* encoding bug instead of a PATH one. Both the production spawn and the real-Pi probe resolve through
|
|
10
|
+
* this one function so the test cannot verify a resolution production does not use.
|
|
11
|
+
*
|
|
12
|
+
* "Not installed" and "installed but disagreeing" are DIFFERENT facts with different verdicts: the
|
|
13
|
+
* first is the environment, the second is the product. `found: false` is the first, and callers report
|
|
14
|
+
* it as a skip with a reason rather than as a product failure.
|
|
15
|
+
*/
|
|
16
|
+
export type PiCommandResolution = {
|
|
17
|
+
found: true;
|
|
18
|
+
command: string;
|
|
19
|
+
via: string;
|
|
20
|
+
} | {
|
|
21
|
+
found: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
searched: string[];
|
|
24
|
+
};
|
|
25
|
+
/** Overridable so the resolution can be exercised for both platforms and both outcomes without a Pi install. */
|
|
26
|
+
export interface PiCommandOptions {
|
|
27
|
+
/** An explicit command (the runtime constructor's second argument). Wins over the environment. */
|
|
28
|
+
command?: string | undefined;
|
|
29
|
+
env?: Record<string, string | undefined>;
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
exists?: (path: string) => boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the command that starts Pi: the explicit override, then PATH, then npm's global bin
|
|
35
|
+
* directories, and only then a report of every place it looked.
|
|
36
|
+
*
|
|
37
|
+
* An explicit override that does not exist is REPORTED rather than handed to the spawn, because a
|
|
38
|
+
* silent fallback would make a typo in `SUPER_PI_PI_COMMAND` look like a working Pi somewhere else.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolvePiCommand(options?: PiCommandOptions): PiCommandResolution;
|
|
41
|
+
/**
|
|
42
|
+
* Decodes a child process's output, falling back to the console code page when it is not UTF-8.
|
|
43
|
+
*
|
|
44
|
+
* A Windows `.cmd` shim runs under cmd.exe, which writes its own diagnostics in the console's code
|
|
45
|
+
* page - GBK on a Chinese Windows - so `chunk.toString()` renders `'pi.cmd' 不是内部或外部命令` as
|
|
46
|
+
* `'pi.cmd' ??????`. Reading those bytes as UTF-8 fatal first and retrying as GBK is what keeps the
|
|
47
|
+
* message readable, and therefore what keeps the next reader from mistaking a PATH problem for an
|
|
48
|
+
* encoding one.
|
|
49
|
+
*/
|
|
50
|
+
export declare function decodeProcessOutput(chunks: Iterable<Buffer>, fallbackCodePage?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* True when the text says the command itself was not found, which is a PATH or install problem rather
|
|
53
|
+
* than a rejection by Pi - the two are indistinguishable once the output is garbled, so the diagnosis
|
|
54
|
+
* is made on the decoded text rather than guessed from an exit code.
|
|
55
|
+
*/
|
|
56
|
+
export declare function looksLikeCommandNotFound(text: string): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Quotes a resolved command for `shell: true` spawns on Windows.
|
|
59
|
+
*
|
|
60
|
+
* npm's global bin directory can sit under a path with spaces (`C:\Program Files\nodejs`), and node
|
|
61
|
+
* joins a shell command out of argv WITHOUT quoting it, so an unquoted resolved path fails to launch
|
|
62
|
+
* on exactly the machine where PATH lookup did not already find Pi. A command with no spaces - the
|
|
63
|
+
* common case, and every fake launcher the tests use - is returned untouched.
|
|
64
|
+
*/
|
|
65
|
+
export declare function shellQuote(command: string, platform?: NodeJS.Platform): string;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
/** npm's launcher names, most specific first: `.cmd` is what `shell: true` can actually start on Windows. */
|
|
4
|
+
const launcherNames = (platform) => platform === "win32" ? ["pi.cmd", "pi.exe", "pi.bat", "pi"] : ["pi", "pi.exe"];
|
|
5
|
+
/**
|
|
6
|
+
* Turns one PATH entry into a path this platform can stat.
|
|
7
|
+
*
|
|
8
|
+
* Git Bash and MSYS hand a native program a POSIX-looking PATH (`/c/Users/...`), and Windows cannot
|
|
9
|
+
* stat that: `existsSync("/c/Users/...")` resolves against the current drive root and says no. So the
|
|
10
|
+
* drive-letter form is rewritten, which is what makes PATH scanning work in exactly the shells this
|
|
11
|
+
* bug appeared in.
|
|
12
|
+
*/
|
|
13
|
+
function nativeEntry(entry, platform) {
|
|
14
|
+
const trimmed = entry.trim().replace(/^"(.*)"$/, "$1");
|
|
15
|
+
if (!trimmed)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (platform !== "win32")
|
|
18
|
+
return trimmed;
|
|
19
|
+
const drive = /^\/([a-zA-Z])(\/.*)?$/.exec(trimmed);
|
|
20
|
+
return drive ? `${drive[1].toUpperCase()}:${(drive[2] ?? "/").replace(/\//g, "\\")}` : trimmed;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Splits PATH the way the platform that handed it to us actually wrote it.
|
|
24
|
+
*
|
|
25
|
+
* Windows uses `;`, but a Git Bash / MSYS PATH arrives colon-separated with drive-letter prefixes, and a
|
|
26
|
+
* `C:` in an ordinary Windows entry means the drive, never a separator. So `;` always wins when present;
|
|
27
|
+
* otherwise `:` is treated as a separator only if the string has no drive-letter prefix at all.
|
|
28
|
+
*/
|
|
29
|
+
const splitPath = (value, platform) => {
|
|
30
|
+
if (platform !== "win32")
|
|
31
|
+
return value.split(":");
|
|
32
|
+
if (value.includes(";"))
|
|
33
|
+
return value.split(";");
|
|
34
|
+
return /(^|[;,])[a-zA-Z]:[\\/]/.test(value) ? [value] : value.split(":");
|
|
35
|
+
};
|
|
36
|
+
const pathDirectories = (env, platform) => splitPath(env.PATH ?? env.Path ?? "", platform).map((entry) => nativeEntry(entry, platform)).filter((entry) => Boolean(entry));
|
|
37
|
+
/** Joins with the target platform's separator, so a POSIX expectation stays comparable on Windows. */
|
|
38
|
+
const joinFor = (platform) => (platform === "win32" ? win32.join : posix.join);
|
|
39
|
+
/**
|
|
40
|
+
* The directories npm installs global CLIs into, beyond PATH.
|
|
41
|
+
*
|
|
42
|
+
* `npm_config_prefix` is npm's own answer when it is set; the rest are the defaults per platform. The
|
|
43
|
+
* home directory is read from the ENVIRONMENT only - `os.homedir()` is never consulted - so a
|
|
44
|
+
* deliberately isolated environment (empty PATH, no HOME) really is isolated instead of quietly
|
|
45
|
+
* finding the developer's own install through the OS.
|
|
46
|
+
*/
|
|
47
|
+
function globalBinDirectories(env, platform) {
|
|
48
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
|
49
|
+
const at = joinFor(platform);
|
|
50
|
+
const directories = [];
|
|
51
|
+
const prefix = env.npm_config_prefix?.trim();
|
|
52
|
+
if (prefix)
|
|
53
|
+
directories.push(platform === "win32" ? nativeEntry(prefix, platform) ?? prefix : at(prefix, "bin"));
|
|
54
|
+
if (platform === "win32") {
|
|
55
|
+
if (env.APPDATA?.trim())
|
|
56
|
+
directories.push(at(env.APPDATA.trim(), "npm"));
|
|
57
|
+
if (env.LOCALAPPDATA?.trim())
|
|
58
|
+
directories.push(at(env.LOCALAPPDATA.trim(), "npm"));
|
|
59
|
+
if (env.ProgramFiles?.trim())
|
|
60
|
+
directories.push(at(env.ProgramFiles.trim(), "npm"));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (home)
|
|
64
|
+
directories.push(at(home, ".local", "bin"), at(home, ".npm-global", "bin"), at(home, "bin"));
|
|
65
|
+
directories.push("/usr/local/bin", "/opt/homebrew/bin");
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(directories.filter(Boolean))];
|
|
68
|
+
}
|
|
69
|
+
/** On POSIX a file that is not executable is not runnable; on Windows that bit does not exist. */
|
|
70
|
+
const defaultExists = (platform) => (path) => {
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
return false;
|
|
73
|
+
if (platform === "win32")
|
|
74
|
+
return true;
|
|
75
|
+
try {
|
|
76
|
+
accessSync(path, constants.X_OK);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const firstHit = (names, directories, exists, platform) => {
|
|
84
|
+
const at = joinFor(platform);
|
|
85
|
+
for (const dir of directories)
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const candidate = at(dir, name);
|
|
88
|
+
if (exists(candidate))
|
|
89
|
+
return { command: candidate, dir };
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the command that starts Pi: the explicit override, then PATH, then npm's global bin
|
|
95
|
+
* directories, and only then a report of every place it looked.
|
|
96
|
+
*
|
|
97
|
+
* An explicit override that does not exist is REPORTED rather than handed to the spawn, because a
|
|
98
|
+
* silent fallback would make a typo in `SUPER_PI_PI_COMMAND` look like a working Pi somewhere else.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePiCommand(options = {}) {
|
|
101
|
+
const { command, env = process.env, platform = process.platform } = options;
|
|
102
|
+
const exists = options.exists ?? defaultExists(platform);
|
|
103
|
+
const names = launcherNames(platform);
|
|
104
|
+
const explicit = (command ?? env.SUPER_PI_PI_COMMAND ?? "").trim();
|
|
105
|
+
const onPath = pathDirectories(env, platform);
|
|
106
|
+
const globalDirs = globalBinDirectories(env, platform);
|
|
107
|
+
if (explicit) {
|
|
108
|
+
const label = command ? "the command given to the runtime" : "SUPER_PI_PI_COMMAND";
|
|
109
|
+
if (/[\\/]/.test(explicit)) {
|
|
110
|
+
const full = nativeEntry(explicit, platform) ?? explicit;
|
|
111
|
+
if (exists(full))
|
|
112
|
+
return { found: true, command: full, via: label };
|
|
113
|
+
return {
|
|
114
|
+
found: false,
|
|
115
|
+
reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect in the agent mechanism.`,
|
|
116
|
+
searched: [full],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const hit = firstHit([explicit], [...onPath, ...globalDirs], exists, platform);
|
|
120
|
+
if (hit)
|
|
121
|
+
return { found: true, command: hit.command, via: `${label} (${hit.dir})` };
|
|
122
|
+
return {
|
|
123
|
+
found: false,
|
|
124
|
+
reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect in the agent mechanism.`,
|
|
125
|
+
searched: [...onPath, ...globalDirs],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const namesFound = firstHit(names, onPath, exists, platform);
|
|
129
|
+
if (namesFound)
|
|
130
|
+
return { found: true, command: namesFound.command, via: `PATH (${namesFound.dir})` };
|
|
131
|
+
const globalFound = firstHit(names, globalDirs, exists, platform);
|
|
132
|
+
if (globalFound)
|
|
133
|
+
return { found: true, command: globalFound.command, via: `npm's global bin directory (${globalFound.dir})` };
|
|
134
|
+
const searched = [...new Set([...onPath, ...globalDirs])];
|
|
135
|
+
return {
|
|
136
|
+
found: false,
|
|
137
|
+
reason: `Pi was not found: no ${names.map((name) => `"${name}"`).join(" or ")} in ${searched.length} directory(ies) on PATH or in npm's global bin location (${searched.join(", ") || "none known"}). Install it with "npm i -g pi" or set SUPER_PI_PI_COMMAND to its full path. This is an environment fact, NOT evidence that the agent mechanism is broken - only a Pi that IS present and disagrees can say that.`,
|
|
138
|
+
searched,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Decodes a child process's output, falling back to the console code page when it is not UTF-8.
|
|
143
|
+
*
|
|
144
|
+
* A Windows `.cmd` shim runs under cmd.exe, which writes its own diagnostics in the console's code
|
|
145
|
+
* page - GBK on a Chinese Windows - so `chunk.toString()` renders `'pi.cmd' 不是内部或外部命令` as
|
|
146
|
+
* `'pi.cmd' ??????`. Reading those bytes as UTF-8 fatal first and retrying as GBK is what keeps the
|
|
147
|
+
* message readable, and therefore what keeps the next reader from mistaking a PATH problem for an
|
|
148
|
+
* encoding one.
|
|
149
|
+
*/
|
|
150
|
+
export function decodeProcessOutput(chunks, fallbackCodePage = "gbk") {
|
|
151
|
+
const buffer = Buffer.concat([...chunks]);
|
|
152
|
+
try {
|
|
153
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
154
|
+
}
|
|
155
|
+
catch { /* not UTF-8 - fall through to the code page below */ }
|
|
156
|
+
try {
|
|
157
|
+
return new TextDecoder(fallbackCodePage).decode(buffer);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return buffer.toString("latin1");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* True when the text says the command itself was not found, which is a PATH or install problem rather
|
|
165
|
+
* than a rejection by Pi - the two are indistinguishable once the output is garbled, so the diagnosis
|
|
166
|
+
* is made on the decoded text rather than guessed from an exit code.
|
|
167
|
+
*/
|
|
168
|
+
export function looksLikeCommandNotFound(text) {
|
|
169
|
+
return /不是内部或外部命令|不是可识别的内部或外部命令|not recognized as an internal or external command|command not found|No such file or directory/i.test(text);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Quotes a resolved command for `shell: true` spawns on Windows.
|
|
173
|
+
*
|
|
174
|
+
* npm's global bin directory can sit under a path with spaces (`C:\Program Files\nodejs`), and node
|
|
175
|
+
* joins a shell command out of argv WITHOUT quoting it, so an unquoted resolved path fails to launch
|
|
176
|
+
* on exactly the machine where PATH lookup did not already find Pi. A command with no spaces - the
|
|
177
|
+
* common case, and every fake launcher the tests use - is returned untouched.
|
|
178
|
+
*/
|
|
179
|
+
export function shellQuote(command, platform = process.platform) {
|
|
180
|
+
if (platform !== "win32" || !/\s/.test(command) || command.includes("\""))
|
|
181
|
+
return command;
|
|
182
|
+
return `"${command}"`;
|
|
183
|
+
}
|