@evandu/super-pi 0.1.106 → 0.1.108
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/agents/super-pi-task/index.ts +21 -7
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -7
- package/dist/client/assets/index-Aa8KN3IB.css +1 -0
- package/dist/client/assets/index-CnIOGNIm.js +79 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +10 -0
- package/dist/server/application/run-service.js +31 -0
- package/dist/server/command-handler.js +4 -0
- package/dist/server/content-search.d.ts +18 -8
- package/dist/server/content-search.js +226 -129
- package/dist/server/index.js +2 -0
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +69 -1
- package/dist/server/types.d.ts +11 -0
- package/package.json +4 -1
- package/dist/client/assets/index-DBR_t9lF.js +0 -79
- package/dist/client/assets/index-mo5Dvh_v.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-CnIOGNIm.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Aa8KN3IB.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -133,6 +133,16 @@ export declare class RunService {
|
|
|
133
133
|
* transcript - so a `role: "user"` is never impersonated.
|
|
134
134
|
*/
|
|
135
135
|
enqueueExecResult(sessionId: string, exitCode: number, stdout: string, stderr: string): void;
|
|
136
|
+
/**
|
|
137
|
+
* Stops an active background task (spawned process or detached subagent) on user request.
|
|
138
|
+
*
|
|
139
|
+
* 1. Kills the underlying PTY child process if one exists in `execService`.
|
|
140
|
+
* 2. If the task originated from a tool call in a conversation transcript, marks the tool
|
|
141
|
+
* call as failed with a clear message and broadcasts `message.tools`.
|
|
142
|
+
* 3. Removes the task from `state.backgroundTasks` and broadcasts `state` so the sidebar
|
|
143
|
+
* updates immediately.
|
|
144
|
+
*/
|
|
145
|
+
stopBackgroundTask(taskId: string): Promise<void>;
|
|
136
146
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
137
147
|
* two browser tabs cannot both observe an idle conversation and start duplicate turns. */
|
|
138
148
|
private readonly conversationOps;
|
|
@@ -310,6 +310,37 @@ export class RunService {
|
|
|
310
310
|
}
|
|
311
311
|
})());
|
|
312
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Stops an active background task (spawned process or detached subagent) on user request.
|
|
315
|
+
*
|
|
316
|
+
* 1. Kills the underlying PTY child process if one exists in `execService`.
|
|
317
|
+
* 2. If the task originated from a tool call in a conversation transcript, marks the tool
|
|
318
|
+
* call as failed with a clear message and broadcasts `message.tools`.
|
|
319
|
+
* 3. Removes the task from `state.backgroundTasks` and broadcasts `state` so the sidebar
|
|
320
|
+
* updates immediately.
|
|
321
|
+
*/
|
|
322
|
+
async stopBackgroundTask(taskId) {
|
|
323
|
+
const tasks = this.store.snapshot().backgroundTasks ?? [];
|
|
324
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
325
|
+
if (this.execService) {
|
|
326
|
+
try {
|
|
327
|
+
this.execService.kill(taskId);
|
|
328
|
+
}
|
|
329
|
+
catch { /* ignore if already exited */ }
|
|
330
|
+
}
|
|
331
|
+
if (task && task.originMessageId !== undefined && task.originCallRow !== undefined) {
|
|
332
|
+
const conv = this.store.snapshot().conversations.find((c) => c.id === task.conversationId);
|
|
333
|
+
const msg = conv?.messages.find((m) => m.id === task.originMessageId);
|
|
334
|
+
if (msg?.toolCalls?.[task.originCallRow]?.backgroundTask) {
|
|
335
|
+
const toolCalls = await this.store.updateToolCall(task.conversationId, task.originMessageId, task.originCallRow, {
|
|
336
|
+
backgroundTask: { id: taskId, status: "failed", output: "已由用户手动终止" },
|
|
337
|
+
});
|
|
338
|
+
this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
await this.store.removeBackgroundTask(taskId);
|
|
342
|
+
this.broadcaster.broadcastState();
|
|
343
|
+
}
|
|
313
344
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
314
345
|
* two browser tabs cannot both observe an idle conversation and start duplicate turns. */
|
|
315
346
|
conversationOps = new Map();
|
|
@@ -108,6 +108,10 @@ export class CommandHandler {
|
|
|
108
108
|
sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
|
+
if (command.type === "backgroundTask.stop") {
|
|
112
|
+
await this.runService.stopBackgroundTask(command.taskId);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
111
115
|
if (command.type === "workspace.create") {
|
|
112
116
|
return await this.workspaceService.createWorkspace(command.name, command.path);
|
|
113
117
|
}
|
|
@@ -22,11 +22,21 @@ export type ContentSearchResult = {
|
|
|
22
22
|
complete: boolean;
|
|
23
23
|
reasons: string[];
|
|
24
24
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
type RipgrepMatch = {
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
text: string;
|
|
29
|
+
};
|
|
30
|
+
type RipgrepRun = (input: {
|
|
31
|
+
rgPath: string;
|
|
32
|
+
pattern: string;
|
|
33
|
+
paths: string[];
|
|
34
|
+
signal: AbortSignal;
|
|
35
|
+
onMatch: (match: RipgrepMatch) => Promise<boolean>;
|
|
36
|
+
}) => Promise<void>;
|
|
37
|
+
export type ContentSearchOptions = {
|
|
38
|
+
resolveRipgrep?: () => Promise<string | undefined>;
|
|
39
|
+
runRipgrep?: RipgrepRun;
|
|
40
|
+
};
|
|
41
|
+
export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">, options?: ContentSearchOptions): Promise<ContentSearchResult>;
|
|
42
|
+
export {};
|
|
@@ -1,152 +1,249 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { FilesError, createRootGuard, isTextName, looksBinary, toRelative } from "./files.js";
|
|
5
6
|
export const MAX_CONTENT_RESULTS = 500;
|
|
6
7
|
export const MAX_CONTENT_FILE_BYTES = 512 * 1024;
|
|
7
8
|
export const MAX_CONTENT_DEPTH = 12;
|
|
8
9
|
export const MAX_CONTENT_MS = 10000;
|
|
10
|
+
let sdkRipgrep;
|
|
11
|
+
/** Pi's public package export hides this helper; resolve the installed module used by createGrepTool. */
|
|
12
|
+
async function resolveSdkRipgrep() {
|
|
13
|
+
sdkRipgrep ??= (async () => {
|
|
14
|
+
const entry = fileURLToPath(await import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
15
|
+
const manager = await import(pathToFileURL(join(dirname(entry), "utils", "tools-manager.js")).href);
|
|
16
|
+
return manager.getToolPath("rg") ?? manager.ensureTool("rg");
|
|
17
|
+
})();
|
|
18
|
+
return sdkRipgrep;
|
|
19
|
+
}
|
|
20
|
+
function normalizeLine(text) { return text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") ? text.slice(0, -1) : text; }
|
|
9
21
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
22
|
+
* One rg process for all indexed files. createGrepTool is deliberately not used: its path accepts only
|
|
23
|
+
* one directory/file and glob cannot express this arbitrary index set, so it would walk a second tree or
|
|
24
|
+
* spawn once per file. Matching still uses the SDK's exact ensureTool/getToolPath-selected ripgrep.
|
|
25
|
+
* The installed rg has no --files-from flag, so verified absolute candidates are passed as paths.
|
|
15
26
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
const runRipgrep = ({ rgPath, pattern, paths, signal, onMatch }) => new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn(rgPath, ["--json", "--line-number", "--color=never", "--hidden", "--no-ignore", "--text", "--fixed-strings", "--", pattern, ...paths], { stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let chain = Promise.resolve(true);
|
|
32
|
+
let stopped = false;
|
|
33
|
+
const stop = () => { if (!stopped) {
|
|
34
|
+
stopped = true;
|
|
35
|
+
child.kill();
|
|
36
|
+
} };
|
|
37
|
+
const onAbort = () => stop();
|
|
38
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
39
|
+
child.stdout.setEncoding("utf8");
|
|
40
|
+
child.stdout.on("data", (chunk) => {
|
|
41
|
+
stdout += chunk;
|
|
42
|
+
let newline;
|
|
43
|
+
while ((newline = stdout.indexOf("\n")) >= 0) {
|
|
44
|
+
const row = stdout.slice(0, newline);
|
|
45
|
+
stdout = stdout.slice(newline + 1);
|
|
46
|
+
chain = chain.then(async (continueSearch) => {
|
|
47
|
+
if (!continueSearch)
|
|
48
|
+
return false;
|
|
49
|
+
let event;
|
|
50
|
+
try {
|
|
51
|
+
event = JSON.parse(row);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (event.type !== "match")
|
|
57
|
+
return true;
|
|
58
|
+
const path = event.data?.path?.text;
|
|
59
|
+
const line = event.data?.line_number;
|
|
60
|
+
const text = event.data?.lines?.text;
|
|
61
|
+
if (!path || !Number.isSafeInteger(line))
|
|
62
|
+
return true;
|
|
63
|
+
const keepGoing = await onMatch({ path, line: line, text: typeof text === "string" ? normalizeLine(text) : "" });
|
|
64
|
+
if (!keepGoing)
|
|
65
|
+
stop();
|
|
66
|
+
return keepGoing;
|
|
67
|
+
});
|
|
68
|
+
chain.catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
child.stderr.setEncoding("utf8");
|
|
72
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
73
|
+
child.on("error", (error) => { signal.removeEventListener("abort", onAbort); reject(new Error(`Failed to run ripgrep: ${error.message}`)); });
|
|
74
|
+
child.on("close", (code) => {
|
|
75
|
+
signal.removeEventListener("abort", onAbort);
|
|
76
|
+
void chain.then(() => {
|
|
77
|
+
if (signal.aborted || stopped || code === 0 || code === 1)
|
|
78
|
+
resolve();
|
|
79
|
+
else
|
|
80
|
+
reject(new Error(`ripgrep failed (${code}): ${stderr.trim() || "unknown error"}`));
|
|
81
|
+
}, reject);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
async function checkedTextFile(absolute) {
|
|
85
|
+
try {
|
|
86
|
+
const info = await stat(absolute);
|
|
87
|
+
if (!info.isFile())
|
|
88
|
+
return "skip";
|
|
89
|
+
if (info.size > MAX_CONTENT_FILE_BYTES)
|
|
90
|
+
return "size";
|
|
91
|
+
const handle = await open(absolute, "r");
|
|
92
|
+
try {
|
|
93
|
+
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
94
|
+
let length = 0;
|
|
95
|
+
while (length < buffer.length) {
|
|
96
|
+
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
97
|
+
if (!read.bytesRead)
|
|
98
|
+
break;
|
|
99
|
+
length += read.bytesRead;
|
|
100
|
+
}
|
|
101
|
+
if (length > MAX_CONTENT_FILE_BYTES)
|
|
102
|
+
return "size";
|
|
103
|
+
const bytes = buffer.subarray(0, length);
|
|
104
|
+
if (looksBinary(bytes) || bytes.includes(0))
|
|
105
|
+
return "skip";
|
|
106
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
107
|
+
return "text";
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "skip";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function checkedFileSize(absolute) {
|
|
118
|
+
try {
|
|
119
|
+
const info = await stat(absolute);
|
|
120
|
+
return !info.isFile() ? "skip" : info.size > MAX_CONTENT_FILE_BYTES ? "size" : "ok";
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return "skip";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function needsContainmentCheck(path) {
|
|
127
|
+
return /(^|[\\/])\.\.([\\/]|$)|^[\\/]|^[A-Za-z]:/.test(path);
|
|
128
|
+
}
|
|
129
|
+
async function mapConcurrently(items, limit, fn) {
|
|
130
|
+
const output = new Array(items.length);
|
|
131
|
+
let next = 0;
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
133
|
+
for (;;) {
|
|
134
|
+
const index = next++;
|
|
135
|
+
if (index >= items.length)
|
|
136
|
+
return;
|
|
137
|
+
output[index] = await fn(items[index]);
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
return output;
|
|
141
|
+
}
|
|
142
|
+
export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }, options = {}) {
|
|
143
|
+
const guard = createRootGuard(root);
|
|
144
|
+
await guard("");
|
|
18
145
|
const hits = [];
|
|
19
146
|
const skipped = new Set(indexStatus.skipped);
|
|
20
147
|
const reasons = new Set(indexStatus.complete ? [] : ["index"]);
|
|
21
148
|
const needle = query.trim();
|
|
22
149
|
if (!needle)
|
|
23
150
|
return { hits, truncated: false, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
24
|
-
const tool = createGrepTool(root);
|
|
25
151
|
const controller = new AbortController();
|
|
26
152
|
const timer = setTimeout(() => controller.abort(), MAX_CONTENT_MS);
|
|
153
|
+
const paths = [];
|
|
154
|
+
const candidateByPath = new Map();
|
|
155
|
+
const eligible = [];
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
if (controller.signal.aborted) {
|
|
158
|
+
reasons.add("time");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (candidate.kind !== "file")
|
|
162
|
+
continue;
|
|
163
|
+
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
164
|
+
skipped.add(candidate.path);
|
|
165
|
+
reasons.add("depth");
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
169
|
+
skipped.add(candidate.path);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (candidate.size !== null && candidate.size > MAX_CONTENT_FILE_BYTES) {
|
|
173
|
+
skipped.add(candidate.path);
|
|
174
|
+
reasons.add("size");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Index paths are normalized and symlink-free. Reject any shape that could escape before creating an
|
|
178
|
+
// rg argument; all output still takes the full shared containment path below before it is published.
|
|
179
|
+
if (needsContainmentCheck(candidate.path)) {
|
|
180
|
+
await guard(candidate.path);
|
|
181
|
+
}
|
|
182
|
+
eligible.push(candidate);
|
|
183
|
+
}
|
|
184
|
+
// Do bounded metadata checks concurrently. rg's binary heuristic cannot prove strict UTF-8, so matching
|
|
185
|
+
// files get the full byte check below; output passes the shared containment guard before publication.
|
|
186
|
+
const checkedCandidates = await mapConcurrently(eligible, 96, async (candidate) => ({ candidate, absolute: join(root, candidate.path), classification: await checkedFileSize(join(root, candidate.path)) }));
|
|
187
|
+
for (const { candidate, absolute, classification } of checkedCandidates) {
|
|
188
|
+
if (controller.signal.aborted) {
|
|
189
|
+
reasons.add("time");
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (classification !== "ok") {
|
|
193
|
+
skipped.add(candidate.path);
|
|
194
|
+
if (classification === "size")
|
|
195
|
+
reasons.add("size");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
paths.push(absolute);
|
|
199
|
+
candidateByPath.set(candidate.path, candidate);
|
|
200
|
+
}
|
|
27
201
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
catch (error) {
|
|
41
|
-
const code = error.code;
|
|
42
|
-
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
43
|
-
throw error;
|
|
44
|
-
skipped.add(candidate.path);
|
|
45
|
-
reasons.add("unreadable");
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
49
|
-
skipped.add(candidate.path);
|
|
50
|
-
reasons.add("depth");
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
54
|
-
skipped.add(candidate.path);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
let bytes;
|
|
58
|
-
try {
|
|
59
|
-
// Read at most cap+1 bytes even if a file grows after stat; never allocate its untrusted size.
|
|
60
|
-
const handle = await open(absolute, "r");
|
|
202
|
+
if (controller.signal.aborted)
|
|
203
|
+
return { hits, truncated: true, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
204
|
+
const rgPath = await (options.resolveRipgrep ?? resolveSdkRipgrep)();
|
|
205
|
+
if (!rgPath)
|
|
206
|
+
throw new FilesError(503, "内容搜索不可用(ripgrep 不可用)", "ripgrep_unavailable");
|
|
207
|
+
await (options.runRipgrep ?? runRipgrep)({ rgPath, pattern: needle, paths, signal: controller.signal, onMatch: async (match) => {
|
|
208
|
+
if (controller.signal.aborted) {
|
|
209
|
+
reasons.add("time");
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
let path;
|
|
213
|
+
let absolute;
|
|
61
214
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
skipped.add(candidate.path);
|
|
65
|
-
reasons.add("unreadable");
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (info.size > MAX_CONTENT_FILE_BYTES) {
|
|
69
|
-
skipped.add(candidate.path);
|
|
70
|
-
reasons.add("size");
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
74
|
-
let length = 0;
|
|
75
|
-
while (length < buffer.length) {
|
|
76
|
-
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
77
|
-
if (!read.bytesRead)
|
|
78
|
-
break;
|
|
79
|
-
length += read.bytesRead;
|
|
80
|
-
}
|
|
81
|
-
if (length > MAX_CONTENT_FILE_BYTES) {
|
|
82
|
-
skipped.add(candidate.path);
|
|
83
|
-
reasons.add("size");
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
bytes = buffer.subarray(0, length);
|
|
215
|
+
path = toRelative(root, match.path);
|
|
216
|
+
absolute = await guard(path);
|
|
87
217
|
}
|
|
88
|
-
|
|
89
|
-
|
|
218
|
+
catch (error) {
|
|
219
|
+
const code = error.code;
|
|
220
|
+
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
221
|
+
throw error;
|
|
222
|
+
skipped.add(match.path);
|
|
223
|
+
reasons.add("unreadable");
|
|
224
|
+
return true;
|
|
90
225
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (looksBinary(bytes) || bytes.includes(0)) {
|
|
98
|
-
skipped.add(candidate.path);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
skipped.add(candidate.path);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
let result;
|
|
109
|
-
try {
|
|
110
|
-
result = await tool.execute("content-search", {
|
|
111
|
-
pattern: needle, path: absolute, literal: true, limit: MAX_CONTENT_RESULTS - hits.length,
|
|
112
|
-
}, controller.signal);
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
if (controller.signal.aborted) {
|
|
116
|
-
reasons.add("time");
|
|
117
|
-
break;
|
|
226
|
+
if (!candidateByPath.has(path)) {
|
|
227
|
+
skipped.add(path);
|
|
228
|
+
reasons.add("unreadable");
|
|
229
|
+
return true;
|
|
118
230
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (block.type !== "text")
|
|
126
|
-
continue;
|
|
127
|
-
for (const row of block.text.split("\n")) {
|
|
128
|
-
if (!row.startsWith(prefix))
|
|
129
|
-
continue;
|
|
130
|
-
const match = /^(\d+): (.*)$/.exec(row.slice(prefix.length));
|
|
131
|
-
if (!match)
|
|
132
|
-
continue;
|
|
133
|
-
rows.push({ path: candidate.path, name: basename(absolute), line: Number(match[1]), text: match[2] });
|
|
231
|
+
const classification = await checkedTextFile(absolute);
|
|
232
|
+
if (classification !== "text") {
|
|
233
|
+
skipped.add(path);
|
|
234
|
+
if (classification === "size")
|
|
235
|
+
reasons.add("size");
|
|
236
|
+
return true;
|
|
134
237
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
reasons.add("line");
|
|
145
|
-
if (details?.matchLimitReached || hits.length >= MAX_CONTENT_RESULTS) {
|
|
146
|
-
reasons.add("results");
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
238
|
+
hits.push({ path, name: basename(absolute), line: match.line, text: match.text });
|
|
239
|
+
if (hits.length >= MAX_CONTENT_RESULTS) {
|
|
240
|
+
reasons.add("results");
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
} });
|
|
245
|
+
if (controller.signal.aborted)
|
|
246
|
+
reasons.add("time");
|
|
150
247
|
}
|
|
151
248
|
finally {
|
|
152
249
|
clearTimeout(timer);
|
package/dist/server/index.js
CHANGED
|
@@ -283,6 +283,8 @@ async function shutdown(reason) {
|
|
|
283
283
|
// The capabilities die AFTER the sessions that carry them: a session that is still alive could
|
|
284
284
|
// otherwise use a credential the server has already forgotten about.
|
|
285
285
|
releaseAllCapabilities();
|
|
286
|
+
// Clean up background tasks so the flushed state never freezes running zombies on disk.
|
|
287
|
+
await store.clearBackgroundTasks();
|
|
286
288
|
await store.flush();
|
|
287
289
|
wsServer.close();
|
|
288
290
|
httpServer.closeAllConnections();
|
package/dist/server/store.d.ts
CHANGED
|
@@ -647,6 +647,7 @@ export declare class Store {
|
|
|
647
647
|
setWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
|
|
648
648
|
setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
|
|
649
649
|
removeBackgroundTask(id: string): Promise<void>;
|
|
650
|
+
clearBackgroundTasks(): Promise<void>;
|
|
650
651
|
/**
|
|
651
652
|
* Applies the fields a run's life can change, and nothing else.
|
|
652
653
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -498,6 +498,59 @@ function reconcileTodos(state) {
|
|
|
498
498
|
const after = JSON.stringify({ todos: state.todos, todosByConversation: state.todosByConversation });
|
|
499
499
|
return { changed: before !== after };
|
|
500
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Startup reconciliation: cleans up background tasks left over from previous server process.
|
|
503
|
+
*
|
|
504
|
+
* `backgroundTasks` is a LIFECYCLE collection: it tracks active in-flight executions (pty child
|
|
505
|
+
* processes or detached subagents) and is emptied as each task settles. When a server restarts,
|
|
506
|
+
* those processes are dead and cannot report their completion. Leaving them in `backgroundTasks`
|
|
507
|
+
* creates permanent "zombies" in the main sidebar that can never be dismissed.
|
|
508
|
+
*
|
|
509
|
+
* If a task originated from a tool call in a conversation, its tool call block is marked as failed
|
|
510
|
+
* so the chat transcript accurately reflects that the task was interrupted by server restart.
|
|
511
|
+
*
|
|
512
|
+
* WHY ONLY THE LINKED ONES ARE SWEPT, and not every entry in the list.
|
|
513
|
+
*
|
|
514
|
+
* A task is created by exactly one route: the `/api/exec` endpoint, and that endpoint REQUIRES a
|
|
515
|
+
* `conversationId` (it is what the capability check and the tool-call anchor are resolved against).
|
|
516
|
+
* So every task this server ever creates is linked to a conversation's tool call, and every one of
|
|
517
|
+
* those is swept here - the zombie problem is fully covered.
|
|
518
|
+
*
|
|
519
|
+
* A task with NO conversation anchor is a shape this server cannot produce. The sweep cannot account
|
|
520
|
+
* for it either: there is no tool call to mark failed, so there is nothing to tell the reader that
|
|
521
|
+
* the task died. Deleting it would remove a record without replacing it with an explanation, which is
|
|
522
|
+
* the one outcome worse than a stale entry. So those are LEFT ALONE, and this function says so in its
|
|
523
|
+
* return value rather than silently keeping them.
|
|
524
|
+
*/
|
|
525
|
+
function reconcileBackgroundTasks(state) {
|
|
526
|
+
const tasks = Array.isArray(state.backgroundTasks) ? state.backgroundTasks : [];
|
|
527
|
+
state.backgroundTasks = [];
|
|
528
|
+
if (!tasks.length)
|
|
529
|
+
return { orphaned: [], changed: false, unlinked: [] };
|
|
530
|
+
const orphaned = [];
|
|
531
|
+
const unlinked = [];
|
|
532
|
+
for (const task of tasks) {
|
|
533
|
+
if (!task)
|
|
534
|
+
continue;
|
|
535
|
+
// The three fields are destructured rather than tested through a boolean, because a boolean does not
|
|
536
|
+
// narrow their types and `toolCalls[undefined]` is not an index.
|
|
537
|
+
const { conversationId, originMessageId, originCallRow } = task;
|
|
538
|
+
if (!conversationId || originMessageId === undefined || originCallRow === undefined) {
|
|
539
|
+
unlinked.push(task);
|
|
540
|
+
state.backgroundTasks.push(task);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
orphaned.push(task);
|
|
544
|
+
const conv = state.conversations?.find((c) => c?.id === conversationId);
|
|
545
|
+
const msg = conv?.messages?.find((m) => m?.id === originMessageId);
|
|
546
|
+
const call = msg?.toolCalls?.[originCallRow];
|
|
547
|
+
if (call?.backgroundTask && call.backgroundTask.status === "running") {
|
|
548
|
+
call.backgroundTask.status = "failed";
|
|
549
|
+
call.backgroundTask.output = "任务随服务重启被终止";
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { orphaned, changed: orphaned.length > 0, unlinked };
|
|
553
|
+
}
|
|
501
554
|
export class Store {
|
|
502
555
|
repository;
|
|
503
556
|
state;
|
|
@@ -531,6 +584,7 @@ export class Store {
|
|
|
531
584
|
// and the other was not - a half-repaired state that still needs a second restart to finish.
|
|
532
585
|
const orphaned = reconcileSubagentRuns(state);
|
|
533
586
|
const orphanedWorkflows = reconcileWorkflows(state);
|
|
587
|
+
const orphanedTasks = reconcileBackgroundTasks(state);
|
|
534
588
|
const interrupted = reconcileConversations(state);
|
|
535
589
|
const requeued = reconcileWaitingMessages(state);
|
|
536
590
|
const todos = reconcileTodos(state);
|
|
@@ -538,11 +592,19 @@ export class Store {
|
|
|
538
592
|
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
539
593
|
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
540
594
|
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
541
|
-
if (defaulted || orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
595
|
+
if (defaulted || orphaned.length || orphanedWorkflows.length || orphanedTasks.changed || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
542
596
|
for (const run of orphaned)
|
|
543
597
|
console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
|
|
544
598
|
for (const plan of orphanedWorkflows)
|
|
545
599
|
console.warn(`Super Pi: workflow ${plan.id} was still marked running when the last server stopped - it is now stopped, because no step can still be executing.`);
|
|
600
|
+
for (const task of orphanedTasks.orphaned)
|
|
601
|
+
console.warn(`Super Pi: background task ${task.id} (${task.title}) was left in flight when the last server stopped - it has been cleaned up.`);
|
|
602
|
+
// The unlinked ones are said out loud rather than swept silently. They cannot come from this
|
|
603
|
+
// server - `/api/exec` requires a conversationId - so they are a hand-written or legacy entry,
|
|
604
|
+
// and the reader is told they were left alone instead of wondering why one task vanished and
|
|
605
|
+
// another did not.
|
|
606
|
+
for (const task of orphanedTasks.unlinked)
|
|
607
|
+
console.warn(`Super Pi: background task ${task.id} (${task.title}) has no conversation to be traced back to - it was left in place, because there is no tool call to mark as interrupted.`);
|
|
546
608
|
// Announced for the same reason: a conversation that failed while nobody was watching is invisible
|
|
547
609
|
// otherwise, and the user who was watching a spinner when the server died deserves to learn that
|
|
548
610
|
// their run is over rather than guessing from the fact that it stopped moving.
|
|
@@ -1825,6 +1887,12 @@ export class Store {
|
|
|
1825
1887
|
this.state.backgroundTasks = this.state.backgroundTasks.filter((item) => item.id !== id);
|
|
1826
1888
|
await this.persistCollections("backgroundTasks");
|
|
1827
1889
|
}
|
|
1890
|
+
async clearBackgroundTasks() {
|
|
1891
|
+
if (!this.state.backgroundTasks.length)
|
|
1892
|
+
return;
|
|
1893
|
+
this.state.backgroundTasks = [];
|
|
1894
|
+
await this.persistCollections("backgroundTasks");
|
|
1895
|
+
}
|
|
1828
1896
|
/**
|
|
1829
1897
|
* Applies the fields a run's life can change, and nothing else.
|
|
1830
1898
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -945,6 +945,17 @@ export type ClientCommandBody =
|
|
|
945
945
|
| {
|
|
946
946
|
type: "providers.fetch";
|
|
947
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* Stops and removes an active background task (spawned process or background subagent).
|
|
950
|
+
*
|
|
951
|
+
* If the task has a live pty child process, it is killed. If it originated from a tool call in
|
|
952
|
+
* a conversation, that call is marked as stopped/failed in the transcript so the user sees a
|
|
953
|
+
* settled outcome instead of a permanent spinner. The task is then removed from `backgroundTasks`.
|
|
954
|
+
*/
|
|
955
|
+
| {
|
|
956
|
+
type: "backgroundTask.stop";
|
|
957
|
+
taskId: string;
|
|
958
|
+
}
|
|
948
959
|
/**
|
|
949
960
|
* Sets the app-wide default model. `null` clears it back to "Pi's own default" - the same
|
|
950
961
|
* "absence, not a frozen copy" convention the workspace defaults use, so removing the default
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.108",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -92,6 +92,9 @@
|
|
|
92
92
|
"scripts/file-content-search.test.mjs",
|
|
93
93
|
"scripts/file-diff-client.test.mjs",
|
|
94
94
|
"scripts/task-tool.test.mjs",
|
|
95
|
+
"scripts/tool-register-env.test.mjs",
|
|
96
|
+
"scripts/bg-task-zombie.test.mjs",
|
|
97
|
+
"scripts/scoped-sidebar-tabs.test.mjs",
|
|
95
98
|
"scripts/fork-server.test.mjs",
|
|
96
99
|
"scripts/styles-no-dangling.test.mjs",
|
|
97
100
|
"scripts/theme-no-hardcoded-colours.test.mjs",
|