@evandu/super-pi 0.1.32 → 0.1.34

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 (34) hide show
  1. package/README.md +4 -0
  2. package/agents/super-pi-task/index.ts +4 -0
  3. package/dist/client/assets/index-BhsIL1Qa.js +50 -0
  4. package/dist/client/assets/index-Da9PkxxS.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/extension.js +7 -2
  7. package/dist/server/active-memory.d.ts +15 -0
  8. package/dist/server/active-memory.js +36 -1
  9. package/dist/server/application/conversation-service.d.ts +39 -0
  10. package/dist/server/application/conversation-service.js +65 -4
  11. package/dist/server/application/extension-ui-service.js +16 -0
  12. package/dist/server/application/run-service.d.ts +21 -0
  13. package/dist/server/application/run-service.js +130 -19
  14. package/dist/server/command-handler.d.ts +14 -0
  15. package/dist/server/command-handler.js +22 -0
  16. package/dist/server/git.d.ts +37 -0
  17. package/dist/server/git.js +30 -0
  18. package/dist/server/http-server.js +126 -8
  19. package/dist/server/index.js +7 -2
  20. package/dist/server/memory.d.ts +27 -0
  21. package/dist/server/memory.js +56 -0
  22. package/dist/server/persistence/json-state-repository.d.ts +2 -0
  23. package/dist/server/persistence/json-state-repository.js +24 -5
  24. package/dist/server/runtime.d.ts +4 -1
  25. package/dist/server/runtime.js +51 -14
  26. package/dist/server/store.d.ts +6 -0
  27. package/dist/server/store.js +46 -3
  28. package/dist/server/terminal.js +20 -7
  29. package/dist/server/types.d.ts +36 -2
  30. package/dist/server/upload.js +8 -19
  31. package/dist/server/ws-server.js +89 -28
  32. package/package.json +5 -3
  33. package/dist/client/assets/index-BBfRhf7h.js +0 -50
  34. package/dist/client/assets/index-sZQBUBuS.css +0 -1
@@ -109,8 +109,8 @@ export async function findUploadFile(uploadsDir, conversationId, filenameOrId) {
109
109
  return null;
110
110
  const directPath = join(uploadsDir, conversationId, filenameOrId);
111
111
  if (existsSync(directPath)) {
112
- const s = await stat(directPath);
113
- if (s.isFile()) {
112
+ const s = await stat(directPath).catch(() => null);
113
+ if (s?.isFile()) {
114
114
  const mime = mimeForExt(extname(directPath));
115
115
  return { filePath: directPath, mimeType: mime, size: s.size };
116
116
  }
@@ -121,29 +121,18 @@ export async function findUploadFile(uploadsDir, conversationId, filenameOrId) {
121
121
  for (const entry of entries) {
122
122
  if (!entry.endsWith(".meta.json") && (entry === filenameOrId || entry.startsWith(`${filenameOrId}.`))) {
123
123
  const fp = join(dir, entry);
124
- const s = await stat(fp);
125
- if (s.isFile()) {
124
+ // A file listed by readdir can be gone by the time stat runs; that is a miss, not a failure.
125
+ const s = await stat(fp).catch(() => null);
126
+ if (s?.isFile()) {
126
127
  return { filePath: fp, mimeType: mimeForExt(extname(fp)), size: s.size };
127
128
  }
128
129
  }
129
130
  }
130
131
  }
131
132
  }
132
- // Fallback: search across all conversation upload directories
133
- const convDirs = await readdir(uploadsDir).catch(() => []);
134
- for (const conv of convDirs) {
135
- const dir = join(uploadsDir, conv);
136
- const entries = await readdir(dir).catch(() => []);
137
- for (const entry of entries) {
138
- if (!entry.endsWith(".meta.json") && (entry === filenameOrId || entry.startsWith(`${filenameOrId}.`))) {
139
- const fp = join(dir, entry);
140
- const s = await stat(fp);
141
- if (s.isFile()) {
142
- return { filePath: fp, mimeType: mimeForExt(extname(fp)), size: s.size };
143
- }
144
- }
145
- }
146
- }
133
+ // Deliberately NO cross-conversation fallback here: an upload URL carries its own conversation id, and
134
+ // serving one conversation's request from another conversation's directory would leak images across
135
+ // conversations. A miss is the caller's 404, not a wider search.
147
136
  return null;
148
137
  }
149
138
  export async function deleteConversationUploads(uploadsDir, conversationId) {
@@ -116,14 +116,28 @@ export class WsServer {
116
116
  socket.send(JSON.stringify({ type: "subagent.catalog", catalog }));
117
117
  }
118
118
  }).catch((error) => console.warn(`Super Pi: sub-agent catalog for a new connection failed: ${error instanceof Error ? error.message : "unknown error"}`));
119
+ /**
120
+ * Liveness follows the standard ws pattern: every pong marks the peer alive, each heartbeat
121
+ * marks it unanswered and pings, and a peer that is STILL unanswered on the next heartbeat is
122
+ * terminated. Without this, a half-open connection (frozen tab, gone NAT entry, pulled cable)
123
+ * stays OPEN forever and every broadcast serializes full state to it.
124
+ */
125
+ let isAlive = true;
126
+ socket.on("pong", () => { isAlive = true; });
119
127
  const heartbeat = setInterval(() => {
120
- if (socket.readyState === WebSocket.OPEN) {
121
- socket.ping();
122
- if (this.rootDir) {
123
- const v = checkDiskVersion(this.rootDir);
124
- if (v.needsRestart) {
125
- socket.send(JSON.stringify({ type: "server.version", version: v }));
126
- }
128
+ if (socket.readyState !== WebSocket.OPEN)
129
+ return;
130
+ if (!isAlive) {
131
+ // Fires 'close', which runs the per-socket cleanup below.
132
+ socket.terminate();
133
+ return;
134
+ }
135
+ isAlive = false;
136
+ socket.ping();
137
+ if (this.rootDir) {
138
+ const v = checkDiskVersion(this.rootDir);
139
+ if (v.needsRestart) {
140
+ socket.send(JSON.stringify({ type: "server.version", version: v }));
127
141
  }
128
142
  }
129
143
  }, 30_000);
@@ -136,30 +150,77 @@ export class WsServer {
136
150
  this.onSocketClose?.([...terminalSessionIds]);
137
151
  terminalSessionIds.clear();
138
152
  });
139
- socket.on("message", async (raw) => {
140
- let command;
153
+ /**
154
+ * Commands from ONE socket are SERIALIZED through a promise chain. `handleCommand` is async, so
155
+ * a bare `async` listener lets command N+1 start while command N is still awaiting inside the
156
+ * services, interleaving that defeats their check-then-act guards. Chaining each command off the
157
+ * previous one makes same-socket execution strictly in-order while different sockets keep their
158
+ * own independent chains. Error handling is unchanged: each command catches its own errors and
159
+ * reports them on the socket, and the chain-level catch only stops an unexpected rejection from
160
+ * poisoning every later command on the same socket.
161
+ */
162
+ let commandQueue = Promise.resolve();
163
+ socket.on("message", (raw) => {
164
+ let parsed;
141
165
  try {
142
- command = JSON.parse(raw.toString());
143
- if (command.type === "terminal.open")
144
- terminalSessionIds.add(command.sessionId);
145
- const entityId = await this.commandHandler.handleCommand(command, (event) => {
146
- if (socket.readyState === WebSocket.OPEN) {
147
- socket.send(JSON.stringify(event));
148
- }
149
- }, remoteAddress);
150
- if (command.operationRequestId)
151
- socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: true, entityId }));
166
+ parsed = JSON.parse(raw.toString());
152
167
  }
153
- catch (error) {
154
- if (command?.operationRequestId) {
155
- socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: error instanceof Error ? error.message : String(error) }));
156
- return;
157
- }
158
- socket.send(JSON.stringify({
159
- type: "error",
160
- message: error instanceof Error ? error.message : "command failed",
161
- }));
168
+ catch {
169
+ socket.send(JSON.stringify({ type: "error", message: "command failed" }));
170
+ return;
162
171
  }
172
+ // A send represents a streaming operation. It must be admitted immediately so a second send
173
+ // can observe the active run and enter the durable follow-up queue; waiting for the first model
174
+ // turn here makes every follow-up test time out and defeats the queue semantics.
175
+ const runIndependently = parsed.type === "message.send" || parsed.type === "run.stop";
176
+ // ...WHICH MEANS A SEND CAN OVERTAKE A REVERT SENT JUST BEFORE IT, because the revert below is
177
+ // queued behind whatever this socket was already doing. The service only learns it is cutting
178
+ // when the command EXECUTES, so a send handled in between would find the conversation open and
179
+ // be admitted onto a transcript the cut has not reached yet. Claiming here closes that gap: this
180
+ // is the arrival edge, and the only one early enough to be of use to a command that does not wait.
181
+ if (parsed.type === "message.revert")
182
+ this.commandHandler.claimRevert(parsed.conversationId);
183
+ const execute = async () => {
184
+ let command;
185
+ try {
186
+ command = parsed;
187
+ if (command.type === "terminal.open")
188
+ terminalSessionIds.add(command.sessionId);
189
+ const entityId = await this.commandHandler.handleCommand(command, (event) => {
190
+ if (socket.readyState === WebSocket.OPEN) {
191
+ socket.send(JSON.stringify(event));
192
+ }
193
+ }, remoteAddress);
194
+ if (command.operationRequestId)
195
+ socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: true, entityId }));
196
+ }
197
+ catch (error) {
198
+ if (command?.operationRequestId) {
199
+ socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: error instanceof Error ? error.message : String(error) }));
200
+ return;
201
+ }
202
+ socket.send(JSON.stringify({
203
+ type: "error",
204
+ message: error instanceof Error ? error.message : "command failed",
205
+ }));
206
+ }
207
+ finally {
208
+ // Hand the claim back whatever happened - including a revert that threw before it ever
209
+ // reached the service. Without this a refused or malformed revert would leave the
210
+ // conversation permanently unable to send, which is a worse failure than the one the claim
211
+ // was added to prevent.
212
+ if (parsed.type === "message.revert")
213
+ this.commandHandler.releaseRevert(parsed.conversationId);
214
+ }
215
+ };
216
+ if (runIndependently)
217
+ void execute().catch((error) => {
218
+ console.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
219
+ });
220
+ else
221
+ commandQueue = commandQueue.then(execute).catch((error) => {
222
+ console.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
223
+ });
163
224
  });
164
225
  });
165
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  "prepack": "npm run build",
26
26
  "dev": "concurrently --kill-others-on-fail \"npm:dev:server\" \"npm:dev:client\"",
27
27
  "dev:client": "vite --host 0.0.0.0",
28
- "dev:server": "tsx watch src/server/index.ts",
28
+ "dev:server": "cross-env SUPER_PI_PORT=3081 tsx watch src/server/index.ts",
29
+ "dev:installed": "npm run build && cross-env SUPER_PI_PORT=3080 node bin/super-pi.js",
29
30
  "build": "tsc -b && vite build",
30
31
  "start": "node dist/server/index.js",
31
32
  "restart:server": "node scripts/restart-server.mjs",
@@ -135,6 +136,7 @@
135
136
  "react-dom": "latest",
136
137
  "tsx": "latest",
137
138
  "typescript": "latest",
138
- "vite": "latest"
139
+ "vite": "latest",
140
+ "cross-env": "latest"
139
141
  }
140
142
  }