@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1

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 +202 -705
  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 +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  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 +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -1,6 +1,8 @@
1
1
  import { chromium } from 'playwright';
2
- import { StrudelController } from '../StrudelController.js';
2
+ import { StrudelController, waitForStrudelReady } from '../StrudelController.js';
3
+ import { DEFAULT_BLOCK_RESOURCES, DEFAULT_STRUDEL_URL, DEFAULT_VIEWPORT, } from '../utils/ServerConfig.js';
3
4
  import { Logger } from '../utils/Logger.js';
5
+ import { BusinessError } from '../utils/CategorisedError.js';
4
6
  /**
5
7
  * Manages multiple concurrent Strudel browser sessions.
6
8
  * Uses browser contexts for isolation - one browser instance with multiple contexts.
@@ -8,7 +10,42 @@ import { Logger } from '../utils/Logger.js';
8
10
  */
9
11
  export class SessionManager {
10
12
  browser = null;
13
+ /**
14
+ * In-flight browser launch, so concurrent creates share one.
15
+ *
16
+ * `if (!this.browser) { this.browser = await chromium.launch(...) }`
17
+ * has an await between the check and the assignment, and the MCP SDK
18
+ * does not serialize tool calls. Three concurrent
19
+ * session({action:'create'}) calls launched three Chromium processes
20
+ * and kept only the last assignment — the other two leaked with no
21
+ * handle left to close them (#317). Same class as #263, one level up:
22
+ * that one leaked contexts, this one leaks whole browsers.
23
+ */
24
+ browserLaunch = null;
25
+ /** In-flight close, so a create waits for it rather than racing it. */
26
+ browserClose = null;
27
+ /**
28
+ * Handles that refused to close, kept so a later teardown can retry.
29
+ *
30
+ * `destroySession` and `closeBrowser` both logged a close failure and
31
+ * moved on, which dropped the last reference to the thing that failed
32
+ * — nothing could ever try again, and a context or browser that was
33
+ * only temporarily wedged stayed up for the life of the process
34
+ * (#423 item 9). Playwright exposes no process handle for a browser
35
+ * from `chromium.launch()` (`process()` is on BrowserServer and
36
+ * ElectronApplication, not Browser), so retrying the close is the
37
+ * strongest move available here.
38
+ */
39
+ pendingCloses = new Map();
11
40
  sessions = new Map();
41
+ /**
42
+ * Ids currently being created.
43
+ *
44
+ * Held from before the first await until the session is in `sessions`
45
+ * or the attempt has failed, so the duplicate and limit checks account
46
+ * for creations in flight rather than only completed ones (#263).
47
+ */
48
+ reservedIds = new Set();
12
49
  defaultSessionId = 'default';
13
50
  logger;
14
51
  isHeadless;
@@ -19,29 +56,81 @@ export class SessionManager {
19
56
  /** Cleanup interval in ms (5 minutes) */
20
57
  CLEANUP_INTERVAL = 5 * 60 * 1000;
21
58
  cleanupTimer = null;
22
- constructor(headless = false) {
59
+ audioAnalysisConfig;
60
+ /** Strudel REPL URL; `strudel_url` in config.json (#227). */
61
+ strudelUrl;
62
+ /** Playwright viewport; null lets the page track the OS window (#514). */
63
+ viewport;
64
+ /** Resource types the per-session page route aborts (#514). */
65
+ blockResources;
66
+ /**
67
+ * Called after a session is removed, however it was removed.
68
+ *
69
+ * Lets the owner drop state it keys by session id without having to
70
+ * intercept every teardown path.
71
+ */
72
+ onSessionDestroyed;
73
+ /**
74
+ * Whether a session has work the sweep must not interrupt.
75
+ *
76
+ * The manager cannot see the server's per-session state — an audio
77
+ * capture in progress, say — so the owner answers. Set alongside
78
+ * `onSessionDestroyed`; absent means "nothing extra to check" (#423).
79
+ */
80
+ isSessionBusy;
81
+ constructor(headless = false, audioAnalysisConfig, strudelUrl = DEFAULT_STRUDEL_URL,
82
+ // Page-setup overrides from config.json `viewport` / `block_resources` (#514).
83
+ fidelity = {}) {
23
84
  this.isHeadless = headless;
85
+ this.audioAnalysisConfig = audioAnalysisConfig;
86
+ this.strudelUrl = strudelUrl;
87
+ this.viewport = fidelity.viewport !== undefined ? fidelity.viewport : DEFAULT_VIEWPORT;
88
+ this.blockResources = fidelity.blockResources ?? DEFAULT_BLOCK_RESOURCES;
24
89
  this.logger = new Logger();
25
90
  }
26
91
  /**
27
92
  * Ensures the shared browser instance is running
28
93
  */
29
94
  async ensureBrowser() {
30
- if (!this.browser) {
31
- this.browser = await chromium.launch({
32
- headless: this.isHeadless,
33
- args: [
34
- '--use-fake-ui-for-media-stream',
35
- '--disable-dev-shm-usage',
36
- '--no-sandbox',
37
- '--disable-setuid-sandbox',
38
- '--disable-gpu',
39
- '--disable-software-rasterizer'
40
- ],
41
- });
42
- // Start cleanup timer when browser is created
43
- this.startCleanupTimer();
95
+ // A close in progress finishes before a launch starts, or the two
96
+ // race and the launch wins a browser that is then torn down (#423).
97
+ if (this.browserClose)
98
+ await this.browserClose;
99
+ // A browser that is still a live object but whose process is gone —
100
+ // a crashed Chromium — is not usable. Without this check every
101
+ // future create failed permanently, while `ensureInitialized` on the
102
+ // legacy path has had exactly this self-heal since #206.
103
+ if (this.browser && !this.browser.isConnected()) {
104
+ this.logger.warn('Shared browser is disconnected; relaunching.');
105
+ this.browser = null;
44
106
  }
107
+ if (this.browser)
108
+ return this.browser;
109
+ // Single-flight: whoever gets here first starts the launch, everyone
110
+ // else awaits the same promise.
111
+ this.browserLaunch ??= this.launchBrowser();
112
+ try {
113
+ return await this.browserLaunch;
114
+ }
115
+ finally {
116
+ this.browserLaunch = null;
117
+ }
118
+ }
119
+ /** Launches Chromium and starts the idle sweep. Call via ensureBrowser. */
120
+ async launchBrowser() {
121
+ this.browser = await chromium.launch({
122
+ headless: this.isHeadless,
123
+ args: [
124
+ '--use-fake-ui-for-media-stream',
125
+ '--disable-dev-shm-usage',
126
+ '--no-sandbox',
127
+ '--disable-setuid-sandbox',
128
+ '--disable-gpu',
129
+ '--disable-software-rasterizer'
130
+ ],
131
+ });
132
+ // Start cleanup timer when browser is created
133
+ this.startCleanupTimer();
45
134
  return this.browser;
46
135
  }
47
136
  /**
@@ -58,34 +147,75 @@ export class SessionManager {
58
147
  if (!id || typeof id !== 'string') {
59
148
  throw new Error('Session ID must be a non-empty string');
60
149
  }
61
- // Check if session already exists
62
- if (this.sessions.has(id)) {
150
+ // Check if session already exists, or is mid-creation
151
+ if (this.sessions.has(id) || this.reservedIds.has(id)) {
63
152
  throw new Error(`Session '${id}' already exists`);
64
153
  }
65
- // Check max sessions limit
66
- if (this.sessions.size >= this.MAX_SESSIONS) {
67
- throw new Error(`Maximum session limit (${this.MAX_SESSIONS}) reached. Destroy an existing session first.`);
154
+ // Check max sessions limit, counting creations in flight
155
+ if (this.sessions.size + this.reservedIds.size >= this.MAX_SESSIONS) {
156
+ throw new BusinessError(`Maximum session limit (${this.MAX_SESSIONS}) reached. Destroy an existing session first.`);
157
+ }
158
+ // Reserve the id synchronously, before the first await.
159
+ //
160
+ // The two checks above and the set below used to be ~2 seconds apart
161
+ // (ensureBrowser, newContext, newPage, goto, readiness wait), and the
162
+ // MCP SDK does not serialize tool calls. So two concurrent creates
163
+ // with the same id both passed the duplicate check and the second
164
+ // overwrote the first — orphaning a context nothing could reach. Four
165
+ // live sessions plus three concurrent creates all read size === 4 and
166
+ // produced seven on a five-session limit (#263).
167
+ this.reservedIds.add(id);
168
+ let context;
169
+ let controller;
170
+ try {
171
+ const browser = await this.ensureBrowser();
172
+ context = await browser.newContext({
173
+ permissions: ['microphone'],
174
+ // null is meaningful: Playwright tracks the real window size (#514).
175
+ viewport: this.viewport,
176
+ reducedMotion: 'reduce',
177
+ });
178
+ const page = await context.newPage();
179
+ controller = new StrudelController(this.isHeadless, this.audioAnalysisConfig, this.strudelUrl, undefined, { viewport: this.viewport, blockResources: this.blockResources });
180
+ await this.initializeControllerWithPage(controller, page);
181
+ const session = {
182
+ controller,
183
+ context,
184
+ page,
185
+ created: new Date(),
186
+ lastActivity: new Date(),
187
+ };
188
+ this.sessions.set(id, session);
189
+ }
190
+ catch (error) {
191
+ // Close what was allocated. Without this the context stayed alive
192
+ // and unreachable: it never entered `sessions`, so destroyAll, the
193
+ // idle sweep, and the MAX_SESSIONS count could none of them see it.
194
+ // Each failed create leaked a Chromium renderer while the limit
195
+ // still read 0/5, and an agent retrying a transient strudel.cc
196
+ // failure leaked one per attempt.
197
+ if (context !== undefined) {
198
+ await this.closeOrRetain(context, `context for '${id}'`);
199
+ }
200
+ // Released here rather than only in `finally`, because the check
201
+ // below has to see `reservedIds` without this attempt still in it.
202
+ // `finally` deletes again; Set.delete is idempotent.
203
+ this.reservedIds.delete(id);
204
+ // A failed create is the one path that can leave Chromium up with
205
+ // nothing to serve. `ensureBrowser` launches it and starts the
206
+ // 5-minute idle sweep, and only `destroySession` and `destroyAll`
207
+ // ever close it — neither of which runs for a session that never
208
+ // landed. So a server whose create failed, and which never made
209
+ // another, held a browser and a live interval with
210
+ // `sessions.size === 0` (#423 item 1).
211
+ if (this.sessions.size === 0 && this.reservedIds.size === 0) {
212
+ await this.closeBrowser();
213
+ }
214
+ throw error;
215
+ }
216
+ finally {
217
+ this.reservedIds.delete(id);
68
218
  }
69
- const browser = await this.ensureBrowser();
70
- // Create isolated browser context
71
- const context = await browser.newContext({
72
- permissions: ['microphone'],
73
- viewport: { width: 1280, height: 720 },
74
- reducedMotion: 'reduce',
75
- });
76
- const page = await context.newPage();
77
- // Create controller with injected page (using a factory pattern)
78
- const controller = new StrudelController(this.isHeadless);
79
- // Initialize the controller with the existing page
80
- await this.initializeControllerWithPage(controller, page);
81
- const session = {
82
- controller,
83
- context,
84
- page,
85
- created: new Date(),
86
- lastActivity: new Date(),
87
- };
88
- this.sessions.set(id, session);
89
219
  this.logger.info(`Session '${id}' created`, {
90
220
  totalSessions: this.sessions.size,
91
221
  });
@@ -99,7 +229,7 @@ export class SessionManager {
99
229
  // Set up page routing for resource optimization
100
230
  await page.route('**/*', (route) => {
101
231
  const resourceType = route.request().resourceType();
102
- if (['image', 'font', 'media'].includes(resourceType)) {
232
+ if (this.blockResources.includes(resourceType)) {
103
233
  route.abort();
104
234
  }
105
235
  else {
@@ -107,20 +237,21 @@ export class SessionManager {
107
237
  }
108
238
  });
109
239
  // Navigate to Strudel
110
- await page.goto('https://strudel.cc/', {
240
+ await page.goto(this.strudelUrl, {
111
241
  waitUntil: 'domcontentloaded',
112
242
  timeout: 15000,
113
243
  });
114
- // Wait for editor
115
- await page.waitForSelector('.cm-content', { timeout: 8000 });
116
- // Wait for CodeMirror to initialize
117
- await page.waitForFunction(() => {
118
- const editor = document.querySelector('.cm-content');
119
- return editor && editor.__view;
120
- }, { timeout: 5000 });
244
+ // Same readiness gate as StrudelController.initialize(). This used to
245
+ // wait on `editor.__view`, which strudel.cc does not expose, so every
246
+ // named session stalled and threw (#228).
247
+ await waitForStrudelReady(page);
121
248
  // Inject page into controller via internal method
122
249
  controller._page = page;
123
250
  controller.browser = null; // Controller doesn't own the browser
251
+ // Console monitoring was previously only wired up on the single-session
252
+ // path, so diagnostics and validate_pattern_runtime reported zero errors
253
+ // for every named session (#228).
254
+ controller.setupConsoleMonitoring();
124
255
  // Inject audio analyzer
125
256
  await controller.analyzer.inject(page);
126
257
  }
@@ -147,14 +278,35 @@ export class SessionManager {
147
278
  if (!session) {
148
279
  throw new Error(`Session '${id}' not found`);
149
280
  }
281
+ // Out of the map BEFORE the await, not after.
282
+ //
283
+ // The entry used to survive `await context.close()`, so a concurrent
284
+ // `getSession(id)` handed out a controller on a closing page — and
285
+ // stamped `lastActivity` on it. The same window let two destroys of
286
+ // one id both pass the guard above, both close, both fire the
287
+ // callback below, and both reach `closeBrowser` (#423).
288
+ this.sessions.delete(id);
289
+ // Not rethrown: the entry is already gone, and a context that
290
+ // refuses to close is not a reason to keep serving it. The handle is
291
+ // retained rather than dropped, so the next teardown retries it
292
+ // (#423 item 9).
293
+ await this.closeOrRetain(session.context, `context for session '${id}'`);
294
+ // Tell the owner, whichever path got us here. The server keeps its
295
+ // own per-session maps (history bundles, capture services), and those
296
+ // used to be cleared only by the session({action:'destroy'}) tool
297
+ // handler — so idle eviction leaked them, and a session recreated
298
+ // under the same id inherited a dead recorder and a previous
299
+ // session's undo stack (#264).
300
+ //
301
+ // `destroyAll` fires it in its own loop rather than routing through
302
+ // here. This comment used to claim destroyAll was covered by this
303
+ // line, and it was not (#423).
150
304
  try {
151
- // Close the browser context (which closes the page)
152
- await session.context.close();
305
+ this.onSessionDestroyed?.(id);
153
306
  }
154
307
  catch (error) {
155
- this.logger.warn(`Error closing session context: ${error.message}`);
308
+ this.logger.warn(`Session destroy callback failed for '${id}': ${error.message}`);
156
309
  }
157
- this.sessions.delete(id);
158
310
  // If this was the default session, reset to 'default'
159
311
  if (this.defaultSessionId === id) {
160
312
  this.defaultSessionId = 'default';
@@ -162,8 +314,12 @@ export class SessionManager {
162
314
  this.logger.info(`Session '${id}' destroyed`, {
163
315
  totalSessions: this.sessions.size,
164
316
  });
165
- // If no more sessions, close browser
166
- if (this.sessions.size === 0) {
317
+ // If no more sessions, close browser — unless a create is in
318
+ // flight. `reservedIds` holds ids between the limit check and the
319
+ // session landing (#263), which is exactly the window in which a
320
+ // concurrent destroy would close the browser the create is about to
321
+ // call newContext on (#317).
322
+ if (this.sessions.size === 0 && this.reservedIds.size === 0) {
167
323
  await this.closeBrowser();
168
324
  }
169
325
  }
@@ -222,17 +378,31 @@ export class SessionManager {
222
378
  async destroyAll() {
223
379
  const sessionIds = this.listSessions();
224
380
  for (const id of sessionIds) {
381
+ const session = this.sessions.get(id);
382
+ if (!session)
383
+ continue;
384
+ this.sessions.delete(id);
385
+ await this.closeOrRetain(session.context, `context for session '${id}'`);
386
+ // The same callback `destroySession` fires. This loop used to
387
+ // close contexts and call `sessions.clear()` without it, so the
388
+ // server's per-session history bundles and capture services were
389
+ // never dropped — while the comment on `destroySession` and the
390
+ // one at the callback's registration both said destroyAll was
391
+ // covered (#423). Only the shutdown path calls this, so nothing
392
+ // leaked in practice; the claim was the problem.
393
+ //
394
+ // It runs outside the close, because a `try` around both put the
395
+ // callback back on the same failure path the fix removed: a
396
+ // context that would not close skipped the teardown again.
225
397
  try {
226
- const session = this.sessions.get(id);
227
- if (session) {
228
- await session.context.close();
229
- }
398
+ this.onSessionDestroyed?.(id);
230
399
  }
231
400
  catch (error) {
232
- this.logger.warn(`Error closing session '${id}': ${error.message}`);
401
+ this.logger.warn(`Session destroy callback failed for '${id}': ${error.message}`);
233
402
  }
234
403
  }
235
404
  this.sessions.clear();
405
+ this.defaultSessionId = 'default';
236
406
  await this.closeBrowser();
237
407
  this.logger.info('All sessions destroyed');
238
408
  }
@@ -241,16 +411,89 @@ export class SessionManager {
241
411
  */
242
412
  async closeBrowser() {
243
413
  this.stopCleanupTimer();
244
- if (this.browser) {
414
+ const browser = this.browser;
415
+ if (!browser && this.pendingCloses.size === 0)
416
+ return;
417
+ // Cleared BEFORE the first await, not after.
418
+ //
419
+ // `ensureBrowser` returns `this.browser` when it is non-null, and it
420
+ // stayed non-null for the whole of `await browser.close()` — so a
421
+ // create landing in that window got a browser that was already going
422
+ // away and `newContext()` threw `Target page, context or browser has
423
+ // been closed`. `reservedIds` guards the window before closeBrowser
424
+ // is entered, not during it (#423).
425
+ //
426
+ // Nothing may await above this line: the retry below is an await,
427
+ // and running it first would reopen exactly that window.
428
+ this.browser = null;
429
+ // Held so a concurrent `ensureBrowser` waits for the close to finish
430
+ // rather than launching a second Chromium beside it.
431
+ this.browserClose = (async () => {
432
+ // Anything an earlier teardown could not close gets another
433
+ // attempt, before the browser it belongs to goes away (#423 item 9).
434
+ await this.retryPendingCloses();
435
+ // Not rethrown: the field is already cleared, and a browser that
436
+ // will not close is not a reason to keep handing it out. The
437
+ // handle is retained rather than dropped, so the next teardown
438
+ // retries it.
439
+ if (browser)
440
+ await this.closeOrRetain(browser, 'shared browser');
441
+ })();
442
+ try {
443
+ await this.browserClose;
444
+ }
445
+ finally {
446
+ this.browserClose = null;
447
+ }
448
+ }
449
+ /**
450
+ * Closes a handle, retaining it for a later retry if it refuses.
451
+ *
452
+ * Never throws: every caller is on a teardown path where the entry is
453
+ * already gone from the map, and a failed close must not stop the rest
454
+ * of the teardown.
455
+ *
456
+ * @param handle - Anything with an async `close()` — a context or the browser
457
+ * @param label - What to call it in the log
458
+ */
459
+ async closeOrRetain(handle, label) {
460
+ try {
461
+ await handle.close();
462
+ this.pendingCloses.delete(handle);
463
+ }
464
+ catch (error) {
465
+ this.pendingCloses.set(handle, label);
466
+ this.logger.error(`Failed to close ${label}; retained for retry`, error);
467
+ }
468
+ }
469
+ /**
470
+ * Retries handles an earlier teardown could not close.
471
+ *
472
+ * One that still refuses stays in the map for the teardown after this
473
+ * one. The map holds at most one entry per handle and entries are
474
+ * dropped on success, so a repeatedly-wedged handle costs one
475
+ * reference, not a growing list.
476
+ */
477
+ async retryPendingCloses() {
478
+ for (const [handle, label] of [...this.pendingCloses]) {
245
479
  try {
246
- await this.browser.close();
480
+ await handle.close();
481
+ this.pendingCloses.delete(handle);
482
+ this.logger.info(`Closed ${label} on retry`);
247
483
  }
248
484
  catch (error) {
249
- this.logger.warn(`Error closing browser: ${error.message}`);
485
+ this.logger.warn(`${label} still will not close: ${error.message}`);
250
486
  }
251
- this.browser = null;
252
487
  }
253
488
  }
489
+ /**
490
+ * Number of handles retained after a failed close. Test seam for #423
491
+ * item 9; nothing in src/ reads it.
492
+ * @returns Count of handles awaiting a retry
493
+ */
494
+ getPendingCloseCount() {
495
+ return this.pendingCloses.size;
496
+ }
254
497
  /**
255
498
  * Starts the automatic cleanup timer
256
499
  */
@@ -285,6 +528,36 @@ export class SessionManager {
285
528
  }
286
529
  }
287
530
  for (const id of sessionsToDestroy) {
531
+ // Rechecked, because the list is a snapshot and each destroy
532
+ // awaits. A session used while an earlier one was being torn down
533
+ // had its `lastActivity` refreshed and was killed anyway — mid-use
534
+ // (#423).
535
+ const session = this.sessions.get(id);
536
+ if (!session)
537
+ continue;
538
+ if (Date.now() - session.lastActivity.getTime() <= this.INACTIVITY_TIMEOUT) {
539
+ this.logger.info(`Session '${id}' became active during the sweep; keeping it`);
540
+ continue;
541
+ }
542
+ // "Idle" meant "no tool call recently", which is not the same as
543
+ // "doing nothing". `lastActivity` moves only in `getSession`, so a
544
+ // session left playing — or recording, since `audio_capture start`
545
+ // returns immediately and the capture runs on in the page for up
546
+ // to ten minutes — looked idle and was torn down underneath its
547
+ // own audio (#423).
548
+ //
549
+ // `getPlaybackState()` is a cached flag, not a page read (#456),
550
+ // so it can say "playing" for a session the user stopped by hand.
551
+ // That errs toward keeping a session too long, which is the safe
552
+ // direction for a destructive sweep.
553
+ if (session.controller.getPlaybackState()) {
554
+ this.logger.info(`Session '${id}' is still playing; keeping it`);
555
+ continue;
556
+ }
557
+ if (this.isSessionBusy?.(id) === true) {
558
+ this.logger.info(`Session '${id}' has work in flight; keeping it`);
559
+ continue;
560
+ }
288
561
  this.logger.info(`Auto-destroying inactive session '${id}'`);
289
562
  try {
290
563
  await this.destroySession(id);