specpi 0.19.1 → 0.20.0

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.
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const MAX_INTEGRATIONS_BYTES = 16384;
5
+
6
+ export function serializeIntegrations(value, file) {
7
+ let text = `${JSON.stringify(value, null, 2)}\n`;
8
+ if (Buffer.byteLength(text) > MAX_INTEGRATIONS_BYTES) {
9
+ text = JSON.stringify(value);
10
+ }
11
+
12
+ if (Buffer.byteLength(text) > MAX_INTEGRATIONS_BYTES) {
13
+ throw new Error(`Tool integration configuration exceeds ${MAX_INTEGRATIONS_BYTES} bytes: ${file}`);
14
+ }
15
+
16
+ return text;
17
+ }
18
+
19
+ export function integrationsFile(agentDir) {
20
+ return path.join(agentDir, "specpi", "tool-integrations.json");
21
+ }
22
+
23
+ // Content corruption is repairable by an explicit selection; link and file-shape failures are not.
24
+ function corrupt(file, message) {
25
+ const error = new Error(`${message}: ${file}`);
26
+ error.corruptIntegrations = true;
27
+
28
+ return error;
29
+ }
30
+
31
+ export function readIntegrations(agentDir) {
32
+ const file = integrationsFile(agentDir);
33
+ for (const target of [agentDir, path.dirname(file), file]) {
34
+ try {
35
+ if (fs.lstatSync(target).isSymbolicLink()) {
36
+ throw new Error(`Tool integration configuration must not be a link: ${file}`);
37
+ }
38
+ } catch (error) {
39
+ if (error.code === "ENOENT") {
40
+ return { schema: 1, structuralSearch: { enabled: false } };
41
+ }
42
+
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ const stat = fs.statSync(file);
48
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_INTEGRATIONS_BYTES) {
49
+ throw new Error(`Tool integration configuration is not a bounded regular file: ${file}`);
50
+ }
51
+
52
+ let value;
53
+ try {
54
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
55
+ } catch {
56
+ throw corrupt(file, "Tool integration configuration could not be read as JSON");
57
+ }
58
+
59
+ if (!value || value.schema !== 1 || typeof value.structuralSearch?.enabled !== "boolean") {
60
+ throw corrupt(file, "Invalid tool integration configuration");
61
+ }
62
+
63
+ return value;
64
+ }
@@ -0,0 +1,18 @@
1
+ export declare const VERSION: string;
2
+ export declare const LANGUAGES: Readonly<Record<string, { parser: string; extensions: string[] }>>;
3
+ export type SearchInput = {
4
+ language: string;
5
+ pattern: string;
6
+ paths: string[];
7
+ maxResults?: number;
8
+ timeoutMs?: number;
9
+ };
10
+ export declare class SearchError extends Error {
11
+ status: string;
12
+ constructor(status: string, message: string);
13
+ }
14
+ export declare function normalizeSearch(input: unknown): Required<SearchInput>;
15
+ export declare function structuralSearch(
16
+ input: SearchInput,
17
+ options: { cwd: string; runtimeDir: string; signal?: AbortSignal; admit?: () => Promise<void> },
18
+ ): Promise<unknown>;
@@ -0,0 +1,383 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import { createSnapshot } from "../delegation/snapshot.mjs";
7
+
8
+ export const VERSION = "0.45.3";
9
+ export const LANGUAGES = Object.freeze({
10
+ javascript: { parser: "javascript", extensions: [".js", ".mjs", ".cjs"] },
11
+ jsx: { parser: "jsx", extensions: [".jsx"] },
12
+ typescript: { parser: "typescript", extensions: [".ts", ".mts", ".cts"] },
13
+ tsx: { parser: "tsx", extensions: [".tsx"] },
14
+ python: { parser: "python", extensions: [".py"] },
15
+ });
16
+ export const MAX_RESULT_BYTES = 24 * 1024;
17
+ export class SearchError extends Error {
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
23
+ function reject(message) {
24
+ throw new SearchError("denied", message);
25
+ }
26
+
27
+ export function normalizeSearch(input) {
28
+ if (
29
+ !input ||
30
+ typeof input !== "object" ||
31
+ Array.isArray(input) ||
32
+ Object.keys(input).some((key) => !["language", "pattern", "paths", "maxResults", "timeoutMs"].includes(key))
33
+ ) {
34
+ reject("Invalid structural search input.");
35
+ }
36
+
37
+ if (!Object.hasOwn(LANGUAGES, input.language)) {
38
+ throw new SearchError("unsupported_language", "Choose a supported structural search language.");
39
+ }
40
+
41
+ if (
42
+ typeof input.pattern !== "string" ||
43
+ !input.pattern.trim() ||
44
+ Buffer.byteLength(input.pattern) > 4096 ||
45
+ input.pattern.includes("\0")
46
+ ) {
47
+ throw new SearchError("invalid_pattern", "Provide a nonempty pattern up to 4096 bytes.");
48
+ }
49
+
50
+ if (
51
+ !Array.isArray(input.paths) ||
52
+ !input.paths.length ||
53
+ input.paths.length > 200 ||
54
+ input.paths.some(
55
+ (p) =>
56
+ typeof p !== "string" || !LANGUAGES[input.language].extensions.includes(path.extname(p).toLowerCase()),
57
+ ) ||
58
+ Buffer.byteLength(JSON.stringify(input.paths)) > 6000
59
+ ) {
60
+ reject("Select 1–200 explicit relative files matching the language, with at most 6000 bytes of paths.");
61
+ }
62
+
63
+ const maxResults = input.maxResults ?? 50;
64
+ const timeoutMs = input.timeoutMs ?? 10000;
65
+ if (
66
+ !Number.isInteger(maxResults) ||
67
+ maxResults < 1 ||
68
+ maxResults > 100 ||
69
+ !Number.isInteger(timeoutMs) ||
70
+ timeoutMs < 1000 ||
71
+ timeoutMs > 30000
72
+ ) {
73
+ reject("Structural search limits are invalid.");
74
+ }
75
+
76
+ return { language: input.language, pattern: input.pattern, paths: [...input.paths], maxResults, timeoutMs };
77
+ }
78
+
79
+ export function resolveBinary(runtimeDir) {
80
+ const platforms = {
81
+ "win32-x64": "win32-x64-msvc",
82
+ "darwin-arm64": "darwin-arm64",
83
+ "darwin-x64": "darwin-x64",
84
+ "linux-x64": "linux-x64-gnu",
85
+ };
86
+ const platform = platforms[`${process.platform}-${process.arch}`];
87
+ if (!platform || (process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime)) {
88
+ throw new SearchError("unavailable", "Structural search is unavailable on this platform.");
89
+ }
90
+
91
+ const directory = path.join(runtimeDir, "node_modules", "@ast-grep", `cli-${platform}`);
92
+ try {
93
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"));
94
+ const executable = path.join(directory, process.platform === "win32" ? "ast-grep.exe" : "ast-grep");
95
+ if (manifest.version !== VERSION || !fs.statSync(executable).isFile()) {
96
+ throw new Error("Wrong binary");
97
+ }
98
+
99
+ return executable;
100
+ } catch {
101
+ throw new SearchError(
102
+ "unavailable",
103
+ "Structural search runtime unavailable. Run specpi update --structural-search=on without acquisition skip flags.",
104
+ );
105
+ }
106
+ }
107
+
108
+ export function minimalEnvironment() {
109
+ const env = {};
110
+ // Native parser only: no loader overrides, HOME/config discovery, provider keys or shell.
111
+ for (const key of ["SystemRoot", "WINDIR", "TEMP", "TMP", "LANG", "LC_ALL"]) {
112
+ if (process.env[key]) {
113
+ env[key] = process.env[key];
114
+ }
115
+ }
116
+
117
+ return env;
118
+ }
119
+
120
+ export function parseSource(executable, directory, input, bytes, signal, quota = { remaining: 2 * 1024 * 1024 }) {
121
+ return new Promise((resolve, rejectPromise) => {
122
+ if (signal?.aborted) {
123
+ rejectPromise(new SearchError("cancelled", "Structural search cancelled."));
124
+
125
+ return;
126
+ }
127
+
128
+ const args = [
129
+ "run",
130
+ "--config",
131
+ path.join(directory, "sgconfig.yml"),
132
+ "--lang",
133
+ LANGUAGES[input.language].parser,
134
+ `--pattern=${input.pattern}`,
135
+ "--stdin",
136
+ "--json=compact",
137
+ "--threads",
138
+ "1",
139
+ "--color",
140
+ "never",
141
+ ];
142
+ const child = spawn(executable, args, {
143
+ cwd: directory,
144
+ env: minimalEnvironment(),
145
+ shell: false,
146
+ windowsHide: true,
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ });
149
+ const output = [];
150
+ quota.stderrRemaining ??= 8192;
151
+ let failure;
152
+ let killTimer;
153
+ let finished = false;
154
+ const fail = (error) => {
155
+ failure ??= error;
156
+ try {
157
+ child.kill("SIGKILL");
158
+ } catch {
159
+ // The close deadline below reports unconfirmed cleanup and prevents reuse.
160
+ }
161
+
162
+ killTimer ??= setTimeout(() => {
163
+ if (!finished) {
164
+ finished = true;
165
+ rejectPromise(
166
+ new SearchError(
167
+ "cleanup_failed",
168
+ "Parser cleanup could not be confirmed; reload after terminating the owned parser.",
169
+ ),
170
+ );
171
+ }
172
+ }, 1000);
173
+ };
174
+
175
+ const abort = () => fail(new SearchError("cancelled", "Structural search cancelled."));
176
+ signal?.addEventListener("abort", abort, { once: true });
177
+ child.stdout.on("data", (chunk) => {
178
+ quota.remaining -= chunk.length;
179
+ if (quota.remaining < 0) {
180
+ fail(new SearchError("partial", "Parser output limit reached; narrow the selection or pattern."));
181
+ } else {
182
+ output.push(chunk);
183
+ }
184
+ });
185
+ child.stderr.on("data", (chunk) => {
186
+ quota.stderrRemaining -= chunk.length;
187
+ if (quota.stderrRemaining < 0) {
188
+ fail(new SearchError("partial", "Parser diagnostic limit reached."));
189
+ }
190
+ });
191
+ child.stdin.on("error", () => {});
192
+ child.on("error", () => {
193
+ failure ??= new SearchError("unavailable", "Structural parser could not start.");
194
+ });
195
+ child.on("close", (code) => {
196
+ clearTimeout(killTimer);
197
+ signal?.removeEventListener("abort", abort);
198
+ if (finished) {
199
+ return;
200
+ }
201
+
202
+ finished = true;
203
+ if (failure) {
204
+ rejectPromise(failure);
205
+
206
+ return;
207
+ }
208
+
209
+ if (code !== 0 && code !== 1) {
210
+ rejectPromise(new SearchError("invalid_pattern", "Parser rejected this pattern or input."));
211
+
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const raw = Buffer.concat(output).toString("utf8").trim();
217
+ const matches = raw ? JSON.parse(raw) : [];
218
+ if (!Array.isArray(matches)) {
219
+ throw new Error("Malformed output");
220
+ }
221
+
222
+ resolve(matches);
223
+ } catch {
224
+ rejectPromise(new SearchError("unavailable", "Parser returned invalid structured output."));
225
+ }
226
+ });
227
+ child.stdin.end(bytes);
228
+ if (signal?.aborted) {
229
+ abort();
230
+ }
231
+ });
232
+ }
233
+
234
+ function clip(text, maximum) {
235
+ let value = String(text)
236
+ .slice(0, maximum)
237
+ .toWellFormed()
238
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
239
+ while (Buffer.byteLength(value) > maximum) {
240
+ value = value.slice(0, -1).toWellFormed();
241
+ }
242
+
243
+ return value;
244
+ }
245
+
246
+ export async function structuralSearch(
247
+ input,
248
+ { cwd, runtimeDir, signal, admit = async () => {}, parser = parseSource } = {},
249
+ ) {
250
+ const spec = normalizeSearch(input);
251
+ await admit();
252
+ if (signal?.aborted) {
253
+ throw new SearchError("cancelled", "Structural search cancelled.");
254
+ }
255
+
256
+ const executable = resolveBinary(runtimeDir);
257
+ const snapshot = createSnapshot(cwd, spec.paths);
258
+ let directory;
259
+ let preserveDirectory = false;
260
+ const result = {
261
+ status: "complete",
262
+ engineVersion: VERSION,
263
+ language: spec.language,
264
+ captureId: crypto.randomUUID(),
265
+ searchedFiles: 0,
266
+ requestedFiles: spec.paths.length,
267
+ sources: snapshot.sources.map(({ id, path: file, digest }) => ({ id, path: file, digest })),
268
+ matches: [],
269
+ truncated: false,
270
+ columnEncoding: "one-based UTF-8 byte columns; end exclusive",
271
+ };
272
+ try {
273
+ if (
274
+ snapshot.sources.some((source) => source.bytes > 1024 * 1024) ||
275
+ Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES / 2
276
+ ) {
277
+ reject("Source or selection metadata exceeds the search quota; narrow the selection.");
278
+ }
279
+
280
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-structural-"));
281
+ fs.writeFileSync(path.join(directory, "sgconfig.yml"), "ruleDirs: []\n", { mode: 0o600 });
282
+ const quota = { remaining: 2 * 1024 * 1024 };
283
+ for (const source of snapshot.sources) {
284
+ await admit();
285
+ const matches = await snapshot.withSourceBytes(source.id, async (bytes) => {
286
+ const raw = await parser(executable, directory, spec, bytes, signal, quota);
287
+ const lines = [0];
288
+ for (let offset = 0; offset < bytes.length; offset += 1) {
289
+ if (bytes[offset] === 10) {
290
+ lines.push(offset + 1);
291
+ }
292
+ }
293
+
294
+ const position = (offset) => {
295
+ let low = 0;
296
+ let high = lines.length;
297
+ while (low + 1 < high) {
298
+ const middle = Math.floor((low + high) / 2);
299
+ if (lines[middle] <= offset) {
300
+ low = middle;
301
+ } else {
302
+ high = middle;
303
+ }
304
+ }
305
+
306
+ return { line: low, column: offset - lines[low] };
307
+ };
308
+
309
+ // CLI display columns count characters. Use original byte offsets instead.
310
+ return raw.slice(0, spec.maxResults + 1).map((match) => {
311
+ const start = match.range?.byteOffset?.start;
312
+ const end = match.range?.byteOffset?.end;
313
+ if (
314
+ !Number.isSafeInteger(start) ||
315
+ !Number.isSafeInteger(end) ||
316
+ start < 0 ||
317
+ end < start ||
318
+ end > bytes.length ||
319
+ bytes.subarray(start, end).toString("utf8") !== match.text
320
+ ) {
321
+ throw new SearchError("unavailable", "Parser returned an invalid source range.");
322
+ }
323
+
324
+ return { text: match.text, range: { start: position(start), end: position(end) } };
325
+ });
326
+ });
327
+ result.searchedFiles += 1;
328
+ for (const match of matches) {
329
+ const start = match.range?.start;
330
+ const end = match.range?.end;
331
+ if (
332
+ ![start?.line, start?.column, end?.line, end?.column].every(
333
+ (v) => Number.isSafeInteger(v) && v >= 0,
334
+ ) ||
335
+ typeof match.text !== "string"
336
+ ) {
337
+ throw new SearchError("unavailable", "Parser returned an invalid source range.");
338
+ }
339
+
340
+ const entry = {
341
+ sourceId: source.id,
342
+ path: source.path,
343
+ start: { line: start.line + 1, byteColumn: start.column + 1 },
344
+ end: { line: end.line + 1, byteColumn: end.column + 1 },
345
+ snippet: clip(match.text, 512),
346
+ snippetTruncated: Buffer.byteLength(match.text) > 512,
347
+ };
348
+ result.matches.push(entry);
349
+ if (
350
+ result.matches.length > spec.maxResults ||
351
+ Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES - 512
352
+ ) {
353
+ result.matches.pop();
354
+ result.truncated = true;
355
+ result.status = "partial";
356
+
357
+ return result;
358
+ }
359
+ }
360
+ }
361
+
362
+ return result;
363
+ } catch (error) {
364
+ preserveDirectory = error instanceof SearchError && error.status === "cleanup_failed";
365
+ if (error instanceof SearchError && error.status === "partial") {
366
+ return { ...result, status: "partial", truncated: true, reason: error.message };
367
+ }
368
+
369
+ throw error;
370
+ } finally {
371
+ snapshot.destroy();
372
+ if (directory && !preserveDirectory) {
373
+ try {
374
+ fs.rmSync(directory, { recursive: true, force: true });
375
+ } catch {
376
+ throw new SearchError(
377
+ "cleanup_failed",
378
+ "Structural scratch cleanup failed; resolve it before reloading.",
379
+ );
380
+ }
381
+ }
382
+ }
383
+ }
@@ -0,0 +1,228 @@
1
+ import path from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { StringEnum } from "@earendil-works/pi-ai";
5
+ import { getAgentDir } from "../browser/core.mjs";
6
+ import { LANGUAGES, SearchError, normalizeSearch, structuralSearch } from "./core.mjs";
7
+ import { readIntegrations } from "./config.mjs";
8
+
9
+ type Policy = { mode: string; generation: number; action: string };
10
+ export default function registerStructuralSearch(pi: ExtensionAPI) {
11
+ const agentDir = getAgentDir(import.meta.url);
12
+ let enabled = false;
13
+ try {
14
+ enabled = readIntegrations(agentDir).structuralSearch.enabled;
15
+ } catch {
16
+ // Malformed configuration never enables code execution.
17
+ }
18
+
19
+ if (!enabled) {
20
+ return;
21
+ }
22
+
23
+ let lifecycle = new AbortController();
24
+ let cleanupFailed = false;
25
+ let pending = 0;
26
+ let tail: Promise<unknown> = Promise.resolve();
27
+ const invalidate = () => {
28
+ lifecycle.abort();
29
+ lifecycle = new AbortController();
30
+ };
31
+
32
+ const unsubscribe = pi.events.on("specpi:guard-policy-changed", invalidate);
33
+ pi.on("session_start", invalidate);
34
+ pi.on("session_tree", invalidate);
35
+ pi.on("session_shutdown", () => {
36
+ invalidate();
37
+ unsubscribe();
38
+ });
39
+ function policy(): Policy {
40
+ let states = 0;
41
+ const replies: Policy[] = [];
42
+ pi.events.emit("specpi:guard-state", {
43
+ reply() {
44
+ states += 1;
45
+ },
46
+ });
47
+ pi.events.emit("specpi:structural-admission", {
48
+ reply(value: Policy) {
49
+ replies.push(value);
50
+ },
51
+ });
52
+ if (states === 0 && replies.length === 0) {
53
+ return { mode: "absent", generation: 0, action: "allow" };
54
+ }
55
+
56
+ const value = replies[0];
57
+ if (
58
+ states !== 1 ||
59
+ replies.length !== 1 ||
60
+ !["guard", "strict", "off", "locked"].includes(value?.mode) ||
61
+ !Number.isSafeInteger(value.generation) ||
62
+ !["allow", "ask", "deny"].includes(value.action)
63
+ ) {
64
+ throw new SearchError("denied", "Structural search policy unavailable or ambiguous.");
65
+ }
66
+
67
+ return value;
68
+ }
69
+
70
+ pi.registerTool({
71
+ name: "structural_search",
72
+ label: "Structural Search",
73
+ description:
74
+ "Read-only ast-grep patterns over explicit selected source files. Use literal search for filenames/comments; results do not resolve types or symbols. No rewrite or custom config.",
75
+ parameters: Type.Object(
76
+ {
77
+ language: StringEnum(Object.keys(LANGUAGES)),
78
+ pattern: Type.String(),
79
+ paths: Type.Array(Type.String()),
80
+ maxResults: Type.Optional(Type.Integer()),
81
+ timeoutMs: Type.Optional(Type.Integer()),
82
+ },
83
+ { additionalProperties: false },
84
+ ),
85
+ executionMode: "sequential",
86
+ async execute(_id, input, originalSignal, _update, ctx) {
87
+ const spec = normalizeSearch(input);
88
+ const cwd = path.resolve(ctx.cwd);
89
+ if (pending >= 2) {
90
+ throw new SearchError("denied", "Structural search queue is full.");
91
+ }
92
+
93
+ pending += 1;
94
+ let started = false;
95
+ const controller = new AbortController();
96
+ const signal = AbortSignal.any([
97
+ controller.signal,
98
+ lifecycle.signal,
99
+ ...(originalSignal ? [originalSignal] : []),
100
+ ]);
101
+ const deadline = Date.now() + spec.timeoutMs;
102
+ const timer = setTimeout(() => controller.abort(), spec.timeoutMs);
103
+ const run = async () => {
104
+ started = true;
105
+ if (cleanupFailed) {
106
+ throw new SearchError(
107
+ "cleanup_failed",
108
+ "Previous parser cleanup failed; resolve it before reloading.",
109
+ );
110
+ }
111
+
112
+ if (!readIntegrations(agentDir).structuralSearch.enabled || signal.aborted) {
113
+ throw new SearchError("denied", "Structural search disabled or cancelled.");
114
+ }
115
+
116
+ const granted = policy();
117
+ if (granted.action === "deny") {
118
+ throw new SearchError("denied", "Command Guard denied structural search.");
119
+ }
120
+
121
+ if (granted.action === "ask") {
122
+ if (!ctx.hasUI) {
123
+ throw new SearchError("denied", "Strict structural search requires approval UI.");
124
+ }
125
+
126
+ const prompt = ctx.ui.select(
127
+ `Read-only structural search ${JSON.stringify({ cwd, language: spec.language, paths: spec.paths, pattern: spec.pattern }).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "_")}`,
128
+ ["Deny", "Allow once"],
129
+ // RPC forwards timeout to clients but does not forward signal-driven dismissal.
130
+ { signal, timeout: Math.max(1, deadline - Date.now()) },
131
+ );
132
+ let cancelApproval: (() => void) | undefined;
133
+ let answer;
134
+ try {
135
+ answer = await Promise.race([
136
+ prompt,
137
+ new Promise<undefined>((resolve) => {
138
+ cancelApproval = () => resolve(undefined);
139
+ signal.addEventListener("abort", cancelApproval, { once: true });
140
+ if (signal.aborted) {
141
+ cancelApproval();
142
+ }
143
+ }),
144
+ ]);
145
+ } finally {
146
+ if (cancelApproval) {
147
+ signal.removeEventListener("abort", cancelApproval);
148
+ }
149
+ }
150
+
151
+ if (answer !== "Allow once") {
152
+ throw new SearchError("denied", "Structural search was not approved.");
153
+ }
154
+ }
155
+
156
+ const admit = async () => {
157
+ const current = policy();
158
+ if (
159
+ signal.aborted ||
160
+ path.resolve(ctx.cwd) !== cwd ||
161
+ current.action === "deny" ||
162
+ current.generation !== granted.generation ||
163
+ current.mode !== granted.mode ||
164
+ !readIntegrations(agentDir).structuralSearch.enabled
165
+ ) {
166
+ throw new SearchError("denied", "Structural search policy changed or request cancelled.");
167
+ }
168
+ };
169
+
170
+ return structuralSearch(spec, {
171
+ cwd,
172
+ runtimeDir: path.join(agentDir, "specpi", "structural-runtime"),
173
+ signal,
174
+ admit,
175
+ });
176
+ };
177
+
178
+ const operation = tail.then(run, run);
179
+ tail = operation
180
+ .catch(() => {})
181
+ .finally(() => {
182
+ pending -= 1;
183
+ });
184
+ let cancelQueued: (() => void) | undefined;
185
+ try {
186
+ const queuedAbort = new Promise<never>((_resolve, reject) => {
187
+ cancelQueued = () => {
188
+ if (!started) {
189
+ reject(new SearchError("cancelled", "Queued structural search cancelled."));
190
+ }
191
+ };
192
+
193
+ signal.addEventListener("abort", cancelQueued, { once: true });
194
+ if (signal.aborted) {
195
+ cancelQueued();
196
+ }
197
+ });
198
+ const value = await Promise.race([operation, queuedAbort]);
199
+
200
+ return { content: [{ type: "text", text: JSON.stringify(value) }], details: {} };
201
+ } catch (error) {
202
+ cleanupFailed ||= error instanceof SearchError && error.status === "cleanup_failed";
203
+ const status = controller.signal.aborted
204
+ ? "timed_out"
205
+ : signal.aborted
206
+ ? "cancelled"
207
+ : error instanceof SearchError
208
+ ? error.status
209
+ : "denied";
210
+ const reason =
211
+ error instanceof SearchError
212
+ ? error.message
213
+ : "Source selection or runtime unavailable; check the explicit files and configuration.";
214
+
215
+ return {
216
+ content: [{ type: "text", text: JSON.stringify({ status, reason }) }],
217
+ details: {},
218
+ isError: true,
219
+ };
220
+ } finally {
221
+ clearTimeout(timer);
222
+ if (cancelQueued) {
223
+ signal.removeEventListener("abort", cancelQueued);
224
+ }
225
+ }
226
+ },
227
+ });
228
+ }