@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.
@@ -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-DBR_t9lF.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-mo5Dvh_v.css">
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
- * SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
27
- * session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
28
- * tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
29
- * Instead give it one verified indexed file at a time; validate again before publishing any hit.
30
- * The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
31
- */
32
- export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">): Promise<ContentSearchResult>;
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 { open } from "node:fs/promises";
2
- import { basename } from "node:path";
3
- import { createGrepTool } from "@earendil-works/pi-coding-agent";
4
- import { isTextName, looksBinary, resolveInsideRoot } from "./files.js";
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
- * SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
11
- * session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
12
- * tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
13
- * Instead give it one verified indexed file at a time; validate again before publishing any hit.
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
- export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }) {
17
- await resolveInsideRoot(root, "");
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
- for (const candidate of candidates) {
29
- if (candidate.kind !== "file")
30
- continue;
31
- if (controller.signal.aborted) {
32
- reasons.add("time");
33
- break;
34
- }
35
- // Validate even an excluded candidate: metadata and cached paths are not a security boundary.
36
- let absolute;
37
- try {
38
- absolute = await resolveInsideRoot(root, candidate.path);
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
- const info = await handle.stat();
63
- if (!info.isFile()) {
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
- finally {
89
- await handle.close();
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
- catch {
93
- skipped.add(candidate.path);
94
- reasons.add("unreadable");
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
- // SDK/ripgrep failures are request failures, never a successful empty search.
120
- throw error;
121
- }
122
- const prefix = basename(absolute) + ":";
123
- const rows = [];
124
- for (const block of result.content) {
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
- if (rows.length) {
137
- await resolveInsideRoot(root, candidate.path);
138
- hits.push(...rows.slice(0, MAX_CONTENT_RESULTS - hits.length));
139
- }
140
- const details = result.details;
141
- if (details?.truncation?.truncated)
142
- reasons.add("output");
143
- if (details?.linesTruncated)
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);
@@ -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();
@@ -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
  *
@@ -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
  *
@@ -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.106",
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",