@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.1.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.
Files changed (53) hide show
  1. package/README.md +212 -780
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +391 -83
  7. package/dist/server/tools/ai.js +129 -152
  8. package/dist/server/tools/analysis.js +170 -91
  9. package/dist/server/tools/capture.js +128 -55
  10. package/dist/server/tools/compose.js +28 -42
  11. package/dist/server/tools/diagnostics.js +23 -53
  12. package/dist/server/tools/editor.js +63 -72
  13. package/dist/server/tools/generate.js +45 -167
  14. package/dist/server/tools/history.js +35 -66
  15. package/dist/server/tools/playback.js +7 -22
  16. package/dist/server/tools/session.js +41 -47
  17. package/dist/server/tools/storage.js +173 -50
  18. package/dist/server/tools/transform.js +136 -199
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Copies a value into something Node's IPC can carry (#307).
3
+ *
4
+ * Its own module because it has to be testable: `engineChild.ts` imports
5
+ * `@strudel/*`, which is ESM and will not load under this project's
6
+ * CommonJS Jest, so nothing in that file can be reached by a unit test.
7
+ * This has no dependencies at all.
8
+ */
9
+ import { ValidationError } from '../utils/CategorisedError.js';
10
+ /** Bounds on what may cross the IPC boundary. Both are far above any real result. */
11
+ const MAX_TRANSFER_DEPTH = 32;
12
+ const MAX_TRANSFER_NODES = 2_000_000;
13
+ /**
14
+ * A result that cannot cross the IPC boundary: too many values, or
15
+ * nested too deep.
16
+ *
17
+ * Extends `ValidationError` because that is what it is — the caller
18
+ * asked for more than can be returned, and the fix is a narrower query.
19
+ * It reaches the parent as a reconstructed error, so the category also
20
+ * travels as an explicit field; see `engineChild`.
21
+ */
22
+ export class TransferError extends ValidationError {
23
+ }
24
+ /**
25
+ * Copies a result into something Node's IPC can carry.
26
+ *
27
+ * The obvious implementation is `JSON.parse(JSON.stringify(value))`, and
28
+ * it was, until cross-model review (codex) pointed out what that hands to
29
+ * a pattern author: **`JSON.stringify` calls `toJSON` on any value that
30
+ * defines one**. A hap value is pattern-controlled, so
31
+ * `pure({toJSON: () => { while (true); }})` runs an infinite loop here —
32
+ * outside the vm's timeout, which has already returned. The parent's
33
+ * deadline still fires and still kills the child, so the server survives;
34
+ * what does not survive is the child if the PARENT is killed first, since
35
+ * a blocked event loop can never process `disconnect`. That is an orphan
36
+ * process with no owner.
37
+ *
38
+ * So: walk it by hand. Never invoke `toJSON`, never invoke a getter on a
39
+ * prototype, drop what cannot cross, and bound both depth and node count
40
+ * so a pathological structure fails loudly instead of silently eating the
41
+ * heap the cap is there to protect.
42
+ */
43
+ export function toTransferable(value, depth = 0, budget = { nodes: 0 }) {
44
+ if (++budget.nodes > MAX_TRANSFER_NODES) {
45
+ throw new TransferError(`Result exceeds the ${String(MAX_TRANSFER_NODES)}-value transfer limit. ` +
46
+ 'Narrow the query or simplify the pattern.');
47
+ }
48
+ if (depth > MAX_TRANSFER_DEPTH) {
49
+ throw new TransferError(`Result nests deeper than ${String(MAX_TRANSFER_DEPTH)} levels and cannot be returned.`);
50
+ }
51
+ if (value === null || value === undefined)
52
+ return null;
53
+ switch (typeof value) {
54
+ case 'string':
55
+ case 'boolean':
56
+ return value;
57
+ case 'number':
58
+ // NaN and Infinity are not JSON; null is what JSON.stringify
59
+ // produced for them too, so this keeps the wire shape unchanged.
60
+ return Number.isFinite(value) ? value : null;
61
+ case 'bigint':
62
+ return value.toString();
63
+ // Functions and symbols have no representation on the other side, and
64
+ // a pattern that produced one was not going to be read anyway.
65
+ case 'function':
66
+ case 'symbol':
67
+ return null;
68
+ default:
69
+ break;
70
+ }
71
+ if (Array.isArray(value)) {
72
+ // Index loop, not `.map`. `map` is looked up on the value, and an
73
+ // array's own `map` property is writable: `const a = []; a.map = ...`
74
+ // is an ordinary assignment, so calling it would run pattern-supplied
75
+ // code in the child — the same class of hole as `toJSON`, one method
76
+ // over. Descriptors for the same reason as below.
77
+ const out = [];
78
+ for (let i = 0; i < value.length; i++) {
79
+ // Charged before the descriptor is even looked at. A hole and a
80
+ // getter both used to `continue` without recursing, and the node
81
+ // budget is only charged inside `toTransferable` — so a sparse
82
+ // array cost nothing against it. `const a = []; a.length = 5e7`
83
+ // is cheap to build and walked all 50 million holes, pushing a
84
+ // null for each, past a 2-million-node bound whose whole purpose
85
+ // is to refuse exactly that (#408).
86
+ if (++budget.nodes > MAX_TRANSFER_NODES) {
87
+ throw new TransferError(`Result exceeds the ${String(MAX_TRANSFER_NODES)}-value transfer limit. ` +
88
+ 'Narrow the query or simplify the pattern.');
89
+ }
90
+ const descriptor = Object.getOwnPropertyDescriptor(value, i);
91
+ if (descriptor === undefined) {
92
+ out.push(null);
93
+ continue;
94
+ }
95
+ if (descriptor.get !== undefined) {
96
+ out.push(null);
97
+ continue;
98
+ }
99
+ out.push(toTransferable(descriptor.value, depth + 1, budget));
100
+ }
101
+ return out;
102
+ }
103
+ // Own enumerable properties only. Inherited accessors are pattern-
104
+ // controlled code too, and nothing legitimate arrives on a prototype.
105
+ const out = {};
106
+ for (const key of Object.keys(value)) {
107
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
108
+ // A getter is a function call. Skip it rather than run it.
109
+ if (descriptor === undefined || descriptor.get !== undefined)
110
+ continue;
111
+ out[key] = toTransferable(descriptor.value, depth + 1, budget);
112
+ }
113
+ return out;
114
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * AiTransport — the one thing that varies between AI providers.
3
+ *
4
+ * `GeminiService` grew ~950 lines around a single provider, but almost
5
+ * none of it is provider-specific: prompt building, JSON parsing, caching,
6
+ * rate limiting and the response contracts are all provider-agnostic. The
7
+ * genuinely variable part is one operation — get a prompt to a model and
8
+ * get text back.
9
+ *
10
+ * So that is the whole seam:
11
+ *
12
+ * type AiTransport = (prompt: string) => Promise<string>
13
+ *
14
+ * Two implementations satisfy it: an HTTP call to the Gemini API, and a
15
+ * subprocess call to a locally authenticated CLI (`agy`, `claude`,
16
+ * `codex`). Everything above the seam stays exactly where it is, which is
17
+ * also why the ~80 existing tests keep working.
18
+ *
19
+ * ## Why there is no audio attachment parameter
20
+ *
21
+ * There was going to be one. It turned out none of the installed CLIs can
22
+ * actually hear audio — verified by asking each directly:
23
+ *
24
+ * agy -> "I CANNOT PERCEIVE AUDIO DIRECTLY"
25
+ * codex -> "CANNOT DECODE AUDIO"
26
+ * claude -> "CANNOT DECODE AUDIO"
27
+ *
28
+ * `agy` is the dangerous one: as an agentic CLI it will sometimes write
29
+ * and run DSP code, producing exactly correct numbers, and sometimes just
30
+ * describe plausible-sounding music it never analysed. A differential
31
+ * probe caught it reporting a clipped file as clean and a clean file as
32
+ * clipped. Right often enough to trust, wrong unpredictably, is the worst
33
+ * property an automated feedback loop can have.
34
+ *
35
+ * The answer is to not ask a model to hear. We already measure the audio
36
+ * locally (`AudioAnalyzer` for FFT/tempo/key, `AudioExportService` for
37
+ * peak/RMS) and send the *numbers* as text. That is deterministic, and it
38
+ * works with every provider rather than one — so the transport only ever
39
+ * needs to carry text.
40
+ *
41
+ * @module services/ai/AiTransport
42
+ */
43
+ /** Raised when a transport cannot authenticate. */
44
+ export class AiAuthError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = 'AiAuthError';
48
+ }
49
+ }
50
+ /** Raised when a transport is rate limited. */
51
+ export class AiRateLimitError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'AiRateLimitError';
55
+ }
56
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * AudioMeasurements — describing audio to a model that cannot hear it.
3
+ *
4
+ * The obvious design was to hand a WAV to a multimodal model. That does
5
+ * not work here: none of the installed CLIs can decode audio. Asked
6
+ * directly, they say so —
7
+ *
8
+ * agy -> "I CANNOT PERCEIVE AUDIO DIRECTLY"
9
+ * codex -> "CANNOT DECODE AUDIO"
10
+ * claude -> "CANNOT DECODE AUDIO"
11
+ *
12
+ * `agy` is the dangerous one. Being agentic, it sometimes writes and runs
13
+ * DSP code and returns exactly correct figures, and sometimes just
14
+ * describes plausible music it never analysed. A differential probe
15
+ * caught it calling a clipped file clean and a clean file clipped. Right
16
+ * often enough to trust, wrong unpredictably, is the worst possible
17
+ * property for an automated feedback loop.
18
+ *
19
+ * So we do not ask a model to listen. We measure the audio ourselves —
20
+ * `AudioExportService` already computes peak and RMS while decoding, and
21
+ * `AudioAnalyzer` computes FFT bands, spectral centroid, tempo and key —
22
+ * and send the numbers as text.
23
+ *
24
+ * That inverts the constraint into an advantage. Measurement is
25
+ * deterministic and reproducible, the model does the part it is actually
26
+ * good at (interpretation and musical judgement), and it works with every
27
+ * provider rather than one.
28
+ *
29
+ * @module services/ai/AudioMeasurements
30
+ */
31
+ /** Converts a linear amplitude to dBFS, the unit mixing engineers use. */
32
+ /**
33
+ * Amplitude at or below which a capture is treated as silent.
34
+ *
35
+ * Not exactly zero: dithering, a DC offset, or a single stray sample
36
+ * can leave a capture technically non-zero while containing no audio.
37
+ */
38
+ export const SILENCE_THRESHOLD = 1e-4;
39
+ export function toDbfs(amplitude) {
40
+ if (amplitude <= 0)
41
+ return -Infinity;
42
+ return 20 * Math.log10(amplitude);
43
+ }
44
+ /**
45
+ * Renders measurements as text a model can reason about.
46
+ *
47
+ * Deliberately states units and interpretation rather than dumping raw
48
+ * numbers: "peak 1.12 (CLIPPING)" gets a useful answer where "peak: 1.12"
49
+ * invites the model to guess what scale that is on.
50
+ *
51
+ * @param m - Locally computed measurements
52
+ * @returns Human- and model-readable measurement block
53
+ */
54
+ export function describeMeasurements(m) {
55
+ const lines = [];
56
+ lines.push(`- Duration: ${String(Math.round(m.durationMs))} ms`);
57
+ if (m.sampleRate !== undefined) {
58
+ lines.push(`- Format: ${String(m.sampleRate)} Hz, ${String(m.channels ?? 2)} channel(s)`);
59
+ }
60
+ // Silence first, and loudly.
61
+ //
62
+ // A silent capture was described as "Infinity dB of headroom", which
63
+ // is arithmetically true and useless: toDbfs(0) is -Infinity, and the
64
+ // verdict ladder had a branch for clipping and one for low headroom
65
+ // but none for nothing-at-all. buildMeasurementPrompt then wraps this
66
+ // in "these measurements are accurate — treat them as ground truth"
67
+ // and asks the model for mood, style and energy. Silence is THE
68
+ // documented failure mode of this project's headless capture, and the
69
+ // model was given no signal it had happened (#343).
70
+ const isSilent = m.peak !== undefined && m.peak <= SILENCE_THRESHOLD
71
+ && (m.rms === undefined || m.rms <= SILENCE_THRESHOLD);
72
+ if (isSilent) {
73
+ lines.push('- SILENT: nothing was recorded. Peak and RMS are both at or below ' +
74
+ `${String(SILENCE_THRESHOLD)}. Do not infer mood, style or energy from this — ` +
75
+ 'there is no audio to describe. The capture failed, or the pattern was not playing.');
76
+ }
77
+ if (m.peak !== undefined) {
78
+ const db = toDbfs(m.peak);
79
+ const verdict = m.peak > 1
80
+ ? 'CLIPPING — above full scale, so the mix is distorting'
81
+ : m.peak > 0.95
82
+ ? 'very close to full scale, little headroom'
83
+ : m.peak <= SILENCE_THRESHOLD
84
+ ? 'silence — no signal at all'
85
+ : `${(-db).toFixed(1)} dB of headroom`;
86
+ lines.push(`- Peak: ${m.peak.toFixed(4)} (${db.toFixed(2)} dBFS) — ${verdict}`);
87
+ }
88
+ if (m.rms !== undefined) {
89
+ lines.push(`- RMS: ${m.rms.toFixed(4)} (${toDbfs(m.rms).toFixed(2)} dBFS) — perceived loudness`);
90
+ }
91
+ if (m.peak !== undefined && m.rms !== undefined && m.rms > 0) {
92
+ const crest = toDbfs(m.peak / m.rms);
93
+ lines.push(`- Crest factor: ${crest.toFixed(1)} dB — ` +
94
+ (crest < 6 ? 'heavily compressed / dense' : crest > 15 ? 'very dynamic / sparse' : 'moderate dynamics'));
95
+ }
96
+ if (m.tempo !== undefined) {
97
+ lines.push(`- Detected tempo: ${String(Math.round(m.tempo.bpm))} BPM ` +
98
+ `(confidence ${m.tempo.confidence.toFixed(2)})`);
99
+ }
100
+ if (m.key !== undefined) {
101
+ lines.push(`- Detected key: ${m.key.key} ${m.key.scale} ` +
102
+ `(confidence ${m.key.confidence.toFixed(2)})`);
103
+ }
104
+ if (m.spectrum !== undefined) {
105
+ const s = m.spectrum;
106
+ lines.push(`- Spectrum (0-255): bass ${String(Math.round(s.bass))}, ` +
107
+ `low-mid ${String(Math.round(s.lowMid))}, mid ${String(Math.round(s.mid))}, ` +
108
+ `high-mid ${String(Math.round(s.highMid))}, treble ${String(Math.round(s.treble))}`);
109
+ lines.push(`- Spectral centroid: ${String(Math.round(s.centroid))} Hz (${s.brightness})`);
110
+ }
111
+ if (m.rhythm !== undefined) {
112
+ lines.push(`- Rhythm: complexity ${m.rhythm.complexity.toFixed(2)}, ` +
113
+ `density ${m.rhythm.density.toFixed(2)}, syncopation ${m.rhythm.syncopation.toFixed(2)}`);
114
+ }
115
+ return lines.join('\n');
116
+ }
117
+ /**
118
+ * Builds the audio-feedback prompt from measurements plus the pattern.
119
+ *
120
+ * The pattern source matters as much as the numbers: it lets the model
121
+ * connect a measurement to the line that caused it, which is the
122
+ * difference between "the mix is clipping" and "these three layers sum
123
+ * above 1.0, lower them".
124
+ *
125
+ * @param m - Locally computed measurements
126
+ * @param pattern - The Strudel source that produced the audio
127
+ * @param context - Optional intent (style/bpm/key the user was aiming for)
128
+ * @returns Prompt text for any model
129
+ */
130
+ export function buildMeasurementPrompt(m, pattern, context) {
131
+ const parts = [
132
+ 'You are analysing a short recording of a live-coded music performance made with Strudel.',
133
+ '',
134
+ 'These measurements were computed directly from the audio signal. They are',
135
+ 'accurate — treat them as ground truth rather than re-estimating them.',
136
+ '',
137
+ describeMeasurements(m),
138
+ ];
139
+ if (pattern !== undefined && pattern.trim().length > 0) {
140
+ parts.push('', 'The Strudel pattern that produced it:', '```javascript', pattern.trim(), '```');
141
+ }
142
+ if (context !== undefined) {
143
+ const intent = [];
144
+ if (context.style !== undefined)
145
+ intent.push(`intended style: ${context.style}`);
146
+ if (context.bpm !== undefined)
147
+ intent.push(`intended BPM: ${String(context.bpm)}`);
148
+ if (context.key !== undefined)
149
+ intent.push(`intended key: ${context.key}`);
150
+ if (intent.length > 0)
151
+ parts.push('', `Intent — ${intent.join(', ')}.`);
152
+ }
153
+ parts.push('', 'Respond with ONLY this JSON object and no other text:', '{', ' "mood": "<one word, e.g. energetic, hypnotic, melancholic>",', ' "style": "<musical genre you infer>",', ' "energy": "<low|medium|high>",', ' "suggestions": ["<specific, actionable change>", "<another>", "<another>"],', ' "confidence": <0.0-1.0>', '}', '', 'Ground every suggestion in a measurement or a line of the pattern. If the', 'audio is clipping, say so first and name the layers responsible. Do not', 'claim to have heard timbral detail the measurements do not support.');
154
+ return parts.join('\n');
155
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * CliTransport — reaches a model through a locally authenticated CLI.
3
+ *
4
+ * Fixes the case where a machine has working AI credentials that
5
+ * `GeminiService` cannot see (#252). Its auth ladder only understands
6
+ * `GEMINI_API_KEY`, `~/.gemini/settings.json`, and gcloud ADC — but the
7
+ * Antigravity CLI (`agy`) keeps credentials elsewhere entirely, so
8
+ * `ai_assist` reported "Gemini API not configured" while Gemini worked
9
+ * fine two directories away.
10
+ *
11
+ * Each CLI is invoked in print/non-interactive mode. The flags are not
12
+ * guessable and were established by probing the installed binaries:
13
+ *
14
+ * agy --print='PROMPT' (must be the `=` form; a bare
15
+ * positional gets eaten by --add-dir)
16
+ * claude -p 'PROMPT' (fastest, ~3s)
17
+ * codex exec --sandbox read-only --skip-git-repo-check 'PROMPT'
18
+ * (reads stdin when given no prompt
19
+ * argument, so stdin must be closed
20
+ * or it hangs forever)
21
+ *
22
+ * @module services/ai/CliTransport
23
+ * @nist si-10 "Information input validation"
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import { accessSync, constants, statSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { AiAuthError } from './AiTransport.js';
29
+ /** Default budget for a CLI call. They are slower than an HTTP request. */
30
+ export const CLI_TIMEOUT_MS = 120_000;
31
+ /** Cap on captured output, so a runaway CLI cannot exhaust memory. */
32
+ export const CLI_MAX_BUFFER = 8 * 1024 * 1024;
33
+ /** Time allowed for buffered output to drain after the child exits. */
34
+ export const DRAIN_GRACE_MS = 50;
35
+ /**
36
+ * Environment handed to the CLI.
37
+ *
38
+ * These are agentic CLIs with filesystem and tool access, and the prompt
39
+ * carries pattern text that may have come from a stored file or an
40
+ * imported MIDI. Passing the full parent environment would hand any
41
+ * provider credential in it to a different vendor's agent, so the child
42
+ * gets only what it needs to find its own config.
43
+ */
44
+ function childEnv() {
45
+ const keep = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME'];
46
+ const env = {};
47
+ for (const key of keep) {
48
+ const value = process.env[key];
49
+ if (value !== undefined)
50
+ env[key] = value;
51
+ }
52
+ return env;
53
+ }
54
+ /**
55
+ * The CLIs this server knows how to drive, in preference order.
56
+ *
57
+ * Order is by measured latency and output cleanliness: claude ~3s with
58
+ * the tidiest output, codex ~5s, agy ~2.4s bare but slower with files.
59
+ * `gemini` is deliberately absent — its free tier was retired and it
60
+ * fails with `IneligibleTierError` before reaching a model, so listing it
61
+ * would only produce confusing failures.
62
+ */
63
+ export const KNOWN_CLIS = [
64
+ {
65
+ bin: 'claude',
66
+ label: 'Claude CLI',
67
+ // The prompt must precede any variadic flag: --disallowedTools and
68
+ // friends will swallow a trailing positional and tokenize it into
69
+ // bogus deny rules.
70
+ args: prompt => ['-p', prompt],
71
+ },
72
+ {
73
+ bin: 'agy',
74
+ label: 'Antigravity CLI',
75
+ // `--print=` rather than `--print `: the space form lets a following
76
+ // flag be taken as the prompt.
77
+ args: prompt => [`--print=${prompt}`],
78
+ },
79
+ {
80
+ bin: 'codex',
81
+ label: 'Codex CLI',
82
+ args: prompt => ['exec', '--sandbox', 'read-only', '--skip-git-repo-check', prompt],
83
+ needsClosedStdin: true,
84
+ },
85
+ ];
86
+ /** Resolves a binary on PATH by scanning it — no shell, no subprocess. */
87
+ function isOnPath(bin) {
88
+ for (const dir of (process.env.PATH ?? '').split(':')) {
89
+ if (dir === '')
90
+ continue;
91
+ try {
92
+ const candidate = join(dir, bin);
93
+ accessSync(candidate, constants.X_OK);
94
+ // Directories are mode 755, so X_OK alone passes for them. Without
95
+ // this check a directory named `claude` on PATH makes the gate
96
+ // promise AI is configured, and spawn then fails.
97
+ if (statSync(candidate).isFile())
98
+ return true;
99
+ }
100
+ catch {
101
+ // not here; keep looking
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+ /**
107
+ * Runs a CLI with stdin closed and its output captured.
108
+ *
109
+ * `spawn` rather than `execFile` because execFile ignores the `stdio`
110
+ * option: codex reads stdin when it gets no prompt argument, so without
111
+ * an explicitly closed stdin it blocks until the timeout kills it —
112
+ * measured, it hung for the full 90s budget.
113
+ */
114
+ function runCli(bin, args, timeoutMs) {
115
+ return new Promise(resolve => {
116
+ // 'ignore' on stdin is the whole point; never a shell, so prompt text
117
+ // cannot be reinterpreted as shell syntax.
118
+ // detached: the CLI is agentic and spawns its own tool subprocesses.
119
+ // Killing only the direct child orphans those, and an orphan holding
120
+ // the inherited stdout fd keeps 'close' from ever firing — measured,
121
+ // a child SIGKILLed at 500ms did not emit 'close' until 30,002ms.
122
+ // Its own process group lets us kill the whole tree.
123
+ const child = spawn(bin, args, {
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ shell: false,
126
+ detached: true,
127
+ env: childEnv(),
128
+ });
129
+ let stdout = '';
130
+ let stderr = '';
131
+ let timedOut = false;
132
+ let bytes = 0;
133
+ let settled = false;
134
+ // Decode as a stream: a multi-byte character split across a chunk
135
+ // boundary becomes U+FFFD if each chunk is toString()'d alone, and
136
+ // model output is full of curly quotes and accents.
137
+ child.stdout.setEncoding('utf8');
138
+ child.stderr.setEncoding('utf8');
139
+ const killTree = (signal) => {
140
+ try {
141
+ if (child.pid !== undefined)
142
+ process.kill(-child.pid, signal);
143
+ }
144
+ catch {
145
+ // group already gone
146
+ }
147
+ try {
148
+ child.kill(signal);
149
+ }
150
+ catch { /* already dead */ }
151
+ };
152
+ const timer = setTimeout(() => {
153
+ timedOut = true;
154
+ killTree('SIGKILL');
155
+ }, timeoutMs);
156
+ let overflowed = false;
157
+ const capture = (chunk, onto) => {
158
+ bytes += chunk.length;
159
+ if (bytes > CLI_MAX_BUFFER) {
160
+ // Say why, rather than surfacing a bare `exited null` with empty
161
+ // stderr that reads like an external kill.
162
+ overflowed = true;
163
+ killTree('SIGKILL');
164
+ return;
165
+ }
166
+ if (onto === 'out')
167
+ stdout += chunk;
168
+ else
169
+ stderr += chunk;
170
+ };
171
+ const settle = (code, errMessage) => {
172
+ if (settled)
173
+ return;
174
+ settled = true;
175
+ clearTimeout(timer);
176
+ // Release the pipes.
177
+ //
178
+ // Settling on 'exit' rather than 'close' is deliberate (see below),
179
+ // but it means the child's stdout and stderr can still be open when
180
+ // this resolves — and an open pipe is an open handle. Under Jest
181
+ // that surfaces as "a worker process has failed to exit gracefully"
182
+ // on any parallel run large enough to reuse a worker: measured, 18
183
+ // suites plus this one warns where 18 plus any other suite does not
184
+ // (#362). Nothing needs the streams after this point; everything
185
+ // they carried is already in `stdout`/`stderr`.
186
+ child.stdout.destroy();
187
+ child.stderr.destroy();
188
+ resolve({
189
+ stdout,
190
+ stderr: overflowed
191
+ ? `output exceeded ${String(CLI_MAX_BUFFER)} bytes and was terminated`
192
+ : errMessage ?? stderr,
193
+ code,
194
+ timedOut,
195
+ });
196
+ };
197
+ child.stdout.on('data', (c) => { capture(c, 'out'); });
198
+ child.stderr.on('data', (c) => { capture(c, 'err'); });
199
+ child.on('error', (err) => { settle(null, err.message); });
200
+ // Settle on 'exit', not 'close'. 'close' waits for every inherited
201
+ // stdio pipe to close, which an orphaned grandchild can hold open
202
+ // indefinitely — that is the hang above. A short grace lets buffered
203
+ // output drain first, so nothing is truncated in the normal case.
204
+ child.on('exit', (code) => {
205
+ setTimeout(() => { settle(code); }, DRAIN_GRACE_MS);
206
+ });
207
+ });
208
+ }
209
+ /**
210
+ * Builds a transport entry for one CLI.
211
+ *
212
+ * The prompt is passed as an argument-vector element, never interpolated
213
+ * into a shell string, so prompt content cannot become shell syntax.
214
+ *
215
+ * @param spec - How to drive the CLI
216
+ * @param timeoutMs - Budget for a single invocation
217
+ * @returns A transport entry usable by the AI service
218
+ * @nist si-10 "Information input validation"
219
+ */
220
+ export function createCliTransport(spec, timeoutMs = CLI_TIMEOUT_MS) {
221
+ return {
222
+ id: `cli:${spec.bin}`,
223
+ label: spec.label,
224
+ isAvailable: () => Promise.resolve(isOnPath(spec.bin)),
225
+ send: async (prompt) => {
226
+ const { stdout, stderr, code, timedOut } = await runCli(spec.bin, spec.args(prompt), timeoutMs);
227
+ if (timedOut) {
228
+ throw new Error(`${spec.label} timed out after ${String(timeoutMs)}ms.`);
229
+ }
230
+ const detail = stderr.trim().slice(0, 400);
231
+ if (code !== 0) {
232
+ if (/ENOENT|not found/i.test(detail)) {
233
+ throw new AiAuthError(`${spec.label} (${spec.bin}) is not installed.`);
234
+ }
235
+ if (/auth|login|credential|unauthor|tier|eligib/i.test(detail)) {
236
+ throw new AiAuthError(`${spec.label} is not authenticated: ${detail}`);
237
+ }
238
+ throw new Error(`${spec.label} exited ${String(code)}: ${detail}`);
239
+ }
240
+ return stdout.trim();
241
+ },
242
+ };
243
+ }
244
+ /**
245
+ * Whether any known CLI is installed — synchronously.
246
+ *
247
+ * A PATH scan, not a subprocess, so availability gates stay synchronous
248
+ * and callers (and their ~80 existing mocks) do not have to become async.
249
+ */
250
+ export function hasCliTransport() {
251
+ return KNOWN_CLIS.some(spec => isOnPath(spec.bin));
252
+ }
253
+ /** Transport entries for every CLI this server knows how to drive. */
254
+ export function cliTransports(timeoutMs = CLI_TIMEOUT_MS) {
255
+ return KNOWN_CLIS.map(spec => createCliTransport(spec, timeoutMs));
256
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Child entrypoint for the isolated local pattern engine (#307).
3
+ *
4
+ * Forked by `IsolatedEngineRunner` with `--max-old-space-size`. Owns one
5
+ * `StrudelEngine`, answers RPC over IPC, and is expected to die from time
6
+ * to time — that is the design, not a fault. Nothing here should try to
7
+ * survive a bad pattern; the parent handles death and respawns.
8
+ *
9
+ * This file is deliberately the only place that imports `StrudelEngine`
10
+ * into the isolated process, so the parent never loads `@strudel/*` at all.
11
+ */
12
+ import { StrudelEngine } from './StrudelEngine.js';
13
+ import { ISOLATED_METHODS } from './LocalPatternEngine.js';
14
+ import { toTransferable } from './Transferable.js';
15
+ import { BusinessError, TransientError, ValidationError } from '../utils/CategorisedError.js';
16
+ const engine = new StrudelEngine();
17
+ /**
18
+ * Object-literal method shorthand, and `Object.hasOwn` on the way in.
19
+ * A plain `HANDLERS[method]` would happily resolve `constructor` or
20
+ * `toString` off the prototype chain and call it with attacker-chosen
21
+ * arguments — the same class of bug this repo has now found nine times.
22
+ */
23
+ const HANDLERS = {
24
+ transpile(code) {
25
+ return engine.transpile(code);
26
+ },
27
+ validate(code) {
28
+ return engine.validate(code);
29
+ },
30
+ analyzePattern(code) {
31
+ return engine.analyzePattern(code);
32
+ },
33
+ queryEvents(code, start, end) {
34
+ return engine.queryEvents(code, start, end);
35
+ },
36
+ };
37
+ /** The category an error carries, when it is one of ours. */
38
+ function categoryOf(error) {
39
+ if (error instanceof ValidationError)
40
+ return 'validation';
41
+ if (error instanceof BusinessError)
42
+ return 'business';
43
+ if (error instanceof TransientError)
44
+ return 'transient';
45
+ return undefined;
46
+ }
47
+ process.on('message', (raw) => {
48
+ const request = raw;
49
+ if (!request || typeof request.id !== 'number')
50
+ return;
51
+ const send = (payload) => {
52
+ process.send?.({ id: request.id, ...payload });
53
+ };
54
+ if (!ISOLATED_METHODS.includes(request.method) || !Object.hasOwn(HANDLERS, request.method)) {
55
+ send({ ok: false, error: { name: 'TypeError', message: `Unknown engine method: ${String(request.method)}` } });
56
+ return;
57
+ }
58
+ try {
59
+ const handler = HANDLERS[request.method];
60
+ const result = handler(...(request.args ?? []));
61
+ send({ ok: true, result: toTransferable(result) });
62
+ }
63
+ catch (error) {
64
+ send({
65
+ ok: false,
66
+ error: {
67
+ name: error instanceof Error ? error.name : 'Error',
68
+ message: error instanceof Error ? error.message : String(error),
69
+ // The class cannot cross IPC — the parent rebuilds a plain Error
70
+ // from this payload — so the verdict travels as a field. Without
71
+ // it, "Result exceeds the 2000000-value transfer limit. Narrow
72
+ // the query" arrived uncategorised and was reported as an
73
+ // internal failure not worth retrying (#382).
74
+ category: categoryOf(error),
75
+ },
76
+ });
77
+ }
78
+ });
79
+ // If the parent goes away, so do we. A forked engine outliving its server
80
+ // is a leaked process, not a feature.
81
+ process.on('disconnect', () => {
82
+ process.exit(0);
83
+ });