@evandu/super-pi 0.1.107 → 0.1.109

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.
@@ -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);
@@ -508,28 +508,48 @@ function reconcileTodos(state) {
508
508
  *
509
509
  * If a task originated from a tool call in a conversation, its tool call block is marked as failed
510
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.
511
524
  */
512
525
  function reconcileBackgroundTasks(state) {
513
526
  const tasks = Array.isArray(state.backgroundTasks) ? state.backgroundTasks : [];
514
527
  state.backgroundTasks = [];
515
528
  if (!tasks.length)
516
- return { orphaned: [], changed: false };
529
+ return { orphaned: [], changed: false, unlinked: [] };
517
530
  const orphaned = [];
531
+ const unlinked = [];
518
532
  for (const task of tasks) {
519
533
  if (!task)
520
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
+ }
521
543
  orphaned.push(task);
522
- if (task.conversationId && task.originMessageId !== undefined && task.originCallRow !== undefined) {
523
- const conv = state.conversations?.find((c) => c?.id === task.conversationId);
524
- const msg = conv?.messages?.find((m) => m?.id === task.originMessageId);
525
- const call = msg?.toolCalls?.[task.originCallRow];
526
- if (call?.backgroundTask && call.backgroundTask.status === "running") {
527
- call.backgroundTask.status = "failed";
528
- call.backgroundTask.output = "任务随服务重启被终止";
529
- }
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 = "任务随服务重启被终止";
530
550
  }
531
551
  }
532
- return { orphaned, changed: true };
552
+ return { orphaned, changed: orphaned.length > 0, unlinked };
533
553
  }
534
554
  export class Store {
535
555
  repository;
@@ -579,6 +599,12 @@ export class Store {
579
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.`);
580
600
  for (const task of orphanedTasks.orphaned)
581
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.`);
582
608
  // Announced for the same reason: a conversation that failed while nobody was watching is invisible
583
609
  // otherwise, and the user who was watching a spinner when the server died deserves to learn that
584
610
  // their run is over rather than guessing from the fact that it stopped moving.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -94,6 +94,8 @@
94
94
  "scripts/task-tool.test.mjs",
95
95
  "scripts/tool-register-env.test.mjs",
96
96
  "scripts/bg-task-zombie.test.mjs",
97
+ "scripts/scoped-sidebar-tabs.test.mjs",
98
+ "scripts/git-diff-view.test.mjs",
97
99
  "scripts/fork-server.test.mjs",
98
100
  "scripts/styles-no-dangling.test.mjs",
99
101
  "scripts/theme-no-hardcoded-colours.test.mjs",
@@ -163,6 +165,7 @@
163
165
  ],
164
166
  "dependencies": {
165
167
  "@earendil-works/pi-coding-agent": "^0.86.1",
168
+ "@git-diff-view/react": "^0.1.8",
166
169
  "@types/diff": "^5.2.3",
167
170
  "@xterm/addon-fit": "^0.11.0",
168
171
  "@xterm/xterm": "^6.0.0",