moontide-mcp 2.0.2 → 2.2.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 (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. package/reference/transitions.md +13 -1
package/dist/session.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AskChannel, listenerScriptPath } from './ask.js';
3
+ import { NOT_OPEN_REQUEST_MESSAGE, RequestStatusBoard, cleanAnswerText } from './request-status.js';
3
4
  import { MIDI_PERMISSION_WAIT_MESSAGE, MidiChannel } from './midi-state.js';
4
5
  import { applyEdits } from './edit-code.js';
5
6
  import { CatalogSaveError, MAX_CATALOG_DRAFTS, MAX_CATALOG_KITS, MAX_CATALOG_SOUNDS, MAX_PART_SOUNDS, catalogFileExists, countText, flattenCatalogPart, parseCatalog, problemsText, saveCatalog, summarizeCatalog, } from './catalog.js';
@@ -71,6 +72,14 @@ export function cleanErrorDetail(detail) {
71
72
  }
72
73
  return codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
73
74
  }
75
+ /** The page's refusal text of a tempo ramp it could not make, trimmed and cut like any error detail but kept as a sentence. */
76
+ function cleanRefusal(text) {
77
+ const trimmed = text.trim();
78
+ if (trimmed === '')
79
+ return UNKNOWN_ERROR_DETAIL;
80
+ const codePoints = Array.from(trimmed);
81
+ return codePoints.length <= MAX_DETAIL_CODE_POINTS ? trimmed : codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
82
+ }
74
83
  /**
75
84
  * Builds the tool-response text for a failed evaluation: Strudel's message, the line when
76
85
  * known, and a truthful note about what is (or isn't) still playing.
@@ -145,6 +154,24 @@ export function earlierRuntimeErrorLine(message, count, versionId = null) {
145
154
  ? `Runtime error from an earlier version (#${versionId}, not the code playing now, repeated ${count} times): ${message}.`
146
155
  : `Runtime error from an earlier version (#${versionId}, not the code playing now): ${message}.`;
147
156
  }
157
+ /** Every structure-note line in a reply starts with this, so no line of the page can start a reply line itself. */
158
+ export const STRUCTURE_NOTE_LEAD = 'Song structure';
159
+ /**
160
+ * Cleans one structure-note line from the page: every run of whitespace, control, format, line and paragraph
161
+ * separator characters becomes one space, then the text is cleaned like an error detail (trailing periods off, cut at
162
+ * MAX_DETAIL_CODE_POINTS code points). Returns null for a line with nothing left, which is dropped.
163
+ */
164
+ export function cleanStructureLine(text) {
165
+ const collapsed = text.replace(/[\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim();
166
+ if (collapsed === '') {
167
+ return null;
168
+ }
169
+ return cleanErrorDetail(collapsed);
170
+ }
171
+ /** One structure-note line as it appears in a reply: the fixed lead, the version when it is known, the cleaned sentence. */
172
+ export function structureNoteLine(line, versionId = null) {
173
+ return versionId === null ? `${STRUCTURE_NOTE_LEAD}: ${line}.` : `${STRUCTURE_NOTE_LEAD} in #${versionId}: ${line}.`;
174
+ }
148
175
  /**
149
176
  * The note Claude gets, once per distinct report, when a page could not load a script from unpkg.com. The fix is on
150
177
  * the user's side, so the note says so: Claude cannot repair a blocked network.
@@ -171,6 +198,26 @@ export function takeoverCanceledLine(cycle, reason) {
171
198
  }
172
199
  return `${head} the page was closed, reloaded or replaced by another tab before then. get_code returns the code from before it.`;
173
200
  }
201
+ /**
202
+ * The note Claude gets, once, when a tempo ramp ended without Claude asking for it: a program stated another tempo, a
203
+ * program stated no tempo, a step failed, or the page that ran it was closed, reloaded or replaced by another tab.
204
+ * Stop, a finished ramp and a replaced ramp say nothing. Only the cause (a fixed word) and a number come in: no
205
+ * page text. `bpm` is the tempo that plays after the end; the wording leaves it out when the page gave none.
206
+ */
207
+ export function glideEndedLine(reason, bpm) {
208
+ const shown = bpm === undefined ? undefined : Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
209
+ const head = 'Note: the tempo ramp ended early:';
210
+ if (reason === 'other-tempo') {
211
+ return `${head} a program stated another tempo${shown === undefined ? '' : `, and ${shown} BPM plays now`}.`;
212
+ }
213
+ if (reason === 'no-tempo') {
214
+ return `${head} a program stated no tempo${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM, where the ramp had got to`}.`;
215
+ }
216
+ if (reason === 'error') {
217
+ return `${head} a step failed in the page${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM`}.`;
218
+ }
219
+ return `${head} the page was closed, reloaded or replaced by another tab, so the tempo of the code plays.`;
220
+ }
174
221
  /**
175
222
  * The note when a change that waited for its line took over and replaced code that had been edited by hand
176
223
  * in the page. Said at the takeover, never at the request: until the line the hand edit is still what plays, and a
@@ -218,6 +265,9 @@ export class Session {
218
265
  // already-counted overflow message, so it's just tallied — this only slightly over-counts
219
266
  // the rare tail past MAX_RUNTIME_ERRORS + MAX_DROPPED_RUNTIME_ERRORS distinct messages.
220
267
  droppedRuntimeErrorOverflowCount = 0;
268
+ // The latest structure note of the active page (see handleStructureNote): one slot, replaced by every note and
269
+ // emptied by the drain. The lines are already cleaned; the version id is looked up at drain time.
270
+ structureNote = null;
221
271
  // True once a stop is confirmed by the page; cleared by the next
222
272
  // successful evaluate() reply from the active page.
223
273
  stopped = false;
@@ -240,6 +290,11 @@ export class Session {
240
290
  pendingChange = null;
241
291
  // The notes of waiting changes that did not take over, queued once and drained once, like historyNotes.
242
292
  takeoverNotes = [];
293
+ // The tempo ramp the page runs, as far as the server knows: set when a reply says a ramp started (or the takeover of a
294
+ // change whose ramp starts at its line), cleared by Stop, a page end and a page loss. Null when none runs.
295
+ glide = null;
296
+ // The notes of a ramp that ended early, queued once and drained once, like takeoverNotes.
297
+ glideNotes = [];
243
298
  // The notes of load failure reports a page sent, one per distinct text (at most three), drained once.
244
299
  loadFailureNotes = new Set();
245
300
  // BPM: the tempo (rounded to one decimal) the last set_code or restore_version reply stated; null before any.
@@ -259,6 +314,8 @@ export class Session {
259
314
  supersededPages = new Set();
260
315
  /** The page-to-Claude channel (tokens, trust, limits, queue, listener). */
261
316
  ask;
317
+ /** What became of each page request: started, waiting for its line, applied and the other states. */
318
+ requests;
262
319
  /** The MIDI facts of the active page, worded once per change into notice lines. */
263
320
  midi;
264
321
  // The setup file's store (null without one), the JSON of the last midi-rig each page was
@@ -290,12 +347,36 @@ export class Session {
290
347
  this.rig = options?.rig ?? null;
291
348
  this.build = options?.build ?? null;
292
349
  this.recordIntentTtlMs = options?.recordIntentTtlMs ?? RECORD_INTENT_TTL_MS;
350
+ const send = (conn, msg, what) => this.sendToPage(conn, msg, what);
351
+ this.requests = new RequestStatusBoard({ send });
293
352
  this.ask = new AskChannel({
294
- send: (conn, msg, what) => this.sendToPage(conn, msg, what),
353
+ send,
354
+ board: this.requests,
295
355
  ackTimeoutMs: options?.askAckTimeoutMs,
296
356
  });
297
357
  this.midi = new MidiChannel();
298
358
  }
359
+ /** A Moontide tool call began: the requests already handed to Claude are being worked on. */
360
+ noteToolCall() {
361
+ this.requests.noteToolCall();
362
+ }
363
+ /**
364
+ * Claude closes an open page request in words. The sentence is cleaned first; one that is empty after
365
+ * cleaning, and an id that is not an open request, are refused with a plain error and change nothing.
366
+ */
367
+ answerRequest(id, outcome, text) {
368
+ const sentence = cleanAnswerText(text);
369
+ if (sentence === '') {
370
+ throw new Error('The sentence is empty after removing spaces and control characters; give one sentence of words.');
371
+ }
372
+ if (!this.requests.closed(id, outcome, sentence)) {
373
+ throw new Error(NOT_OPEN_REQUEST_MESSAGE);
374
+ }
375
+ }
376
+ /** Whether a page request with this id is on the board and not finished. */
377
+ requestIsOpen(id) {
378
+ return this.requests.isOpen(id);
379
+ }
299
380
  setPageUrl(url) {
300
381
  this.pageUrl = url;
301
382
  }
@@ -341,6 +422,42 @@ export class Session {
341
422
  return;
342
423
  this.pendingChange = null;
343
424
  this.takeoverNotes.push(takeoverCanceledLine(waiting.cycle, reason));
425
+ const answering = waiting.intent?.answering;
426
+ if (answering === undefined)
427
+ return;
428
+ if (reason !== 'replaced') {
429
+ this.requests.canceled(answering);
430
+ }
431
+ else if (!this.answeringStillCarried(answering, waiting.id)) {
432
+ this.requests.replaced(answering);
433
+ }
434
+ }
435
+ /**
436
+ * The ramp ended. Clears the record and queues its one note for the next tool reply, except for a finished ramp and
437
+ * for Stop, which are not told. A no-op when no ramp is recorded, so a second cause (a cancel, then a closing page)
438
+ * never adds a second line.
439
+ */
440
+ dropGlide(reason, bpm) {
441
+ if (this.glide === null)
442
+ return;
443
+ this.glide = null;
444
+ if (reason === 'finished' || reason === 'stop')
445
+ return;
446
+ this.glideNotes.push(glideEndedLine(reason, bpm));
447
+ }
448
+ /**
449
+ * Whether a change other than this one still in flight answers the same request. A newer change that
450
+ * names the request carries its answer on, so the request must not end as replaced while that answer is
451
+ * still coming: the newer change settles it (applied, failed or replaced in its turn).
452
+ */
453
+ answeringStillCarried(answering, exceptEvaluateId) {
454
+ for (const [evaluateId, entry] of this.pending) {
455
+ if (entry.kind === 'evaluate' && evaluateId !== exceptEvaluateId) {
456
+ if (this.recordIntents.get(evaluateId)?.answering === answering)
457
+ return true;
458
+ }
459
+ }
460
+ return false;
344
461
  }
345
462
  /**
346
463
  * Keeps the change the page accepted for its line. One at most (last wins): a change that is
@@ -348,16 +465,32 @@ export class Session {
348
465
  * itself (takeover-canceled 'replaced').
349
466
  */
350
467
  keepWaitingChange(change) {
351
- if (this.pendingChange !== null && this.pendingChange.id !== change.id) {
352
- console.error(`[moontide] A waiting change (${this.pendingChange.id}) was replaced by request ${change.id} without a cancel message`);
468
+ const replaced = this.pendingChange;
469
+ if (replaced !== null && replaced.id !== change.id) {
470
+ console.error(`[moontide] A waiting change (${replaced.id}) was replaced by request ${change.id} without a cancel message`);
471
+ // The same rule as a cancel message: the request ends as replaced unless the new change answers it too.
472
+ const answering = replaced.intent?.answering;
473
+ if (answering !== undefined && answering !== change.intent?.answering) {
474
+ this.requests.replaced(answering);
475
+ }
353
476
  }
354
477
  this.pendingChange = change;
478
+ if (change.intent?.answering !== undefined) {
479
+ this.requests.line(change.intent.answering, change.at === 'phrase' ? 'phrase' : 'bar');
480
+ }
355
481
  }
356
482
  /**
357
483
  * BPM: rounds the tempo of a reply to one decimal and says whether it differs from the last tempo a reply
358
484
  * stated. A reply without a tempo leaves the last told tempo alone.
359
485
  */
360
- noteTempo(bpm) {
486
+ noteTempo(bpm, glide) {
487
+ // A reply that states a ramp says the ramp, not a tempo change: the ramp's target is the tempo told from here on, so the
488
+ // next reply never announces it as a change when the ramp gets there.
489
+ if (glide !== undefined) {
490
+ const target = Math.round(glide.toBpm * 10) / 10;
491
+ this.lastToldBpm = target;
492
+ return { bpm: bpm === undefined ? null : Math.round(bpm * 10) / 10, tempoChanged: false };
493
+ }
361
494
  if (bpm === undefined)
362
495
  return { bpm: null, tempoChanged: false };
363
496
  const rounded = Math.round(bpm * 10) / 10;
@@ -383,6 +516,7 @@ export class Session {
383
516
  // A change that waits does not survive a page taking the role: the page that held its gate is
384
517
  // gone or replays its code, so Claude is told once (the line is queued once however many causes meet).
385
518
  this.dropWaitingChange('page');
519
+ this.dropGlide('page');
386
520
  this.supersededPages.delete(conn);
387
521
  if (previous !== null && previous !== conn) {
388
522
  this.supersededPages.add(previous);
@@ -437,6 +571,7 @@ export class Session {
437
571
  if (this.activePage === conn) {
438
572
  this.activePage = null;
439
573
  this.dropWaitingChange('page');
574
+ this.dropGlide('page');
440
575
  this.midi.clear();
441
576
  this.lastMidiFacts = null;
442
577
  // A superseded tab says "playing in another tab": tell it that no tab plays any more, or it
@@ -545,6 +680,10 @@ export class Session {
545
680
  this.handleRuntimeError(conn, msg);
546
681
  return;
547
682
  }
683
+ if (msg.type === 'structure-note') {
684
+ this.handleStructureNote(conn, msg);
685
+ return;
686
+ }
548
687
  if (msg.type === 'stop-result') {
549
688
  this.handleStopResult(conn, msg);
550
689
  return;
@@ -557,6 +696,10 @@ export class Session {
557
696
  this.handleTakeoverCanceled(conn, msg);
558
697
  return;
559
698
  }
699
+ if (msg.type === 'glide-ended') {
700
+ this.handleGlideEnded(conn, msg);
701
+ return;
702
+ }
560
703
  if (msg.type === 'user-stopped') {
561
704
  this.handleUserStopped(conn);
562
705
  return;
@@ -598,6 +741,13 @@ export class Session {
598
741
  this.ask.handleAsk(conn, msg);
599
742
  return;
600
743
  }
744
+ if (msg.type === 'ask-board-request') {
745
+ const requests = this.requests.snapshotFor(conn);
746
+ // The board told the page where each request stands; the hand-over messages that follow reach it too.
747
+ this.ask.adoptOrigin(conn, requests.map((row) => row.id));
748
+ this.sendToPage(conn, { type: 'ask-board', requests }, 'the request board');
749
+ return;
750
+ }
601
751
  if (msg.type === 'history-request') {
602
752
  this.handleHistoryRequest(conn);
603
753
  return;
@@ -1140,6 +1290,8 @@ export class Session {
1140
1290
  return;
1141
1291
  }
1142
1292
  this.stopped = true;
1293
+ // Stop ends a ramp too; it is the user's own act, so Claude is not told.
1294
+ this.dropGlide('stop');
1143
1295
  // A stop the page reports ends a wait too; the page's takeover-canceled for the same id (sent
1144
1296
  // just before) already queued the line, and a second cause adds nothing.
1145
1297
  this.dropWaitingChange('stop');
@@ -1161,6 +1313,7 @@ export class Session {
1161
1313
  cycle: msg.pending.cycle,
1162
1314
  at: lateIntent?.at ?? 'bar',
1163
1315
  intent: lateIntent,
1316
+ ...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
1164
1317
  });
1165
1318
  console.error(`[moontide] Late pending confirmation kept for request ${msg.id}`);
1166
1319
  }
@@ -1183,6 +1336,15 @@ export class Session {
1183
1336
  this.handFailureLine = null;
1184
1337
  const lateVersionId = this.recordConfirmed(conn, msg.code, lateOrigin, lateIntent);
1185
1338
  this.rememberVersion(msg.id, lateVersionId);
1339
+ // A reply whose audio is stopped records no ramp: Stop ends every ramp, so none runs.
1340
+ // An at-line report on a reply with no pending line is a waiting change that already took over: its ramp started at its line.
1341
+ if ((msg.glide?.state === 'started' || msg.glide?.state === 'at-line') && msg.audio !== 'stopped') {
1342
+ this.glide = { toBpm: msg.glide.toBpm };
1343
+ }
1344
+ // This branch does not pass through confirmSuccess, so it tells the page itself.
1345
+ if (lateIntent?.answering !== undefined) {
1346
+ this.requests.applied(lateIntent.answering, lateVersionId);
1347
+ }
1186
1348
  console.error(`[moontide] Late confirmation stored for request ${msg.id}`);
1187
1349
  }
1188
1350
  else if (msg.ok) {
@@ -1216,6 +1378,7 @@ export class Session {
1216
1378
  cycle: msg.pending.cycle,
1217
1379
  at: intent?.at ?? 'bar',
1218
1380
  intent,
1381
+ ...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
1219
1382
  });
1220
1383
  entry.resolve({
1221
1384
  code: msg.code,
@@ -1224,17 +1387,25 @@ export class Session {
1224
1387
  replacedHandEdit: false,
1225
1388
  versionId: null,
1226
1389
  pending: msg.pending,
1227
- ...this.noteTempo(msg.bpm),
1390
+ ...this.noteTempo(msg.bpm, msg.glide),
1391
+ ...(msg.glide !== undefined ? { glide: msg.glide } : {}),
1228
1392
  });
1229
1393
  }
1230
1394
  else {
1231
1395
  const { replacedHandEdit, versionId } = this.confirmSuccess(conn, msg.id, msg.code, msg.audio, intent);
1396
+ // A reply whose audio is stopped states and records no ramp: Stop ends every ramp, so none runs.
1397
+ const glide = msg.audio === 'stopped' ? undefined : msg.glide;
1398
+ // A ramp this change started is the one the server tells about when it ends early; a running report leaves the record.
1399
+ // An at-line report here is a waiting change that already took over when it was answered: its ramp started at its line.
1400
+ if (glide?.state === 'started' || glide?.state === 'at-line')
1401
+ this.glide = { toBpm: glide.toBpm };
1232
1402
  entry.resolve({
1233
1403
  code: msg.code,
1234
1404
  audio: msg.audio,
1235
1405
  replacedHandEdit,
1236
1406
  versionId,
1237
- ...this.noteTempo(msg.bpm),
1407
+ ...this.noteTempo(msg.bpm, glide),
1408
+ ...(glide !== undefined ? { glide } : {}),
1238
1409
  });
1239
1410
  }
1240
1411
  }
@@ -1249,7 +1420,24 @@ export class Session {
1249
1420
  }
1250
1421
  }
1251
1422
  else {
1423
+ const failedIntent = this.recordIntents.get(msg.id);
1252
1424
  this.recordIntents.delete(msg.id);
1425
+ const failedFor = failedIntent?.answering;
1426
+ // The same rule as a replaced change: an earlier change for the same request that still waits for
1427
+ // its line, or is still on its way, carries the answer on and settles the request in its turn.
1428
+ // A ramp the page refused was refused whole and nothing changed, so the request stays open for the change Claude sends
1429
+ // next (without glideBars), which can still answer it.
1430
+ if (msg.glideRefused !== true &&
1431
+ failedFor !== undefined &&
1432
+ this.pendingChange?.intent?.answering !== failedFor &&
1433
+ !this.answeringStillCarried(failedFor, msg.id)) {
1434
+ this.requests.failed(failedFor, cleanErrorDetail(msg.error));
1435
+ }
1436
+ if (msg.glideRefused === true) {
1437
+ // The page's own refusal text, whole: it already says that nothing was changed, so no evaluation-failure tail is added.
1438
+ entry.reject(new Error(cleanRefusal(msg.error)));
1439
+ return;
1440
+ }
1253
1441
  const tail = this.code === '' ? NOTHING_PLAYING_TAIL : this.stopped ? STILL_STOPPED_TAIL : STILL_PLAYING_TAIL;
1254
1442
  entry.reject(new Error(evalFailedMessage(msg.error, msg.line, tail)));
1255
1443
  }
@@ -1274,12 +1462,17 @@ export class Session {
1274
1462
  this.handFailureLine = null;
1275
1463
  const versionId = this.recordConfirmed(conn, code, origin, intent);
1276
1464
  this.rememberVersion(id, versionId);
1465
+ if (intent?.answering !== undefined) {
1466
+ this.requests.applied(intent.answering, versionId);
1467
+ }
1277
1468
  return { replacedHandEdit, versionId };
1278
1469
  }
1279
1470
  /**
1280
1471
  * The page says the waiting change is audible now. Accepted only from the active page and only for
1281
1472
  * the id of the change that waits; anything else is one stderr line and changes nothing. The message carries
1282
- * only the id, so the recorded code is the one stored from the pending reply.
1473
+ * the id and, when the ramp the waiting change started at its line still runs, `glideRuns: true`; the recorded code
1474
+ * is the one stored from the pending reply, and the ramp record is set from `glideRuns` (or cleared without it) for a
1475
+ * change that asked for a ramp.
1283
1476
  */
1284
1477
  handleTakeover(conn, msg) {
1285
1478
  if (conn !== this.activePage) {
@@ -1293,6 +1486,11 @@ export class Session {
1293
1486
  }
1294
1487
  this.pendingChange = null;
1295
1488
  const { replacedHandEdit } = this.confirmSuccess(conn, waiting.id, waiting.code, 'playing', waiting.intent);
1489
+ // A change that asked for a ramp at its line ended any older ramp there. Its own ramp is the recorded ramp from here on only
1490
+ // when the page says it still runs; otherwise none runs (nothing to ramp between, or it already ended) and the record is
1491
+ // cleared with no line. A change replaced or canceled before its line never gets here.
1492
+ if (waiting.glide !== undefined)
1493
+ this.glide = msg.glideRuns === true ? { toBpm: waiting.glide.toBpm } : null;
1296
1494
  // An edit that carried a hand edit replaced nothing of it: the Mixer and Parts refuse while a change
1297
1495
  // waits and a hand evaluation cancels the wait, so the code playing at this takeover is still the hand-edited
1298
1496
  // text the edit was built on, and the note would be false.
@@ -1317,6 +1515,17 @@ export class Session {
1317
1515
  }
1318
1516
  this.dropWaitingChange(msg.reason);
1319
1517
  }
1518
+ /**
1519
+ * The page says its tempo ramp ended on its own. Only the active page is trusted; the numbers are bounded by the
1520
+ * protocol schema and the wording is the server's.
1521
+ */
1522
+ handleGlideEnded(conn, msg) {
1523
+ if (conn !== this.activePage) {
1524
+ console.error('[moontide] Ignoring glide-ended from a non-active page');
1525
+ return;
1526
+ }
1527
+ this.dropGlide(msg.reason, msg.bpm === undefined ? undefined : Math.round(msg.bpm * 10) / 10);
1528
+ }
1320
1529
  /**
1321
1530
  * The editedByHand flag after a confirmed evaluation: Claude's code makes the stored code
1322
1531
  * Claude's again (false). A change made from the page (origin 'page') is the user's:
@@ -1484,6 +1693,7 @@ export class Session {
1484
1693
  if (conn === this.activePage) {
1485
1694
  // A late stop confirmation still reflects reality — the page did stop.
1486
1695
  this.stopped = true;
1696
+ this.dropGlide('stop');
1487
1697
  this.dropWaitingChange('stop');
1488
1698
  console.error(`[moontide] Late stop confirmation stored for request ${msg.id}`);
1489
1699
  }
@@ -1501,6 +1711,7 @@ export class Session {
1501
1711
  this.pending.delete(msg.id);
1502
1712
  if (conn === this.activePage) {
1503
1713
  this.stopped = true;
1714
+ this.dropGlide('stop');
1504
1715
  this.dropWaitingChange('stop');
1505
1716
  entry.resolve(undefined);
1506
1717
  }
@@ -1549,6 +1760,26 @@ export class Session {
1549
1760
  this.droppedRuntimeErrorOverflowCount++;
1550
1761
  }
1551
1762
  }
1763
+ /**
1764
+ * What the page read from the song structure of the code that plays. Accepted only from the active page, like a
1765
+ * runtime error. The latest note replaces the earlier one (an empty list clears it); every line is cleaned here and
1766
+ * the drain puts a fixed lead and the version in front of it. A note is a fact about the structure, never a runtime
1767
+ * error, so it never changes the status of a reply.
1768
+ */
1769
+ handleStructureNote(conn, msg) {
1770
+ if (conn !== this.activePage) {
1771
+ console.error('[moontide] Ignoring a structure note from a non-active page');
1772
+ return;
1773
+ }
1774
+ const lines = [];
1775
+ for (const raw of msg.lines) {
1776
+ const cleaned = cleanStructureLine(raw);
1777
+ if (cleaned !== null) {
1778
+ lines.push(cleaned);
1779
+ }
1780
+ }
1781
+ this.structureNote = { tag: msg.evaluateId ?? null, lines };
1782
+ }
1552
1783
  /**
1553
1784
  * The MIDI facts of the page. Used only from the active page, like a runtime error:
1554
1785
  * a stale page's facts do not describe what is playing.
@@ -1811,6 +2042,14 @@ export class Session {
1811
2042
  ? 'Note: 1 more distinct runtime error was not shown.'
1812
2043
  : `Note: ${n} more distinct runtime errors were not shown.`);
1813
2044
  }
2045
+ if (this.structureNote !== null) {
2046
+ const { tag, lines: noteLines } = this.structureNote;
2047
+ const versionId = tag !== null ? this.versionByEvaluateId.get(tag) ?? null : null;
2048
+ for (const line of noteLines) {
2049
+ lines.push(structureNoteLine(line, versionId));
2050
+ }
2051
+ this.structureNote = null;
2052
+ }
1814
2053
  this.runtimeErrors.clear();
1815
2054
  this.droppedRuntimeErrors.clear();
1816
2055
  this.droppedRuntimeErrorOverflowCount = 0;
@@ -1827,6 +2066,9 @@ export class Session {
1827
2066
  // A change that waited for a line and did not take over, told once.
1828
2067
  lines.push(...this.takeoverNotes);
1829
2068
  this.takeoverNotes.length = 0;
2069
+ // A tempo ramp that ended early, told once.
2070
+ lines.push(...this.glideNotes);
2071
+ this.glideNotes.length = 0;
1830
2072
  // The build line comes before the setup file lines, then the MIDI lines, then the ask
1831
2073
  // lines (the arming line stays last). Runtime error lines and the history notes come earlier.
1832
2074
  lines.push(...this.buildNotes);
@@ -1896,6 +2138,8 @@ export class Session {
1896
2138
  force: false,
1897
2139
  at: options?.at,
1898
2140
  phraseCycles: options?.phraseCycles,
2141
+ glideBars: options?.glideBars,
2142
+ answering: options?.answering,
1899
2143
  });
1900
2144
  }
1901
2145
  /**
@@ -1954,6 +2198,8 @@ export class Session {
1954
2198
  force: false,
1955
2199
  at: options?.at,
1956
2200
  phraseCycles: options?.phraseCycles,
2201
+ glideBars: options?.glideBars,
2202
+ answering: options?.answering,
1957
2203
  carriesHandEdit,
1958
2204
  }).then((outcome) => ({ outcome, code: result.code, ranges: result.ranges, base, carriesHandEdit }));
1959
2205
  }
@@ -2001,6 +2247,7 @@ export class Session {
2001
2247
  label: restoredLabel(id),
2002
2248
  restoredFrom: id,
2003
2249
  origin: options?.origin,
2250
+ answering: options?.answering,
2004
2251
  force: true,
2005
2252
  });
2006
2253
  }
@@ -2020,6 +2267,8 @@ export class Session {
2020
2267
  message.at = intent.at;
2021
2268
  if (intent.phraseCycles !== undefined)
2022
2269
  message.phraseCycles = intent.phraseCycles;
2270
+ if (intent.glideBars !== undefined)
2271
+ message.glideBars = intent.glideBars;
2023
2272
  return message;
2024
2273
  });
2025
2274
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moontide-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.konni-arcadia/moontide",
5
5
  "description": "Claude steers live music by writing Strudel code played in a browser page",
6
6
  "keywords": [