pi-supernova 0.9.0 → 0.10.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.
- package/README.md +191 -14
- package/index.js +30 -9
- package/package.json +1 -1
- package/src/adapters/bash.js +40 -0
- package/src/adapters/read.js +24 -3
- package/src/bridge/host-bridge.js +50 -7
- package/src/bridge/invoke.js +6 -1
- package/src/bridge/trace.js +4 -1
- package/src/context/evidence.js +10 -1
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +14 -5
- package/src/context/repo-index.js +15 -4
- package/src/context/snap-search.js +14 -13
- package/src/context/snap.js +11 -2
- package/src/contract/bash.js +43 -11
- package/src/contract/read.js +19 -5
- package/src/fs/background.js +304 -0
- package/src/fs/commit.js +6 -0
- package/src/fs/file-io.js +17 -2
- package/src/fs/process-tree.js +92 -0
- package/src/fs/read-window.js +18 -1
- package/src/fs/vfs.js +17 -4
- package/src/fs/workspace.js +51 -46
- package/src/output/bottleneck.js +14 -1
- package/src/runtime/guest-api.js +36 -20
- package/src/runtime/reference.js +11 -14
- package/src/shared/syntax-context.js +28 -7
|
@@ -15,17 +15,15 @@ function inScope(filePath, dir, includeHidden) {
|
|
|
15
15
|
return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
18
|
+
function makeCandidate(filePath, dir, query, tokens, flags, needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS))) {
|
|
19
19
|
const relative = path.relative(dir, filePath);
|
|
20
20
|
const lower = relative.toLowerCase();
|
|
21
21
|
const base = path.basename(lower);
|
|
22
22
|
|
|
23
23
|
const extension = path.extname(base);
|
|
24
24
|
const stemBase = extension ? base.slice(0, -extension.length) : base;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
25
|
+
const queryLower = query.toLowerCase();
|
|
26
|
+
const exactPath = lower === queryLower || base === queryLower || stemBase === queryLower;
|
|
29
27
|
|
|
30
28
|
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
31
29
|
pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
|
|
@@ -120,7 +118,7 @@ function parseRgRecord(line, truncated, isLast) {
|
|
|
120
118
|
}
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles) {
|
|
121
|
+
function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles) {
|
|
124
122
|
if (record.type !== "match" && record.type !== "context") return;
|
|
125
123
|
const data = record.data;
|
|
126
124
|
|
|
@@ -131,21 +129,21 @@ function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candid
|
|
|
131
129
|
let candidate = candidates.get(filePath);
|
|
132
130
|
|
|
133
131
|
if (!candidate) {
|
|
134
|
-
candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
132
|
+
candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags, candidateNeedles);
|
|
135
133
|
candidates.set(filePath, candidate);
|
|
136
134
|
}
|
|
137
135
|
|
|
138
136
|
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
|
|
139
|
+
function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal) {
|
|
142
140
|
let overlayTruncated = false;
|
|
143
141
|
|
|
144
142
|
for (const filePath of pendingPaths) {
|
|
145
143
|
const pending = overlayText(filePath);
|
|
146
144
|
|
|
147
145
|
if (pending === undefined) continue;
|
|
148
|
-
const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
146
|
+
const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags, candidateNeedles);
|
|
149
147
|
overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
|
|
150
148
|
|
|
151
149
|
if (candidate.matched.size) candidates.set(filePath, candidate);
|
|
@@ -176,7 +174,7 @@ async function runContentSearch({ dir, includeHidden, searchNeedles, run, overla
|
|
|
176
174
|
return response;
|
|
177
175
|
}
|
|
178
176
|
|
|
179
|
-
function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
|
|
177
|
+
function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal) {
|
|
180
178
|
const records = response.stdout.split("\n");
|
|
181
179
|
|
|
182
180
|
for (let i = 0; i < records.length; i++) {
|
|
@@ -185,17 +183,20 @@ function absorbRgRecords(candidates, response, dir, includeHidden, overlayText,
|
|
|
185
183
|
|
|
186
184
|
if (record === undefined) break;
|
|
187
185
|
if (!record) continue;
|
|
188
|
-
absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles);
|
|
186
|
+
absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles);
|
|
189
187
|
}
|
|
190
188
|
}
|
|
191
189
|
|
|
192
190
|
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
|
|
193
191
|
const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
192
|
+
// Coverage needles are always token-derived (even in exact mode, where the
|
|
193
|
+
// search needles collapse to the query): computed once, not once per file.
|
|
194
|
+
const candidateNeedles = exact ? tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS)) : needles;
|
|
194
195
|
const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
|
|
195
196
|
const candidates = new Map();
|
|
196
197
|
const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
|
|
197
|
-
absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal);
|
|
198
|
-
const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal);
|
|
198
|
+
absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
|
|
199
|
+
const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
|
|
199
200
|
|
|
200
201
|
return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
|
|
201
202
|
}
|
package/src/context/snap.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {inScope,makeCandidate,contentCandidates,MAX_SEARCH_CHARS} from './snap-search.js';
|
|
2
2
|
import { tokenizeQuery, stem } from "./query.js";
|
|
3
|
+
|
|
3
4
|
export {tokenizeQuery,scorePathTopology,stem} from './query.js';
|
|
5
|
+
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
|
|
6
8
|
import * as fs from "node:fs/promises";
|
|
@@ -22,13 +24,13 @@ function rankScore(candidate, tokenCount) {
|
|
|
22
24
|
function location(candidate, root) {
|
|
23
25
|
const context = candidate.context;
|
|
24
26
|
|
|
25
|
-
return { path:
|
|
27
|
+
return { path: relativeSlash(root, candidate.path), line: candidate.line, signature: candidate.signature,
|
|
26
28
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
async function spanCandidates(filePath, lines, root, overlayText, signal) {
|
|
30
32
|
const staged = overlayText(filePath);
|
|
31
|
-
const rel =
|
|
33
|
+
const rel = relativeSlash(root, filePath);
|
|
32
34
|
let text = staged;
|
|
33
35
|
|
|
34
36
|
if (text === undefined) {
|
|
@@ -38,10 +40,12 @@ async function spanCandidates(filePath, lines, root, overlayText, signal) {
|
|
|
38
40
|
const stat = await file.stat();
|
|
39
41
|
|
|
40
42
|
if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
|
|
43
|
+
|
|
41
44
|
if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
|
|
42
45
|
text = await file.readFile({ encoding: "utf8", signal });
|
|
43
46
|
} finally { await file.close(); }
|
|
44
47
|
}
|
|
48
|
+
|
|
45
49
|
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
|
|
46
50
|
|
|
47
51
|
return lines.map(line => {
|
|
@@ -68,6 +72,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
|
|
|
68
72
|
try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
|
|
69
73
|
catch { signal?.throwIfAborted(); out.push(location(candidate, root)); }
|
|
70
74
|
}
|
|
75
|
+
|
|
71
76
|
if (out.length >= MAX_ALTERNATIVES) break;
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -104,7 +109,9 @@ function listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths) {
|
|
|
104
109
|
|
|
105
110
|
function filenameEligible(search, filePath, relative, tokens, exact, queryLower) {
|
|
106
111
|
if (search.candidates.has(filePath)) return false;
|
|
112
|
+
|
|
107
113
|
if (!tokens.some(token => relative.includes(token))) return false;
|
|
114
|
+
|
|
108
115
|
if (exact && tokens.length > 1 && !relative.includes(queryLower)) return false;
|
|
109
116
|
|
|
110
117
|
return true;
|
|
@@ -174,6 +181,7 @@ async function decideSnapResult(ranked, tokens, empty, candidates, relativeRoot,
|
|
|
174
181
|
function fuzzySnapMiss(exact, query, paths, pathContext, relativeRoot, empty) {
|
|
175
182
|
const eligible = exact && query.length >= 4 && query.length <= 64;
|
|
176
183
|
const limited = eligible && paths.length > 1024;
|
|
184
|
+
|
|
177
185
|
const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
|
|
178
186
|
{ ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
|
|
179
187
|
|
|
@@ -209,6 +217,7 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
209
217
|
signal?.throwIfAborted();
|
|
210
218
|
|
|
211
219
|
const empty = emptySnap();
|
|
220
|
+
|
|
212
221
|
const dirStat = await fs.stat(dir).catch(error => {
|
|
213
222
|
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
|
|
214
223
|
|
package/src/contract/bash.js
CHANGED
|
@@ -2,13 +2,10 @@ import { isString, isObject } from "../shared/decode.js";
|
|
|
2
2
|
|
|
3
3
|
const ARGV_ERROR = "bash argv requires a command string and an array of string args";
|
|
4
4
|
|
|
5
|
-
function quoteShellArg(value) {
|
|
6
|
-
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
5
|
/** Normalize guest bash(command, opts) / bash({command, args}) into host args. */
|
|
10
6
|
function normalizeArgv(args) {
|
|
11
7
|
if (args.args === undefined) return;
|
|
8
|
+
|
|
12
9
|
if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
|
|
13
10
|
|
|
14
11
|
for (let i = 0; i < args.args.length; i++) {
|
|
@@ -17,30 +14,39 @@ function normalizeArgv(args) {
|
|
|
17
14
|
throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
|
|
18
15
|
}
|
|
19
16
|
}
|
|
17
|
+
|
|
20
18
|
args.args = args.args.map((arg, i) => {
|
|
21
19
|
if (arg.includes("\0")) throw new Error(`bash args[${i}] must not contain null bytes`);
|
|
20
|
+
|
|
22
21
|
return String(arg);
|
|
23
22
|
});
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
delete args._directArgv;
|
|
27
|
-
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
28
|
-
delete args.args;
|
|
29
|
-
} else args._directArgv = true;
|
|
24
|
+
args._directArgv = true;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
|
|
27
|
+
const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv", "background", "pty"];
|
|
33
28
|
|
|
34
29
|
/** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
|
|
35
30
|
function assertBashOptions(args) {
|
|
36
31
|
const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
|
|
37
32
|
|
|
38
|
-
if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs");
|
|
33
|
+
if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs, background, pty");
|
|
39
34
|
}
|
|
35
|
+
|
|
40
36
|
export function normalizeBash(command, opts) {
|
|
41
37
|
const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
|
|
38
|
+
|
|
39
|
+
if (args.action !== undefined || args.sessionId !== undefined) return normalizeTerminalControl(args);
|
|
42
40
|
assertBashOptions(args);
|
|
41
|
+
|
|
42
|
+
for (const key of ["background", "pty"]) {
|
|
43
|
+
if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error(`bash ${key} must be boolean`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (args.pty !== undefined && args.background !== true) throw new Error("bash pty requires background:true");
|
|
47
|
+
|
|
43
48
|
if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
|
|
49
|
+
|
|
44
50
|
if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
|
|
45
51
|
normalizeArgv(args);
|
|
46
52
|
|
|
@@ -49,11 +55,37 @@ export function normalizeBash(command, opts) {
|
|
|
49
55
|
return args;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
function normalizeTerminalControl(args) {
|
|
59
|
+
const fields = {
|
|
60
|
+
list: ["action"],
|
|
61
|
+
poll: ["action", "sessionId", "cursor", "waitMs"],
|
|
62
|
+
write: ["action", "sessionId", "input"],
|
|
63
|
+
stop: ["action", "sessionId"],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!Object.hasOwn(fields, args.action)) throw new Error("bash terminal action must be list, poll, write or stop");
|
|
67
|
+
const unknown = Object.keys(args).filter(key => !fields[args.action].includes(key));
|
|
68
|
+
|
|
69
|
+
if (unknown.length) throw new Error(`bash ${args.action} does not accept option ${unknown.join(", ")}`);
|
|
70
|
+
|
|
71
|
+
if (args.action !== "list" && (!isString(args.sessionId) || !args.sessionId.trim())) throw new Error("bash terminal action requires sessionId");
|
|
72
|
+
|
|
73
|
+
if (args.action === "write" && (!isString(args.input) || args.input.length > 16384)) throw new Error("bash terminal input must be a string of at most 16384 characters");
|
|
74
|
+
|
|
75
|
+
if (args.cursor !== undefined && (!Number.isSafeInteger(args.cursor) || args.cursor < 0)) throw new Error("bash terminal cursor must be a non-negative safe integer");
|
|
76
|
+
|
|
77
|
+
if (args.waitMs !== undefined && (!Number.isInteger(args.waitMs) || args.waitMs < 0 || args.waitMs > 30000)) throw new Error("bash terminal waitMs must be an integer from 0 to 30000");
|
|
78
|
+
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
function normalizeTimeout(args) {
|
|
53
83
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
84
|
+
|
|
54
85
|
// Reject before the host's external-mutation barrier can flush staged files.
|
|
55
86
|
if (args.timeoutMs !== undefined) {
|
|
56
87
|
const timeout = Number(args.timeoutMs);
|
|
88
|
+
|
|
57
89
|
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
|
|
58
90
|
args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
|
|
59
91
|
}
|
package/src/contract/read.js
CHANGED
|
@@ -12,6 +12,7 @@ function assertReadOptions(args) {
|
|
|
12
12
|
const unknown = Object.keys(args).filter(key => !READ_OPTION_KEYS.includes(key));
|
|
13
13
|
|
|
14
14
|
if (unknown.length === 0) return;
|
|
15
|
+
|
|
15
16
|
const windowHint = unknown.some(key => key === "start" || key === "end")
|
|
16
17
|
? " For a line window use read(path, {offset:1, limit:80}): offset is the first line and limit is the line count."
|
|
17
18
|
: "";
|
|
@@ -33,11 +34,14 @@ export function gatherReadArgs(p, a, b) {
|
|
|
33
34
|
return args;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Only the string shorthand guesses between a path and a symbol. Explicit
|
|
38
|
+
// path/target objects and path arrays must not turn missing files into search.
|
|
39
|
+
return autoResolve(isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export function assertReadPaths(targetParam) {
|
|
40
43
|
if (!Array.isArray(targetParam)) return;
|
|
44
|
+
|
|
41
45
|
if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
42
46
|
|
|
43
47
|
for (const item of targetParam) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
@@ -49,6 +53,7 @@ function assertReadFlags(args) {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
|
|
56
|
+
|
|
52
57
|
if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -56,20 +61,24 @@ function assertExclusiveRead(args) {
|
|
|
56
61
|
const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
|
|
57
62
|
|
|
58
63
|
if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
|
|
64
|
+
|
|
59
65
|
if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
|
|
66
|
+
|
|
60
67
|
if ((focusModes === 1 || args.evidence === true) && args.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
function autoResolve(args) {
|
|
64
71
|
if (!isString(args.path) || args.resolve !== undefined || args.complete === true || args.json !== undefined) return args;
|
|
65
|
-
|
|
72
|
+
|
|
73
|
+
if (args.about !== undefined || args.query !== undefined || args.offset !== undefined || args.limit !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
|
|
66
74
|
|
|
67
75
|
return { ...args, resolve: true };
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
/**
|
|
78
|
+
/** Preserve explicit intent across guest, host, and coalesced batch normalization. */
|
|
71
79
|
export function normalizeRead(params) {
|
|
72
80
|
if (!isObject(params)) throw new Error("read requires an options object");
|
|
81
|
+
|
|
73
82
|
if (params.path !== undefined && params.target !== undefined && params.path !== params.target) {
|
|
74
83
|
throw new Error("read accepts either path or target, not both");
|
|
75
84
|
}
|
|
@@ -81,7 +90,7 @@ export function normalizeRead(params) {
|
|
|
81
90
|
assertExclusiveRead(args);
|
|
82
91
|
assertReadPaths(args.path);
|
|
83
92
|
|
|
84
|
-
return
|
|
93
|
+
return args;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
export function needsProbe(params) {
|
|
@@ -126,7 +135,7 @@ function classifyEvidence(params, target) {
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
function classifyBarePath(params, target) {
|
|
129
|
-
if (params.json === undefined && !looksLikePath(target)) {
|
|
138
|
+
if (params.resolve === true && params.json === undefined && !looksLikePath(target)) {
|
|
130
139
|
return { kind: "snap", query: isString(params.about) ? params.about : target, scoped: isString(params.about) };
|
|
131
140
|
}
|
|
132
141
|
|
|
@@ -139,9 +148,13 @@ export function classifyRead(params, existing) {
|
|
|
139
148
|
const target = params.path;
|
|
140
149
|
|
|
141
150
|
if (isSessionUri(target)) return classifySession(params);
|
|
151
|
+
|
|
142
152
|
if (params.evidence === true) return classifyEvidence(params, target);
|
|
153
|
+
|
|
143
154
|
if (isString(params.query)) return { kind: "snap", query: params.query, scoped: Boolean(target && target !== params.query) };
|
|
155
|
+
|
|
144
156
|
if (params.outline === true) return { kind: "outline" };
|
|
157
|
+
|
|
145
158
|
if (existing) return classifyExisting(params, existing);
|
|
146
159
|
|
|
147
160
|
return classifyBarePath(params, target);
|
|
@@ -186,5 +199,6 @@ export function decodeReadValue(args, value) {
|
|
|
186
199
|
|
|
187
200
|
function jsonReadError(args, error) {
|
|
188
201
|
const target = String(args.path ?? args.target ?? "resource");
|
|
202
|
+
|
|
189
203
|
return new Error("JSON read failed for " + target + jsonSelectorNote(args) + ": " + errorMessage(error));
|
|
190
204
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { spawnCommand, commandSpawnError } from "./workspace.js";
|
|
2
|
+
import { retireProcessTree } from "./process-tree.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const MAX_RUNNING = 8;
|
|
8
|
+
|
|
9
|
+
const MAX_RETAINED = 32;
|
|
10
|
+
|
|
11
|
+
const OUTPUT_CHARS = 65536;
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function terminalArgv(argv) {
|
|
16
|
+
// The PTY child is a process-group leader. Capture that identity before exec,
|
|
17
|
+
// then close the control FD so commands cannot hold it open or forge messages.
|
|
18
|
+
const command = ["/bin/sh", "-c", 'printf "group:%s\\n" "$$" >&3; /bin/stty cols 80 rows 24 || exit; exec "$@" 3>&-', "supernova-command", ...argv];
|
|
19
|
+
|
|
20
|
+
// Node uses socketpairs for stdio; macOS script needs a real input pipe.
|
|
21
|
+
// Report script's exit separately from the feeder, which may still await input.
|
|
22
|
+
if (process.platform === "darwin") return ["/bin/sh", "-c", '/bin/cat | { "$@"; code=$?; printf "exit:%s\\n" "$code" >&3; }', "supernova-pty", "/usr/bin/script", "-q", "-F", "/dev/null", ...command];
|
|
23
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
24
|
+
|
|
25
|
+
return ["/usr/bin/script", "-q", "-e", "-f", "-c", "exec " + command.map(quote).join(" "), "/dev/null"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function notifyChanged(job) {
|
|
29
|
+
// Advisory invalidation must not prevent resource cleanup or escape an event.
|
|
30
|
+
try { job.changed?.(); } catch {}
|
|
31
|
+
|
|
32
|
+
if (job.cleaned) job.changed = undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wake(job) {
|
|
36
|
+
for (const notify of job.waiters) notify();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendOutput(job, chunk) {
|
|
40
|
+
job.total += chunk.length;
|
|
41
|
+
job.output = (job.output + chunk).slice(-OUTPUT_CHARS);
|
|
42
|
+
wake(job);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshot(job, cursor = 0) {
|
|
46
|
+
if (cursor > job.total) throw new Error("terminal cursor is beyond available output");
|
|
47
|
+
const start = job.total - job.output.length;
|
|
48
|
+
const outputStart = Math.max(start, cursor);
|
|
49
|
+
|
|
50
|
+
const result = {
|
|
51
|
+
sessionId:job.id, pid:job.child.pid, status:job.status, pty:job.pty,
|
|
52
|
+
exitCode:job.exitCode, signal:job.signal, outputStart, cursor:job.total,
|
|
53
|
+
truncated:cursor < start, output:job.output.slice(outputStart-start),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (job.error) result.error = job.error;
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function waitForChange(job, waitMs, signal) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const done = error => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
job.waiters.delete(changed);
|
|
68
|
+
signal?.removeEventListener("abort", aborted);
|
|
69
|
+
|
|
70
|
+
if (error) reject(error); else resolve();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const changed = () => done();
|
|
74
|
+
const aborted = () => done(new Error("terminal poll aborted; job remains running"));
|
|
75
|
+
const timer = setTimeout(changed, waitMs);
|
|
76
|
+
job.waiters.add(changed);
|
|
77
|
+
signal?.addEventListener("abort", aborted, {once:true});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sendInput(job, input, signal) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
|
|
84
|
+
if (job.child.stdin.writableLength > OUTPUT_CHARS) throw new Error("terminal input queue is full; wait before writing more");
|
|
85
|
+
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const done = error => {
|
|
88
|
+
signal?.removeEventListener("abort", aborted);
|
|
89
|
+
|
|
90
|
+
if (error) reject(error); else resolve();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const aborted = () => done(new Error("terminal input aborted; input may have been sent"));
|
|
94
|
+
signal?.addEventListener("abort", aborted, {once:true});
|
|
95
|
+
job.child.stdin.write(input, done);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createBackgroundTerminals() {
|
|
100
|
+
const jobs = new Map();
|
|
101
|
+
let closed = false;
|
|
102
|
+
let generation = 0;
|
|
103
|
+
let closing;
|
|
104
|
+
|
|
105
|
+
function lookup(id, owner) {
|
|
106
|
+
const job = jobs.get(id);
|
|
107
|
+
|
|
108
|
+
if (!job || job.owner !== owner) throw new Error("unknown background terminal session: " + id);
|
|
109
|
+
|
|
110
|
+
return job;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertCapacity(expectedGeneration) {
|
|
114
|
+
if (closed || expectedGeneration !== generation) throw new Error("background terminal session changed or closed; start a new call");
|
|
115
|
+
|
|
116
|
+
if ([...jobs.values()].filter(job=>!job.cleaned).length >= MAX_RUNNING) throw new Error("background terminal limit reached (8 running); stop a session first");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function validateStart(params, expectedGeneration = generation) {
|
|
120
|
+
assertCapacity(expectedGeneration);
|
|
121
|
+
|
|
122
|
+
if (params.pty) {
|
|
123
|
+
if (!["darwin","linux"].includes(process.platform)) throw new Error("PTY background terminals require macOS or Linux; use pty:false for pipes");
|
|
124
|
+
await access("/usr/bin/script", constants.X_OK).catch(()=>{throw new Error("PTY background terminals require executable /usr/bin/script; use pty:false for pipes");});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function terminate(job, status) {
|
|
129
|
+
if (job.stopping) return job.stopping;
|
|
130
|
+
|
|
131
|
+
if (job.cleaned) return;
|
|
132
|
+
job.stopping = (async () => {
|
|
133
|
+
clearTimeout(job.timer);
|
|
134
|
+
await retireProcessTree(job);
|
|
135
|
+
job.cleaned = true;
|
|
136
|
+
job.error = job.processError;
|
|
137
|
+
job.status = job.error ? "failed" : status;
|
|
138
|
+
notifyChanged(job);
|
|
139
|
+
wake(job);
|
|
140
|
+
})().catch(error => {
|
|
141
|
+
job.stopping = null; // A later stop/shutdown must be able to retry cleanup.
|
|
142
|
+
job.status = "failed";
|
|
143
|
+
job.error = "terminal cleanup failed: " + error.message;
|
|
144
|
+
notifyChanged(job);
|
|
145
|
+
wake(job);
|
|
146
|
+
throw error;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return job.stopping;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function start(argv, options) {
|
|
153
|
+
const startedGeneration = options.generation ?? generation;
|
|
154
|
+
await validateStart(options, startedGeneration);
|
|
155
|
+
options.signal?.throwIfAborted();
|
|
156
|
+
// Capacity and lifecycle can change while the PTY capability check awaits.
|
|
157
|
+
assertCapacity(startedGeneration);
|
|
158
|
+
|
|
159
|
+
while (jobs.size >= MAX_RETAINED) {
|
|
160
|
+
const old = [...jobs.values()].find(job=>job.cleaned);
|
|
161
|
+
|
|
162
|
+
if (!old) break;
|
|
163
|
+
jobs.delete(old.id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const command = options.pty ? terminalArgv(argv) : argv;
|
|
167
|
+
|
|
168
|
+
const child = spawnCommand(command, {
|
|
169
|
+
cwd:options.cwd, env:options.pty ? {...options.env,TERM:options.env.TERM || "xterm-256color"} : options.env,
|
|
170
|
+
stdio:options.pty ? ["pipe","pipe","pipe","pipe"] : ["pipe","pipe","pipe"],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const job = {
|
|
174
|
+
id:randomUUID(), owner:options.owner, child, pty:options.pty === true,
|
|
175
|
+
status:"running", output:"", total:0, exitCode:null, signal:null,
|
|
176
|
+
waiters:new Set(), changed:options.changed, groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(), cleaned:false,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
jobs.set(job.id,job);
|
|
180
|
+
child.stdin.on("error",()=>{}); // EPIPE is delivered to each write callback, never uncaught.
|
|
181
|
+
|
|
182
|
+
if (job.pty) {
|
|
183
|
+
let control = "";
|
|
184
|
+
child.stdio[3].setEncoding("utf8");
|
|
185
|
+
child.stdio[3].on("data",chunk=>{
|
|
186
|
+
control += chunk;
|
|
187
|
+
|
|
188
|
+
if (control.length > 128) {
|
|
189
|
+
job.processError = "invalid PTY control message";
|
|
190
|
+
void terminate(job,"failed").catch(()=>{});
|
|
191
|
+
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let newline;
|
|
196
|
+
|
|
197
|
+
while ((newline = control.indexOf("\n")) >= 0) {
|
|
198
|
+
const message = control.slice(0,newline);
|
|
199
|
+
control = control.slice(newline+1);
|
|
200
|
+
|
|
201
|
+
if (/^group:[1-9]\d*$/.test(message)) job.groups.add(Number(message.slice(6)));
|
|
202
|
+
else if (/^exit:\d+$/.test(message)) {
|
|
203
|
+
job.commandExitCode = Number(message.slice(5));
|
|
204
|
+
void terminate(job,"exited").catch(()=>{});
|
|
205
|
+
} else job.processError = "invalid PTY control message";
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
child.stdio[3].on("error",error=>{
|
|
209
|
+
job.processError = error.message;
|
|
210
|
+
void terminate(job,"failed").catch(()=>{});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const stream of [child.stdout,child.stderr]) {
|
|
215
|
+
stream.setEncoding("utf8");
|
|
216
|
+
stream.on("data",chunk=>appendOutput(job,chunk));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
child.once("exit",(code,signal)=>{
|
|
220
|
+
job.processExited = true;
|
|
221
|
+
job.exitCode = job.commandExitCode ?? code;
|
|
222
|
+
job.signal = job.commandExitCode === undefined ? signal : null;
|
|
223
|
+
void terminate(job,"exited").catch(()=>{});
|
|
224
|
+
});
|
|
225
|
+
child.once("close",(code,signal)=>{
|
|
226
|
+
job.processClosed = true;
|
|
227
|
+
job.exitCode = job.commandExitCode ?? code;
|
|
228
|
+
job.signal = job.commandExitCode === undefined ? signal : null;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await new Promise((resolve,reject)=>{
|
|
233
|
+
child.once("spawn",resolve);
|
|
234
|
+
child.once("error",error=>{const mapped=commandSpawnError(error,command[0]);job.error=mapped.message;reject(mapped);});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (options.signal?.aborted || closed || startedGeneration !== generation) {
|
|
238
|
+
await terminate(job,"stopped");
|
|
239
|
+
throw new Error("background terminal start aborted");
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!child.pid || job.cleaned) jobs.delete(job.id);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
job.timer = setTimeout(()=>{void terminate(job,"timed_out").catch(()=>{});},options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
247
|
+
job.timer.unref?.();
|
|
248
|
+
|
|
249
|
+
return snapshot(job);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateControl(params, owner, expectedGeneration = generation) {
|
|
253
|
+
if (expectedGeneration !== generation) throw new Error("background terminal session changed; start a new call");
|
|
254
|
+
|
|
255
|
+
if (params.action === "list") return;
|
|
256
|
+
const job = lookup(params.sessionId,owner);
|
|
257
|
+
|
|
258
|
+
if (params.action === "write" && (job.status !== "running" || job.stopping)) throw new Error("background terminal is not running");
|
|
259
|
+
|
|
260
|
+
if (params.cursor !== undefined && params.cursor > job.total) throw new Error("terminal cursor is beyond available output");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function control(params, owner, signal, expectedGeneration = generation) {
|
|
264
|
+
validateControl(params,owner,expectedGeneration);
|
|
265
|
+
signal?.throwIfAborted();
|
|
266
|
+
|
|
267
|
+
if (params.action === "list") return [...jobs.values()].flatMap(job=>job.owner === owner ? [snapshot(job,job.total)] : []);
|
|
268
|
+
const job = lookup(params.sessionId,owner);
|
|
269
|
+
|
|
270
|
+
if (params.action === "write") await sendInput(job,params.input,signal);
|
|
271
|
+
else if (params.action === "stop") await terminate(job,"stopped");
|
|
272
|
+
else if (job.status === "running" && (params.cursor ?? 0) === job.total && params.waitMs) await waitForChange(job,params.waitMs,signal);
|
|
273
|
+
|
|
274
|
+
// Polls, writes and stops can cross a session shutdown while awaiting I/O.
|
|
275
|
+
// Do not return a result owned by the session that just closed.
|
|
276
|
+
// Polls, writes and stops can cross a session shutdown while awaiting I/O.
|
|
277
|
+
// Do not return a result owned by the session that just closed.
|
|
278
|
+
if (expectedGeneration !== generation) throw new Error("background terminal session changed; start a new call");
|
|
279
|
+
|
|
280
|
+
return snapshot(job,params.cursor);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function shutdown() {
|
|
284
|
+
if (closing) return closing;
|
|
285
|
+
closed = true;
|
|
286
|
+
generation++;
|
|
287
|
+
const retiring = [...jobs.values()];
|
|
288
|
+
closing = Promise.allSettled(retiring.map(job=>terminate(job,"stopped"))).then(results=>{
|
|
289
|
+
for (const job of retiring) if (job.cleaned) jobs.delete(job.id);
|
|
290
|
+
const failure = results.find(result=>result.status === "rejected");
|
|
291
|
+
|
|
292
|
+
if (failure) throw failure.reason;
|
|
293
|
+
}).finally(()=>{closing=undefined;});
|
|
294
|
+
|
|
295
|
+
return closing;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function reopen() {
|
|
299
|
+
if (closing || [...jobs.values()].some(job=>!job.cleaned)) throw new Error("background terminal session cleanup is incomplete");
|
|
300
|
+
closed = false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {start, control, validateStart, validateControl, shutdown, reopen, getGeneration:()=>generation};
|
|
304
|
+
}
|