@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
@@ -8,11 +8,13 @@ import { PatternGenerator } from '../services/PatternGenerator.js';
8
8
  import { GeminiService } from '../services/GeminiService.js';
9
9
  import { AudioCaptureService } from '../services/AudioCaptureService.js';
10
10
  import { MIDIExportService } from '../services/MIDIExportService.js';
11
+ import { AudioExportService } from '../services/AudioExportService.js';
12
+ import { MIDIImportService } from '../services/MIDIImportService.js';
11
13
  import { SessionManager } from '../services/SessionManager.js';
12
14
  import { readFileSync, existsSync } from 'fs';
13
15
  import { Logger } from '../utils/Logger.js';
14
16
  import { PerformanceMonitor } from '../utils/PerformanceMonitor.js';
15
- import { StrudelEngine } from '../services/StrudelEngine.js';
17
+ import { IsolatedStrudelEngine } from '../services/IsolatedStrudelEngine.js';
16
18
  import { diagnosticsModule } from './tools/diagnostics.js';
17
19
  import { playbackModule } from './tools/playback.js';
18
20
  import { storageModule } from './tools/storage.js';
@@ -25,13 +27,47 @@ import { sessionModule } from './tools/session.js';
25
27
  import { captureModule } from './tools/capture.js';
26
28
  import { aiModule } from './tools/ai.js';
27
29
  import { composeModule } from './tools/compose.js';
28
- import { categorizeError, err, isEnvelope, ok } from './tools/types.js';
30
+ import { categorizeError, err, isEnvelope, ok, isFailureShaped, PATTERN_STASHED_PREFIX } from './tools/types.js';
29
31
  import { readResource, resources as mcpResources } from './resources.js';
30
- import { join } from 'node:path';
32
+ import { join, resolve } from 'node:path';
33
+ import { parseServerConfig } from '../utils/ServerConfig.js';
34
+ import { BusinessError } from '../utils/CategorisedError.js';
31
35
  const configPath = './config.json';
32
- const config = existsSync(configPath)
33
- ? JSON.parse(readFileSync(configPath, 'utf-8'))
34
- : { headless: false };
36
+ /**
37
+ * Parsed once at module load. parseServerConfig never throws — a bad
38
+ * value falls back to its default and records a warning — so a typo in
39
+ * config.json cannot stop the server starting. It also warns about keys
40
+ * nothing reads, which is what stops the next `strudel_url` from being
41
+ * silently ignored for two releases (#227).
42
+ */
43
+ let configFound = false;
44
+ function loadConfig() {
45
+ configFound = existsSync(configPath);
46
+ // No warning for an absent file — running without config.json is the
47
+ // normal case and warning about it every start would be crying wolf.
48
+ // The path checked is reported through `diagnostics` instead, so a
49
+ // user whose client launches the server from another cwd can SEE that
50
+ // their file was not the one read (#442).
51
+ if (!configFound)
52
+ return parseServerConfig(undefined);
53
+ try {
54
+ return parseServerConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
55
+ }
56
+ catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ const parsed = parseServerConfig(undefined);
59
+ parsed.warnings.push(`config.json could not be parsed (${message}); using defaults`);
60
+ return parsed;
61
+ }
62
+ }
63
+ /**
64
+ * How long shutdown may take before the process exits anyway. Browser
65
+ * cleanup talks to a wedged Chromium over CDP often enough that "wait
66
+ * forever" is not an option (#307).
67
+ */
68
+ const SHUTDOWN_GRACE_MS = 5000;
69
+ const config = loadConfig();
70
+ const audioAnalysisConfig = config.audioAnalysis;
35
71
  export class StrudelMCPServer {
36
72
  server;
37
73
  controller;
@@ -46,12 +82,15 @@ export class StrudelMCPServer {
46
82
  * sessions no longer conflict.
47
83
  */
48
84
  audioCaptureServices = new Map();
85
+ /** In-flight recorder injections, so concurrent calls share one (#423). */
86
+ captureServiceBuilds = new Map();
49
87
  midiExportService;
88
+ audioExportService;
89
+ midiImportService;
50
90
  sessionManager;
51
91
  logger;
52
92
  perfMonitor;
53
93
  strudelEngine;
54
- sessionHistory = [];
55
94
  /**
56
95
  * Per-session undo/redo/history bundles (#179). Keyed by session id;
57
96
  * 'default' for the legacy/single-session path. Bundles are lazily
@@ -63,27 +102,60 @@ export class StrudelMCPServer {
63
102
  /** Maximum history entries per session to prevent memory leaks */
64
103
  MAX_HISTORY = 100;
65
104
  isInitialized = false;
66
- generatedPatterns = new Map();
105
+ /**
106
+ * A pattern produced before a browser existed, held for the next init.
107
+ *
108
+ * One slot, not a map. Both readers only ever took the last value, so
109
+ * the map's other entries were pure growth — nothing deleted from it,
110
+ * up to 10KB each, for the life of the process. Its keys were also
111
+ * `pattern_${Date.now()}`, so two stashes in the same millisecond
112
+ * collided and one was silently dropped (#262).
113
+ */
114
+ pendingPattern = null;
115
+ /** In-flight auto-init, so concurrent callers share one browser launch. */
116
+ initPromise = null;
67
117
  constructor() {
68
118
  this.server = new Server({
69
119
  name: 'live-coding-music-mcp',
70
- version: '3.0.0',
120
+ version: '4.0.0',
71
121
  }, {
72
122
  capabilities: {
73
123
  tools: {},
74
124
  resources: {},
75
125
  },
76
126
  });
77
- this.controller = new StrudelController(config.headless);
78
- this.store = new PatternStore('./patterns');
127
+ this.controller = new StrudelController(config.headless, audioAnalysisConfig, config.strudelUrl, undefined, { viewport: config.viewport, blockResources: config.blockResources });
128
+ this.store = new PatternStore(config.patternsDir);
79
129
  this.theory = new MusicTheory();
80
130
  this.generator = new PatternGenerator();
81
131
  this.geminiService = new GeminiService();
82
- this.midiExportService = new MIDIExportService();
83
- this.sessionManager = new SessionManager(config.headless);
132
+ // Exports are confined to this directory (#224); `exports_dir` in
133
+ // config.json relocates it.
134
+ this.midiExportService = new MIDIExportService(config.exportsDir);
135
+ // Shares the export directory and sanitizer with MIDI export (#223, #224).
136
+ this.audioExportService = new AudioExportService(config.exportsDir);
137
+ this.midiImportService = new MIDIImportService();
138
+ this.sessionManager = new SessionManager(config.headless, audioAnalysisConfig, config.strudelUrl, { viewport: config.viewport, blockResources: config.blockResources });
139
+ // Covers every teardown path — the destroy tool, idle eviction, and
140
+ // destroyAll — rather than only the one the tool handler knew about.
141
+ // destroyAll fired none of these until #423; the claim above this
142
+ // line predated the code by some months.
143
+ this.sessionManager.onSessionDestroyed = (id) => {
144
+ this.historyBundles.delete(id);
145
+ this.audioCaptureServices.delete(id);
146
+ };
147
+ // The sweep cannot see a capture in progress: `audio_capture start`
148
+ // returns immediately and the recording runs on in the page, so the
149
+ // session looks idle while it is very much working (#423).
150
+ this.sessionManager.isSessionBusy = (id) => this.audioCaptureServices.get(id)?.isCapturing() === true;
84
151
  this.logger = new Logger();
152
+ // A config problem the user never sees is how #227 survived: two
153
+ // documented keys were read by nothing, with no warning anywhere.
154
+ for (const warning of config.warnings) {
155
+ this.logger.warn(warning);
156
+ }
85
157
  this.perfMonitor = new PerformanceMonitor();
86
- this.strudelEngine = new StrudelEngine();
158
+ this.strudelEngine = new IsolatedStrudelEngine();
87
159
  this.setupHandlers();
88
160
  }
89
161
  getTools() {
@@ -128,7 +200,11 @@ export class StrudelMCPServer {
128
200
  try {
129
201
  const content = await readResource(uri, {
130
202
  store: this.store,
131
- examplesDir: join(process.cwd(), 'patterns', 'examples'),
203
+ // Follows `patterns_dir`, like the store does. Hardcoding
204
+ // `process.cwd()` meant a server launched from anywhere but
205
+ // the repo root advertised `strudel://examples` and then
206
+ // failed to read it (#442).
207
+ examplesDir: join(config.patternsDir, 'examples'),
132
208
  });
133
209
  return { contents: [content] };
134
210
  }
@@ -173,6 +249,38 @@ export class StrudelMCPServer {
173
249
  }
174
250
  return ok(result);
175
251
  }
252
+ // A tool returning `{ success: false, ... }` is reporting a
253
+ // FAILURE. Wrapping it in ok() told MCP clients the call
254
+ // succeeded, with the real outcome buried one level down in a
255
+ // field the envelope contract does not mention — so an agent
256
+ // checking `envelope.ok` proceeded as though it had worked.
257
+ //
258
+ // 28 sites across the tool modules return this shape (capture,
259
+ // storage, ai). Rather than migrate them all at once, honour the
260
+ // shape here: it is unambiguous, and the alternative is a silent
261
+ // wrong answer.
262
+ if (isFailureShaped(result)) {
263
+ // Prefer whichever field actually carries the diagnosis, and
264
+ // combine them when both say something. transpile_pattern sets
265
+ // message:'Transpilation failed' with error:'Unexpected token
266
+ // (1:6)' — taking `message` alone would put the content-free half
267
+ // in the field an agent reads and bury the useful half in
268
+ // partialResult.
269
+ const detail = typeof result.error === 'string' && result.error.length > 0
270
+ ? result.error
271
+ : undefined;
272
+ const summary = typeof result.message === 'string' && result.message.length > 0
273
+ ? result.message
274
+ : undefined;
275
+ const message = summary !== undefined && detail !== undefined && !summary.includes(detail)
276
+ ? `${summary}: ${detail}`
277
+ : detail ?? summary ?? 'Tool reported failure without a message.';
278
+ return err(categorizeError(new Error(message)), message, {
279
+ // Keep what the tool produced: some of these carry useful
280
+ // context alongside the failure.
281
+ partialResult: result,
282
+ });
283
+ }
176
284
  return ok(result);
177
285
  }
178
286
  catch (error) {
@@ -185,46 +293,69 @@ export class StrudelMCPServer {
185
293
  // execute() does its own session-aware init check. The hardcoded
186
294
  // tool-name lists had drifted and were silently wrong for explicit
187
295
  // session_id.
188
- async getCurrentPatternSafe(sessionId) {
296
+ /**
297
+ * The session a call belongs to when it names none.
298
+ *
299
+ * Returns the default session's id when SessionManager actually holds
300
+ * that session, and `undefined` when it does not — which means the
301
+ * legacy single controller, the only thing a server that has run
302
+ * nothing but `init` has.
303
+ *
304
+ * There used to be two answers to this question.
305
+ * `getControllerForSession` resolved through
306
+ * `sessionManager.getDefaultSession()`, while `getCurrentPatternSafe`,
307
+ * `writePatternSafe` and `getAudioCaptureService` went straight to the
308
+ * legacy controller. So after `session({action:'switch'})`, `playback`
309
+ * followed the switch and `edit_pattern` did not — reproduced end to
310
+ * end in #421: a write with no `session_id` landed in the legacy
311
+ * browser while the session switched to still held strudel.cc's
312
+ * boilerplate.
313
+ *
314
+ * `getSession` stamps `lastActivity`, which is correct here: resolving
315
+ * a session is the first step of using it.
316
+ */
317
+ effectiveSessionId(sessionId) {
318
+ if (sessionId !== undefined)
319
+ return sessionId;
320
+ const defaultId = this.sessionManager.getDefaultSessionId();
321
+ return this.sessionManager.getSession(defaultId) !== undefined ? defaultId : undefined;
322
+ }
323
+ async getCurrentPatternSafe(rawSessionId) {
324
+ const sessionId = this.effectiveSessionId(rawSessionId);
189
325
  if (sessionId) {
190
326
  // Explicit session — strict lookup, no pre-init stash. Named sessions
191
- // must be created via `create_session` before they're useful.
327
+ // must be created via `session({ action: "create" })` before they're useful.
192
328
  const sessionController = this.sessionManager.getSession(sessionId);
193
329
  if (!sessionController) {
194
- throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
195
- }
196
- try {
197
- return await sessionController.getCurrentPattern();
198
- }
199
- catch {
200
- return '';
330
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
201
331
  }
332
+ // Do NOT collapse a read failure into ''. An empty string already
333
+ // means "the editor is empty", and every read-modify-write caller
334
+ // treats it as a blank canvas — so a transient CDP failure made
335
+ // edit_pattern append OVERWRITE the live pattern with just the
336
+ // appended line, and report success (#277). Let it propagate; the
337
+ // dispatcher turns it into a failure envelope.
338
+ return await sessionController.getCurrentPattern();
202
339
  }
203
340
  if (!this.isInitialized) {
204
- // Default session: return the last pre-init generated pattern if any
205
- const lastPattern = Array.from(this.generatedPatterns.values()).pop();
206
- return lastPattern || '';
207
- }
208
- try {
209
- return await this.controller.getCurrentPattern();
210
- }
211
- catch {
212
- return '';
341
+ // Default session: return the pre-init generated pattern if any
342
+ return this.pendingPattern ?? '';
213
343
  }
344
+ return await this.controller.getCurrentPattern();
214
345
  }
215
- async writePatternSafe(pattern, sessionId) {
346
+ async writePatternSafe(pattern, rawSessionId) {
347
+ const sessionId = this.effectiveSessionId(rawSessionId);
216
348
  if (sessionId) {
217
349
  const sessionController = this.sessionManager.getSession(sessionId);
218
350
  if (!sessionController) {
219
- throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
351
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
220
352
  }
221
353
  return await sessionController.writePattern(pattern);
222
354
  }
223
355
  if (!this.isInitialized) {
224
356
  // Default session: stash the pattern for the next init/auto-init
225
- const id = `pattern_${Date.now()}`;
226
- this.generatedPatterns.set(id, pattern);
227
- return `Pattern generated (initialize Strudel to use it): ${pattern.substring(0, 50)}...`;
357
+ this.pendingPattern = pattern;
358
+ return `${PATTERN_STASHED_PREFIX} ${pattern.substring(0, 50)}...`;
228
359
  }
229
360
  return await this.controller.writePattern(pattern);
230
361
  }
@@ -240,7 +371,7 @@ export class StrudelMCPServer {
240
371
  if (sessionId) {
241
372
  const controller = this.sessionManager.getSession(sessionId);
242
373
  if (!controller) {
243
- throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
374
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
244
375
  }
245
376
  return controller;
246
377
  }
@@ -274,30 +405,58 @@ export class StrudelMCPServer {
274
405
  // Save current state for undo and history (#41) before any edit
275
406
  // (#179): route to the session-specific bundle. Default session is
276
407
  // 'default'; named sessions get their own isolated stacks.
277
- if (['write', 'append', 'insert', 'replace', 'clear'].includes(name)) {
278
- const sid = args?.session_id;
408
+ if (name === 'edit_pattern') {
409
+ // The session this edit belongs to, resolved the same way the
410
+ // read and write below resolve it. `sid ?? 'default'` sent every
411
+ // unnamed edit's undo snapshot to a bundle keyed 'default', which
412
+ // then survived destroying the session it actually belonged to —
413
+ // so the next default session inherited the previous one's undo
414
+ // stack (#421).
415
+ const sid = this.effectiveSessionId(args?.session_id);
416
+ // A default session is enough to snapshot against; `init` is not
417
+ // required when SessionManager is the one holding the page.
279
418
  const canRead = sid !== undefined || this.isInitialized;
280
419
  if (canRead) {
420
+ // Two different failures used to share this catch. A missing
421
+ // controller or destroyed session legitimately means there is
422
+ // nothing to save — but a READ failure means we are about to
423
+ // overwrite a pattern we could not capture, leaving nothing for
424
+ // undo to restore. The fault that destroys the work also disabled
425
+ // the recovery (#277), so they are separated here.
426
+ let controller;
281
427
  try {
282
- const controller = this.getControllerForSession(sid);
283
- const current = await controller.getCurrentPattern();
284
- const bundle = this.getHistoryBundle(sid ?? 'default');
285
- bundle.undoStack.push(current);
286
- this.historyIdCounter++;
287
- bundle.historyStack.push({
288
- id: this.historyIdCounter,
289
- pattern: current,
290
- timestamp: new Date(),
291
- action: name,
292
- });
293
- if (bundle.undoStack.length > this.MAX_HISTORY)
294
- bundle.undoStack.shift();
295
- if (bundle.historyStack.length > this.MAX_HISTORY)
296
- bundle.historyStack.shift();
297
- bundle.redoStack.length = 0;
428
+ controller = this.getControllerForSession(sid);
298
429
  }
299
430
  catch {
300
- // Controller might not be initialized yet, or session is gone
431
+ // No session to snapshot; the edit itself will report that.
432
+ controller = undefined;
433
+ }
434
+ if (controller !== undefined) {
435
+ const current = await controller.getCurrentPattern();
436
+ // A destroy can land during that await. `getHistoryBundle`
437
+ // creates lazily, so it would then build a fresh bundle for a
438
+ // session that no longer exists — a permanent map entry the
439
+ // destroy callback has already run past (#423).
440
+ const destroyedMidRead = sid !== undefined && this.sessionManager.getSession(sid) === undefined;
441
+ if (destroyedMidRead) {
442
+ this.logger.warn(`Session '${sid}' was destroyed while snapshotting; skipping history.`);
443
+ }
444
+ const bundle = destroyedMidRead ? null : this.getHistoryBundle(sid ?? 'default');
445
+ bundle?.undoStack.push(current);
446
+ if (bundle !== null) {
447
+ this.historyIdCounter++;
448
+ bundle.historyStack.push({
449
+ id: this.historyIdCounter,
450
+ pattern: current,
451
+ timestamp: new Date(),
452
+ action: args?.mode ?? 'write',
453
+ });
454
+ if (bundle.undoStack.length > this.MAX_HISTORY)
455
+ bundle.undoStack.shift();
456
+ if (bundle.historyStack.length > this.MAX_HISTORY)
457
+ bundle.historyStack.shift();
458
+ bundle.redoStack.length = 0;
459
+ }
301
460
  }
302
461
  }
303
462
  }
@@ -305,7 +464,12 @@ export class StrudelMCPServer {
305
464
  // Part of the #104 file split — each module owns its own definitions
306
465
  // and handlers. server.ts keeps the protocol + state-tracking shell.
307
466
  const ctx = {
308
- controller: this.controller,
467
+ // Surfaced by `diagnostics level=status`; see ConfigReport (#442).
468
+ configReport: {
469
+ path: resolve(configPath),
470
+ found: configFound,
471
+ warnings: config.warnings,
472
+ },
309
473
  perfMonitor: this.perfMonitor,
310
474
  store: this.store,
311
475
  generator: this.generator,
@@ -314,6 +478,8 @@ export class StrudelMCPServer {
314
478
  geminiService: this.geminiService,
315
479
  strudelEngine: this.strudelEngine,
316
480
  midiExportService: this.midiExportService,
481
+ audioExportService: this.audioExportService,
482
+ midiImportService: this.midiImportService,
317
483
  getAudioCaptureService: (sessionId) => this.getAudioCaptureService(sessionId),
318
484
  dropAudioCaptureService: (sessionId) => { this.audioCaptureServices.delete(sessionId); },
319
485
  getHistory: (sessionId) => {
@@ -325,7 +491,6 @@ export class StrudelMCPServer {
325
491
  maxHistory: this.MAX_HISTORY,
326
492
  };
327
493
  },
328
- historyEntryId: () => ++this.historyIdCounter,
329
494
  dropHistory: (sessionId) => { this.historyBundles.delete(sessionId); },
330
495
  logger: this.logger,
331
496
  isInitialized: () => this.isInitialized,
@@ -373,13 +538,34 @@ export class StrudelMCPServer {
373
538
  if (name === 'init') {
374
539
  const initResult = await this.controller.initialize();
375
540
  this.isInitialized = true;
376
- // Replay any pattern generated before init landed.
377
- if (this.generatedPatterns.size > 0) {
378
- const lastPattern = Array.from(this.generatedPatterns.values()).pop();
379
- if (lastPattern) {
380
- await this.controller.writePattern(lastPattern);
381
- return `${initResult}. Loaded generated pattern.`;
382
- }
541
+ // Install the recorder hook before any pattern playback connects the
542
+ // Strudel audio graph. Otherwise audio_capture can miss the first
543
+ // GainNode.connect call and remain disconnected even while diagnostics
544
+ // reports healthy WebAudio playback.
545
+ if (this._page) {
546
+ await this.getAudioCaptureService();
547
+ }
548
+ // Replay any pattern generated before init landed, then forget it.
549
+ //
550
+ // Clearing is the whole point: the stash exists to bridge the gap
551
+ // before a browser exists, and that gap closes the first time init
552
+ // succeeds. Without the clear, a second init — which agents call
553
+ // routinely, since it reports "Already initialized" and reads as a
554
+ // no-op — silently overwrote the live editor with a pattern from
555
+ // before the first init. Undo could not recover it either, because
556
+ // this writes through the controller and only edit_pattern pushes
557
+ // history (#262).
558
+ // Read without clearing, and clear only once the write lands.
559
+ //
560
+ // `takePendingPattern()` cleared first, so a write that threw —
561
+ // a page that died between init and this line — destroyed the
562
+ // pattern unrecoverably. The comment above justifies CLEARING;
563
+ // it does not justify clearing early (#453).
564
+ const pending = this.pendingPattern;
565
+ if (pending !== null) {
566
+ await this.controller.writePattern(pending);
567
+ this.pendingPattern = null;
568
+ return `${initResult}. Loaded generated pattern.`;
383
569
  }
384
570
  return initResult;
385
571
  }
@@ -398,11 +584,45 @@ export class StrudelMCPServer {
398
584
  return bundle;
399
585
  }
400
586
  /** Idempotent browser bring-up. Used by tools that promise auto-init (e.g. `compose`). */
587
+ /**
588
+ * Initializes the default browser if it is not already up.
589
+ *
590
+ * Single-flight. The MCP SDK does not serialize tool calls — each
591
+ * request is dispatched through a promise chain and returns immediately
592
+ * — so two `compose` calls arriving together both used to pass the
593
+ * `isInitialized` check, both call `initialize()`, and both assign
594
+ * `this.browser`. The second assignment orphaned the first Chromium
595
+ * process, which `cleanup()` could no longer reach. With the default
596
+ * `headless: false` the user also got two windows, half their tool
597
+ * calls addressing the one they were not watching (#265).
598
+ *
599
+ * The in-flight promise is the same shape `checkADC()` already uses.
600
+ */
401
601
  async ensureInitialized() {
602
+ // `isInitialized` was only ever set true, so once the browser died —
603
+ // user closed the window, or cleanup() ran — this returned early and
604
+ // never reached the self-healing initialize(). compose, the tool that
605
+ // advertises auto-init, then failed permanently with 'Target page,
606
+ // context or browser has been closed' (#265).
607
+ // Strict false, and an optional call: a controller that cannot answer
608
+ // (or does not implement the check) should mean 'assume alive' rather
609
+ // than tearing down a working browser. Recovery is the optimization
610
+ // here; a false negative just preserves the old behaviour.
611
+ if (this.isInitialized && this.controller.isAlive?.() === false) {
612
+ this.isInitialized = false;
613
+ }
402
614
  if (this.isInitialized)
403
615
  return;
404
- await this.controller.initialize();
405
- this.isInitialized = true;
616
+ // Promise.resolve wrap: initialize() is not guaranteed to return a
617
+ // thenable (a mocked controller returns undefined), and chaining
618
+ // directly off it would throw before ever launching a browser.
619
+ this.initPromise ??= Promise.resolve(this.controller.initialize())
620
+ .then(() => { this.isInitialized = true; })
621
+ .finally(() => { this.initPromise = null; });
622
+ await this.initPromise;
623
+ if (this._page) {
624
+ await this.getAudioCaptureService();
625
+ }
406
626
  }
407
627
  /** Getter for page access in audio capture. */
408
628
  get _page() {
@@ -413,18 +633,25 @@ export class StrudelMCPServer {
413
633
  // the class. Post-#180: per-session — each session's recorder injects
414
634
  // into its own page; concurrent captures across sessions no longer
415
635
  // share a singleton stream.
416
- async getAudioCaptureService(sessionId) {
417
- // Resolve the right page: explicit session via SessionManager, or
418
- // legacy/default via this.controller.
636
+ async getAudioCaptureService(rawSessionId) {
637
+ // Resolve the right page: the named or default session via
638
+ // SessionManager, or the legacy controller when neither exists.
639
+ //
640
+ // This used to treat "no session_id" as "legacy, always", so a
641
+ // recorder was injected into the legacy page while `startCapture`
642
+ // ran against the default session's page — two different browser
643
+ // contexts (#421). It also refused with "Browser not initialized"
644
+ // when a live default session was sitting right there.
645
+ const sessionId = this.effectiveSessionId(rawSessionId);
419
646
  let page;
420
647
  let key;
421
648
  if (sessionId) {
422
649
  const sessionController = this.sessionManager.getSession(sessionId);
423
650
  if (!sessionController) {
424
- throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
651
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
425
652
  }
426
653
  if (!sessionController.page) {
427
- throw new Error(`Session '${sessionId}' has no active page yet.`);
654
+ throw new BusinessError(`Session '${sessionId}' has no active page yet.`);
428
655
  }
429
656
  page = sessionController.page;
430
657
  key = sessionId;
@@ -436,23 +663,104 @@ export class StrudelMCPServer {
436
663
  page = this._page;
437
664
  key = 'default';
438
665
  }
439
- let service = this.audioCaptureServices.get(key);
440
- if (!service) {
441
- service = new AudioCaptureService();
442
- await service.injectRecorder(page);
443
- this.audioCaptureServices.set(key, service);
666
+ // Re-inject when the cached service is bound to a different page.
667
+ //
668
+ // A cached service used to be returned unconditionally, which broke
669
+ // in two ways (#264). A session recreated under a previously-evicted
670
+ // id got the old session's recorder, pointing at a closed page. And
671
+ // after `init` recovered a dead browser (#206), the 'default' service
672
+ // still referenced the old page — so audio capture was permanently
673
+ // broken by the very mechanism meant to recover from a crash, with
674
+ // "Audio capture not initialized" until restart.
675
+ //
676
+ // Strict false, like the liveness check in ensureInitialized: a
677
+ // service that cannot answer keeps the old reuse-the-cache
678
+ // behaviour, so this can only ever add a re-injection that was
679
+ // needed, never drop one that was working.
680
+ const service = this.audioCaptureServices.get(key);
681
+ // Awaited: the check asks the page whether the recorder is still
682
+ // there, because a Playwright `Page` outlives the JS realm it points
683
+ // at and a reload wipes the recorder while identity still matches
684
+ // (#437).
685
+ if (service && (await service.isInjectedInto?.(page)) !== false) {
686
+ return service;
687
+ }
688
+ // Single-flight, like `SessionManager.ensureBrowser` (#263).
689
+ //
690
+ // This was check-then-act: two concurrent `audio_capture` calls on
691
+ // one uncached session both missed, both constructed, both awaited
692
+ // `injectRecorder`, and the last `set` won. `injectRecorder` assigns
693
+ // `window.strudelAudioCapture` unconditionally, so the second
694
+ // injection WIPED a capture the first had started, and the orphaned
695
+ // service still reported itself injected (#423).
696
+ const existing = this.captureServiceBuilds.get(key);
697
+ if (existing !== undefined)
698
+ return await existing;
699
+ const build = (async () => {
700
+ const fresh = new AudioCaptureService();
701
+ await fresh.injectRecorder(page);
702
+ // A destroy landing during the await deleted a key that was not
703
+ // there yet; re-inserting here would resurrect a service for a
704
+ // dead session. The caller still gets its recorder — the page it
705
+ // asked about is the page it got — but nothing is cached for an
706
+ // id that no longer exists.
707
+ if (key === 'default' || this.sessionManager.getSession(key) !== undefined) {
708
+ this.audioCaptureServices.set(key, fresh);
709
+ }
710
+ return fresh;
711
+ })();
712
+ this.captureServiceBuilds.set(key, build);
713
+ try {
714
+ return await build;
715
+ }
716
+ finally {
717
+ this.captureServiceBuilds.delete(key);
444
718
  }
445
- return service;
446
719
  }
447
720
  async run() {
448
721
  const transport = new StdioServerTransport();
449
722
  await this.server.connect(transport);
450
723
  this.logger.info('Enhanced Strudel MCP server v2.0.1 running (fixed)');
451
- process.on('SIGINT', async () => {
452
- this.logger.info('Shutting down...');
724
+ // SIGTERM as well as SIGINT. The isolated engine child is a real
725
+ // process, and `docker stop`, systemd and most supervisors send
726
+ // SIGTERM — handling only SIGINT left the child orphaned in exactly
727
+ // the deployments where nobody is watching a terminal (#307).
728
+ let shuttingDown = false;
729
+ const shutdown = async (signal) => {
730
+ if (shuttingDown) {
731
+ // A second signal means the operator is not willing to wait any
732
+ // longer, and they are usually right. Swallowing it left Ctrl+C
733
+ // doing nothing at all while a wedged browser held the first
734
+ // shutdown open forever.
735
+ this.logger.warn(`${signal} during shutdown — exiting now.`);
736
+ process.exit(1);
737
+ }
738
+ shuttingDown = true;
739
+ this.logger.info(`Shutting down (${signal})...`);
740
+ // cleanup() and destroyAll() both talk to a browser over CDP, and a
741
+ // wedged one never answers. Without this the process hangs in
742
+ // shutdown indefinitely and the supervisor eventually SIGKILLs it,
743
+ // which is the one path that CAN orphan the engine child. unref'd
744
+ // so it never keeps a healthy process alive.
745
+ const forceExit = setTimeout(() => {
746
+ this.logger.error(`Shutdown exceeded ${String(SHUTDOWN_GRACE_MS)}ms — forcing exit.`);
747
+ process.exit(1);
748
+ }, SHUTDOWN_GRACE_MS);
749
+ forceExit.unref();
750
+ // Disposed FIRST and synchronously. The two awaits below can hang
751
+ // on a wedged browser, and the child must not outlive us because
752
+ // cleanup took too long.
753
+ this.strudelEngine.dispose();
453
754
  await this.controller.cleanup();
454
755
  await this.sessionManager.destroyAll();
756
+ clearTimeout(forceExit);
455
757
  process.exit(0);
456
- });
758
+ };
759
+ process.on('SIGINT', () => void shutdown('SIGINT'));
760
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
761
+ // Last resort: a path that reaches exit without a signal — an
762
+ // uncaught throw, or the transport closing — still must not leak a
763
+ // process. Synchronous only; this handler cannot await.
764
+ process.on('exit', () => { this.strudelEngine.dispose(); });
457
765
  }
458
766
  }