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.
@@ -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 exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
26
- || stemBase === query.toLowerCase();
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
  }
@@ -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: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
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 = path.relative(root, filePath);
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
 
@@ -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
- if (process.platform === "win32") {
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
  }
@@ -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
- return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
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
- if (args.about !== undefined || args.query !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
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
- /** Exclusive-mode + JSON + auto-resolve. Same rules for guest and host. */
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 autoResolve(args);
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
+ }