moontide-mcp 2.1.0 → 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.
package/README.md CHANGED
@@ -41,8 +41,11 @@ change.
41
41
  - Around the code, the page shows the music in several views: the named tracks on a timeline
42
42
  where you can edit notes, the Song grid with the sections of a piece, the history as a list
43
43
  or a tree, a mixer with groups and lights whose moves rewrite the code, and a jam view with
44
- the scale on a piano or a guitar.
45
- - A right-click on anything in the page sends Claude a request with that context.
44
+ the scale on a piano or a guitar. When the notes fit several scales, the jam view proposes
45
+ one and says why.
46
+ - Claude can move the tempo up or down over a number of bars, instead of jumping.
47
+ - A right-click on anything in the page sends Claude a request with that context. The Requests
48
+ list in the top bar shows what became of each request.
46
49
  - The same code can play real MIDI instruments from the page, through Web MIDI.
47
50
 
48
51
  Moontide does not start from scratch. The music engine is
@@ -70,6 +73,20 @@ Strudel contributors. Moontide is the bridge between Claude and a Strudel page.
70
73
  bar) says whether page requests are live (they reach Claude right away) or wait for Claude
71
74
  (they reach it with its next action). Its (i) explains how to switch.
72
75
 
76
+ The **Requests** button in the top bar counts the requests still in flight and opens a list:
77
+ one row per request, with what happened to it and how long ago. A request reads Sent, then
78
+ Waiting for Claude's next action or Received, then Claude has started on it, which means
79
+ Claude made a call after the request arrived. A change made for it reads Waiting for its
80
+ line while it waits for the next bar or phrase, then Applied as v12 (the version in the
81
+ History view); a change that failed reads Failed with the error, and one that a newer change
82
+ replaced or Stop canceled says so. A request Claude answers in words reads Answered or
83
+ Declined, with Claude's sentence. A request shows as applied only when Claude names it with
84
+ its change, never from timing alone. After 30 seconds without news a row reads No change yet
85
+ and keeps counting: that is not a failure. If the page loses its connection, open rows read
86
+ Connection lost. Click a row to see the whole request and what its
87
+ state means. Finished rows move to a short Recent list after 30 seconds. Nothing is kept
88
+ after the Moontide server stops.
89
+
73
90
  ## Install
74
91
 
75
92
  The short version, for someone who already has Node 22 or later and Claude Code:
@@ -274,6 +291,15 @@ The code can say which harmony it means with one top-level line, for example
274
291
  `const harmony = 'C:minor'`, placed above the patterns. It plays nothing and is never a
275
292
  track. The Jam view then shows two lines, "Declared:" and "Heard:", side by side.
276
293
 
294
+ When the notes fit several scales, the Jam view proposes one and says why in a sentence, for
295
+ example "Proposed: C major, C is the note heard most." It takes the declared scale of the playing
296
+ section when that scale is among the ones that fit, and otherwise the scale the notes support
297
+ most. Warnings stay on. "Scales that fit, click to switch:" lists them, and one click or Enter
298
+ switches the whole view. Your pick holds while the same scales fit, and is forgotten on reload.
299
+ When the scales differ in their notes and the code declares none of them, warnings are off: the
300
+ view says why and lists the scales without a switch. The piano is coloured like the guitar, with
301
+ the same legend, and every role also has a shape or text cue, so it reads without colour.
302
+
277
303
  ## Song structure
278
304
 
279
305
  Every new piece Claude writes starts with one `const song` at the top. It names the sections
@@ -309,6 +335,30 @@ Left or Right, to select a range of whole cycles, and use "Ask Claude" to send i
309
335
 
310
336
  A right-click on a section, a track, a cell or a group asks Claude about that thing.
311
337
 
338
+ ## Tempo ramp
339
+
340
+ Ask Claude to speed up or slow down over a number of bars, for example "bring it up to 140 over
341
+ eight bars". Claude sends the new code with the tempo to reach and `glideBars`, a whole number
342
+ of bars from 1 to 64, on `set_code` or `edit_code`. The tempo then moves from the tempo that
343
+ plays to the one in the code in equal small steps, and ends exactly on it. It starts at once, or
344
+ at the line when the change waits for the next bar or phrase.
345
+
346
+ While it runs, the tempo in the top bar shows the live tempo, an arrow and the tempo to reach.
347
+ Hover it to see how many bars are left.
348
+
349
+ Your Mixer moves, Song grid cells, Timeline edits and hand edits keep the ramp going. Code with
350
+ another tempo cancels it, and that tempo plays at once. Code with no tempo cancels it, and the
351
+ tempo stays where it was. A new `glideBars` replaces it. Stop cancels it too, and the next Play
352
+ starts at the code's tempo. A reload, or another tab taking over, ends it. There is no cancel
353
+ button of its own.
354
+
355
+ A ramp is refused, and nothing changes, when nothing plays, when the code sets no tempo, or when
356
+ it sets the tempo that already plays. A ramp is never a version: it writes nothing into the code
357
+ or the history.
358
+
359
+ The MIDI clock follows every step of a ramp. How hardware that follows the clock, such as an
360
+ arpeggiator or a step sequencer, reacts to a ramp has not been verified.
361
+
312
362
  ## History
313
363
 
314
364
  Every version that plays is kept: Claude's own changes and your Ctrl+Enter edits, each with
@@ -368,6 +418,8 @@ Known limits:
368
418
  - Windows and Linux are untested by hand. Edge, Safari and Firefox are untested.
369
419
  - Internet access is needed.
370
420
  - MIDI needs Chrome or Edge.
421
+ - How hardware that follows Moontide's MIDI clock, such as an arpeggiator or a step sequencer,
422
+ reacts to a tempo ramp has not been verified.
371
423
  - Run one Moontide at a time with the same history file.
372
424
  - The first start through npx downloads the package and can be slow.
373
425
 
package/dist/ask.js CHANGED
@@ -58,11 +58,24 @@ export const REQUEST_LINE_MOMENT_FACT = 'About the moment field of a page reques
58
58
  'turn the note into a rest. Kind velocity has direction, softer or louder, for the note\'s velocity. ' +
59
59
  'About a request whose context has kind range: the user selected whole cycles of one lane. track is the lane, from and to ' +
60
60
  `are the first and last cycle, both included (a range covers at most ${MAX_RANGE_CYCLES} cycles), and in a piece with a song ` +
61
- 'section is the section of cycle from and sectionTo the section of cycle to when it differs. Such a request has no moment; ' +
61
+ 'sections lists the sections those cycles cross in playing order, a section played twice in a row once and a section that ' +
62
+ 'comes back after another one again, and moreSections, when present, counts the sections left out so that the list fits. ' +
63
+ 'Such a request has no moment; ' +
62
64
  'its gesture has kind range. The gesture\'s notes lists the notes of that lane that start in those cycles, in order of ' +
63
65
  `start and then pitch (the lower first), at most ${MAX_MOMENT_NOTES}, each shaped like a moment note without state; more ` +
64
66
  'counts the notes left out, and version is the version that was playing (null when it was not known). A note that began ' +
65
67
  'before from and still sounds is not listed. A range with no notes in it is a valid request.';
68
+ /**
69
+ * What the page shows for a request, as facts. Like the moment fact it goes where Claude reads page requests
70
+ * and nothing limits the length: the arming line, the page-requests-on text and the line in front of request
71
+ * lines that a tool reply hands over without an arming line. It says what the page shows and when, never what
72
+ * to do about it.
73
+ */
74
+ export const REQUEST_LINE_STATUS_FACT = 'About the id field of a page request: the page shows the user what became of each request it sent. ' +
75
+ 'It shows a request as applied, or as waiting for its line when the change takes over at the next bar or phrase, ' +
76
+ 'only when the set_code, edit_code or restore_version call that answers it passes that id as answering. ' +
77
+ 'It shows a request as answered or declined, with one sentence, only after answer_request closes it with that id. ' +
78
+ 'A request with neither shows as no change yet. An id that is not an open request changes nothing.';
66
79
  /**
67
80
  * A log function that writes at most one line per window for each kind, to stderr. The next line
68
81
  * that does get written says how many were left out. Every log that a page or a local process can
@@ -148,7 +161,7 @@ export function listenerCommandOrReason(options) {
148
161
  */
149
162
  export function armingLine(options) {
150
163
  const head = 'Page requests: no listener is connected. A request typed in the Moontide page waits on the server and arrives in the reply of your next other Moontide tool. ' +
151
- `${REQUEST_LINE_FRAMING} ${REQUEST_LINE_MOMENT_FACT}`;
164
+ `${REQUEST_LINE_FRAMING} ${REQUEST_LINE_MOMENT_FACT} ${REQUEST_LINE_STATUS_FACT}`;
152
165
  const result = listenerCommandOrReason(options);
153
166
  if ('reason' in result) {
154
167
  return `${head} ${result.reason}`;
@@ -199,6 +212,7 @@ export function pageRequestsOnText(options) {
199
212
  `Note: ${MONITOR_LIMIT_FACT}; when a Moontide tool reply gives this command again, run it again the same way.`,
200
213
  REQUEST_LINE_FRAMING,
201
214
  REQUEST_LINE_MOMENT_FACT,
215
+ REQUEST_LINE_STATUS_FACT,
202
216
  ].join('\n');
203
217
  }
204
218
  export const PAGE_REQUESTS_ALREADY_ON = 'Moontide page requests are already live: a Moontide monitor is connected and brings each request as soon as it is sent. ' +
@@ -290,6 +304,8 @@ export class AskChannel {
290
304
  /** Given only to Claude, in the arming line of a tool reply; changes at every server start. */
291
305
  listenerToken = newToken();
292
306
  send;
307
+ // Told when a request is accepted, handed to Claude, sent back to the queue or loses its page.
308
+ board;
293
309
  ackTimeoutMs;
294
310
  now;
295
311
  trusted = new Set();
@@ -307,6 +323,7 @@ export class AskChannel {
307
323
  logRefusal;
308
324
  constructor(options) {
309
325
  this.send = options.send;
326
+ this.board = options.board;
310
327
  this.ackTimeoutMs = options.ackTimeoutMs ?? LISTENER_ACK_TIMEOUT_MS;
311
328
  this.now = options.now ?? Date.now;
312
329
  this.logRefusal = makeThrottledLog(ASK_RATE_WINDOW_MS, this.now);
@@ -409,8 +426,11 @@ export class AskChannel {
409
426
  this.refuse(conn, msg.id, 'line-too-long');
410
427
  return;
411
428
  }
412
- // The id is chosen by the page. A repeat would overwrite the in-flight slot and orphan its timer.
413
- if (this.inFlight.has(msg.id) || this.queue.some((waiting) => waiting.id === msg.id)) {
429
+ // The id is chosen by the page. A repeat would overwrite the in-flight slot and orphan its timer, or
430
+ // take over the board row of a request Claude already holds, which may be another page's.
431
+ if (this.inFlight.has(msg.id) ||
432
+ this.queue.some((waiting) => waiting.id === msg.id) ||
433
+ this.board?.has(msg.id) === true) {
414
434
  this.logRefusal('duplicate', '[moontide] Refused a page request: its id is already in use');
415
435
  this.refuse(conn, msg.id, 'invalid', 'duplicate request id');
416
436
  return;
@@ -418,6 +438,7 @@ export class AskChannel {
418
438
  this.recent.push(now);
419
439
  const entry = { id: msg.id, line, origin: conn, answered: 'none' };
420
440
  if (this.listener !== null) {
441
+ this.board?.opened(msg.id, conn, { queued: false, text: msg.text, chip: msg.chip });
421
442
  this.dispatch(entry);
422
443
  return;
423
444
  }
@@ -427,6 +448,7 @@ export class AskChannel {
427
448
  return;
428
449
  }
429
450
  entry.answered = 'waiting';
451
+ this.board?.opened(msg.id, conn, { queued: true, text: msg.text, chip: msg.chip });
430
452
  this.queue.push(entry);
431
453
  this.send(conn, { type: 'ask-result', id: msg.id, status: 'waiting', waiting: this.queue.length }, 'the ask answer');
432
454
  }
@@ -479,6 +501,7 @@ export class AskChannel {
479
501
  this.pushPageRequestsState();
480
502
  const requeued = this.requeueInFlight();
481
503
  for (const entry of requeued) {
504
+ this.board?.waiting(entry.id);
482
505
  if (entry.answered === 'none') {
483
506
  entry.answered = 'waiting';
484
507
  if (entry.origin !== null) {
@@ -512,6 +535,7 @@ export class AskChannel {
512
535
  }
513
536
  this.inFlight.delete(ack);
514
537
  const { entry } = slot;
538
+ this.board?.delivered([entry.id]);
515
539
  if (entry.origin === null) {
516
540
  return;
517
541
  }
@@ -525,6 +549,7 @@ export class AskChannel {
525
549
  /** A page connection closed: its trust goes, and nobody is answered for its requests any more. */
526
550
  detachPage(conn) {
527
551
  this.trusted.delete(conn);
552
+ this.board?.pageGone(conn);
528
553
  for (const entry of this.queue) {
529
554
  if (entry.origin === conn) {
530
555
  entry.origin = null;
@@ -541,17 +566,31 @@ export class AskChannel {
541
566
  }
542
567
  this.untrustedRefusals.delete(conn);
543
568
  }
569
+ /**
570
+ * A reloaded page took over these requests from the board: the ones still waiting or in flight whose
571
+ * page has gone now answer to it, so it is told when they are handed over or go back to waiting.
572
+ */
573
+ adoptOrigin(conn, ids) {
574
+ const adopted = new Set(ids);
575
+ const entries = [...this.queue, ...[...this.inFlight.values()].map((slot) => slot.entry)];
576
+ for (const entry of entries) {
577
+ if (entry.origin === null && adopted.has(entry.id)) {
578
+ entry.origin = conn;
579
+ }
580
+ }
581
+ }
544
582
  /**
545
583
  * Called by the tool replies: takes the WHOLE waiting queue at once and returns its request lines
546
584
  * (oldest first), telling each page its requests were handed over. Then, while no listener is
547
585
  * connected, a page has authenticated and page-requests-off has not paused it, the arming line. When
548
- * there is request text but no arming line, the moment fact goes in front of the request lines.
586
+ * there is request text but no arming line, the moment fact and the status fact go in front of the request lines.
549
587
  */
550
588
  drainLines(options) {
551
589
  const lines = [];
552
590
  const entries = this.queue;
553
591
  this.queue = [];
554
592
  const handedOver = new Map();
593
+ this.board?.delivered(entries.map((entry) => entry.id));
555
594
  for (const entry of entries) {
556
595
  lines.push(entry.line);
557
596
  if (entry.origin !== null) {
@@ -564,13 +603,13 @@ export class AskChannel {
564
603
  this.send(origin, { type: 'ask-received', ids }, 'the ask hand-over');
565
604
  }
566
605
  if (!this.hasListener() && this.hasTrustedPage() && !this.armingPaused) {
567
- // The arming line carries the moment fact itself.
606
+ // The arming line carries both facts itself.
568
607
  lines.push(armingLine({ scriptPath: options.scriptPath, port: options.port, token: this.listenerToken }));
569
608
  }
570
609
  else if (lines.length > 0) {
571
610
  // No arming line (page requests are paused, or a listener is connected): the reply still says what the
572
- // moment of the request lines it carries holds.
573
- lines.unshift(REQUEST_LINE_MOMENT_FACT);
611
+ // moment of the request lines it carries holds and what the page shows for a request.
612
+ lines.unshift(REQUEST_LINE_MOMENT_FACT, REQUEST_LINE_STATUS_FACT);
574
613
  }
575
614
  return lines;
576
615
  }
package/dist/mcp.js CHANGED
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { EDIT_NOTHING_PLAYING_MESSAGE, NOT_STARTED_MESSAGE } from './session.js';
8
8
  import { PAGE_WAIT_MS, START_INSTRUCTION, START_PROMPT, START_PROMPT_DESCRIPTION, START_PROMPT_TITLE, START_TOOL, START_TOOL_DESCRIPTION, notStartedForRequestsText, notStartedForRequestsToolText, pageLine, startMoontide, } from './start.js';
9
9
  import { MAX_EDITS, changedExcerpt } from './edit-code.js';
10
- import { MAX_PHRASE_CYCLES, MIN_PHRASE_CYCLES } from './protocol.js';
10
+ import { MAX_ANSWER_TEXT_LENGTH, MAX_ASK_ID_LENGTH, MAX_GLIDE_BARS, MAX_PHRASE_CYCLES, MIN_GLIDE_BARS, MIN_PHRASE_CYCLES, } from './protocol.js';
11
11
  import { MAX_LABEL_LENGTH, MAX_FAVORITE_NAME_LENGTH } from './history.js';
12
12
  import { PAGE_REQUESTS_ALREADY_OFF, PAGE_REQUESTS_ALREADY_ON, PAGE_REQUESTS_OFF_DESCRIPTION, PAGE_REQUESTS_OFF_PROMPT, PAGE_REQUESTS_OFF_TEXT, PAGE_REQUESTS_OFF_TITLE, PAGE_REQUESTS_ON_DESCRIPTION, PAGE_REQUESTS_ON_PROMPT, PAGE_REQUESTS_ON_TITLE, PAGE_REQUESTS_INSTRUCTION, PAGE_REQUESTS_STATE_DESCRIPTION, PAGE_REQUESTS_TOOL, PAGE_REQUESTS_TOOL_DESCRIPTION, REQUEST_LINE_FRAMING, listenerScriptPath, noPageForRequestsText, noPageForRequestsToolText, pageRequestsOnText, } from './ask.js';
13
13
  import { REFERENCE_TOPICS, REFERENCE_TOPIC_IDS, REFERENCE_URI_PREFIX, readReferenceTopic, referenceIndexText, referenceToolDescription, structureTemplateText, } from './reference.js';
@@ -77,10 +77,12 @@ export function whenFlagLines(code) {
77
77
  // text: it holds no code and no page text. The music plays as written; nothing is refused, blocked or rewritten.
78
78
  export const NO_STRUCTURE_NOTE = 'Note: this new piece declares no song structure; every new piece declares one once at the top (see the structure topic of get_strudel_reference). It plays as written.';
79
79
  // A text match only, like GETTIME_CALL_RE: the code is never parsed, evaluated or changed on the server, and a match
80
- // inside a comment or a string counts too, by design. Anchored at a line start with [ \t]* (never \s*, so a run of blank
81
- // lines cannot make the scan quadratic) and ended on a word boundary, so `const songs`, `const song2`, `let song` and a
82
- // commented-out line are not a structure. The m flag only: no global flag, so test() keeps no lastIndex.
83
- const SONG_CONST_RE = /^[ \t]*const[ \t]+song\b/m;
80
+ // inside a comment or a string counts too, by design. A song declared with let or var gets its own line from the page,
81
+ // which reads only const, so the server says nothing more for it and the two lines never disagree. Anchored at a line
82
+ // start with [ \t]* (never \s*, so a run of blank lines cannot make the scan quadratic) and ended on a word boundary, so
83
+ // `const songs`, `const song2` and a commented-out line are not a song. The m flag only: no global flag, so test() keeps
84
+ // no lastIndex.
85
+ const SONG_DECLARATION_RE = /^[ \t]*(?:const|let|var)[ \t]+song\b/m;
84
86
  /** The shortest line, in characters after trimming, that counts when telling a new piece from a changed one. */
85
87
  const NEW_PIECE_MIN_LINE = 8;
86
88
  /**
@@ -105,7 +107,7 @@ function isNewPiece(previous, code) {
105
107
  }
106
108
  /** The note when `code` replaces `previousCode` as a new piece and declares no song structure, empty otherwise. */
107
109
  export function newPieceWithoutStructureLines(previousCode, code) {
108
- if (SONG_CONST_RE.test(code))
110
+ if (SONG_DECLARATION_RE.test(code))
109
111
  return [];
110
112
  return isNewPiece(previousCode, code) ? [NO_STRUCTURE_NOTE] : [];
111
113
  }
@@ -154,6 +156,24 @@ export function tempoLine(bpm, pendingCycle) {
154
156
  const shown = Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
155
157
  return pendingCycle === undefined ? `Tempo: ${shown} BPM.` : `Tempo from cycle ${pendingCycle}: ${shown} BPM.`;
156
158
  }
159
+ /**
160
+ * The one-line tempo ramp of a reply, said in place of the tempo line. Three states: a ramp that started with this change,
161
+ * a ramp that starts at the line of a waiting change (`pendingCycle` is the line), and a ramp that goes on because this
162
+ * change restated its target. The numbers come from the page, bounded by the protocol schema; only numbers are
163
+ * formatted here, so no page text enters the reply.
164
+ */
165
+ export function glideLine(glide, pendingCycle) {
166
+ const shown = (bpm) => (Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1));
167
+ if (glide.state === 'running') {
168
+ return `Tempo ramp goes on: to ${shown(glide.toBpm)} BPM, now ${shown(glide.nowBpm)} BPM.`;
169
+ }
170
+ const length = glide.bars === 1 ? 'over 1 bar' : `over ${glide.bars} bars`;
171
+ if (glide.state === 'at-line') {
172
+ const line = pendingCycle === undefined ? 'at its line' : `from cycle ${pendingCycle}`;
173
+ return `Tempo ramp ${line}: from the tempo playing then to ${shown(glide.toBpm)} BPM ${length}.`;
174
+ }
175
+ return `Tempo ramp: from ${shown(glide.fromBpm)} to ${shown(glide.toBpm)} BPM ${length}.`;
176
+ }
157
177
  /**
158
178
  * The first note of get_code while a change waits to take over: the code the reply shows is the code
159
179
  * that plays until the line.
@@ -189,16 +209,47 @@ function changeStatus(outcome, at, hadRuntimeErrors, pageUrl) {
189
209
  * the at-once note when a requested line was skipped because nothing played.
190
210
  */
191
211
  function changeNoteLines(outcome, at) {
192
- const tempo = outcome.tempoChanged && outcome.bpm !== null ? [tempoLine(outcome.bpm, outcome.pending?.cycle)] : [];
212
+ // A change that started a ramp says the ramp in place of the tempo line: the tempo that plays has not changed yet.
213
+ const tempo = outcome.glide !== undefined
214
+ ? [glideLine(outcome.glide, outcome.pending?.cycle)]
215
+ : outcome.tempoChanged && outcome.bpm !== null
216
+ ? [tempoLine(outcome.bpm, outcome.pending?.cycle)]
217
+ : [];
193
218
  const atOnce = (at === 'bar' || at === 'phrase') && outcome.pending === undefined && outcome.audio !== 'stopped'
194
219
  ? [AT_ONCE_NOTE]
195
220
  : [];
196
221
  return [...tempo, ...atOnce];
197
222
  }
198
223
  const LABEL_FIELD_DESCRIPTION = 'A few words saying what this change is, for example "added a bass"; shown by list_history';
224
+ // One description for the three tools that change the music, so Claude meets the same fact wherever it reads it.
225
+ export const ANSWERING_FIELD_DESCRIPTION = 'The id of the page request this change answers, from its request line; the page shows that request as applied only when the change names it here';
226
+ // Said in a reply when `answering` names no open page request. No user text, no id: the note is the same every time.
227
+ // The music change itself is never refused or held back for it.
228
+ export const UNKNOWN_ANSWERING_NOTE = 'Note: answering names no open page request (it is unknown or already closed), so it changed no request status; the music change is not affected by it.';
229
+ /** The note when `answering` was given and names no open request, empty otherwise. */
230
+ export function unknownAnsweringLines(answering, open) {
231
+ return answering !== undefined && !open ? [UNKNOWN_ANSWERING_NOTE] : [];
232
+ }
233
+ export const ANSWER_REQUEST_TOOL = 'answer_request';
234
+ export const ANSWER_REQUEST_DESCRIPTION = 'Closes a request typed in the Moontide page without changing the music. Use outcome answered when the request was answered in words, declined when it will not or cannot be done. ' +
235
+ `Give one sentence of at most ${MAX_ANSWER_TEXT_LENGTH} characters; the page shows it under the request. ` +
236
+ 'A change that answers a request names it with answering on set_code, edit_code or restore_version instead. ' +
237
+ 'An id that is not an open request is refused and nothing changes.';
238
+ export const ANSWER_REQUEST_ID_DESCRIPTION = 'The id of the page request to close, from its request line';
239
+ export const ANSWER_REQUEST_OUTCOME_DESCRIPTION = 'answered when the request was answered in words, declined when it will not or cannot be done';
240
+ export const ANSWER_REQUEST_TEXT_DESCRIPTION = `One sentence for the user, at most ${MAX_ANSWER_TEXT_LENGTH} characters; the page shows it under the request`;
241
+ /** The reply when a request was closed, one fixed line per outcome. */
242
+ export function answerRequestReply(outcome) {
243
+ return outcome === 'answered' ? 'The page request was closed as answered.' : 'The page request was closed as declined.';
244
+ }
199
245
  const AT_FIELD_DESCRIPTION = "When the change takes over: 'now' (the default) within a fraction of a second, 'bar' on the next bar line, 'phrase' on the next phrase line (4 cycles, counted from Play). " +
200
246
  'The code is checked at once; the reply says the cycle and about how many seconds away it is.';
201
247
  const PHRASE_CYCLES_FIELD_DESCRIPTION = "How many cycles one phrase lasts, for at: 'phrase' (2 to 64, default 4)";
248
+ // Facts only: the bounds are the only digits. It is one description for both tools that change the music's code.
249
+ export const GLIDE_BARS_FIELD_DESCRIPTION = 'Whole bars, 1 to 64, to move the tempo smoothly from the tempo that plays to the one this code sets with setcps or setcpm; with at, it starts at the line. Refused when nothing plays, no tempo is set or it already plays. Stop, or code with another tempo or none, cancels it';
250
+ // Said when set_code or edit_code asks for a ramp while nothing plays. The page holds the same text for the case the server
251
+ // cannot know about (a page that has not played yet); a test checks both. Nothing was started, opened or sent.
252
+ export const GLIDE_NOTHING_PLAYS_MESSAGE = 'Nothing plays, so there is no tempo to ramp from. Nothing was changed. Send the change again without glideBars.';
202
253
  /**
203
254
  * The tool description of edit_code. The when-to-use rule comes first; Claude Code keeps at most 2048
204
255
  * characters of a tool description.
@@ -349,6 +400,8 @@ export function createMcpServer(options) {
349
400
  'Call it before every change.',
350
401
  annotations: { readOnlyHint: true },
351
402
  }, async () => {
403
+ // Marks the page requests Claude already holds as started; first, so a request this reply hands over is not.
404
+ session.noteToolCall();
352
405
  // Before the start there is no page and no code: say how to start and start nothing. No drain, so queued
353
406
  // notices wait for the next reply.
354
407
  const server = starter.current();
@@ -382,6 +435,7 @@ export function createMcpServer(options) {
382
435
  topic: z.enum(REFERENCE_TOPIC_IDS).optional().describe('One topic to return; omit it to get the index of topics'),
383
436
  },
384
437
  }, async ({ topic }) => {
438
+ session.noteToolCall();
385
439
  try {
386
440
  const text = topic ? readReferenceTopic(topic) : referenceIndexText();
387
441
  return { content: [{ type: 'text', text }] };
@@ -422,19 +476,34 @@ export function createMcpServer(options) {
422
476
  .max(MAX_PHRASE_CYCLES)
423
477
  .optional()
424
478
  .describe(PHRASE_CYCLES_FIELD_DESCRIPTION),
479
+ glideBars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS).optional().describe(GLIDE_BARS_FIELD_DESCRIPTION),
480
+ answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
425
481
  },
426
- }, async ({ code, label, at, phraseCycles }) => {
482
+ }, async ({ code, label, at, phraseCycles, glideBars, answering }) => {
483
+ session.noteToolCall();
484
+ // An id that is not open never reaches the change: it is told once and the music change goes ahead.
485
+ const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
427
486
  try {
428
487
  // A phrase length means nothing for another timing; refused before anything reaches the page.
429
488
  if (phraseCycles !== undefined && at !== 'phrase') {
430
489
  throw new Error("phraseCycles applies only with at: 'phrase'.");
431
490
  }
491
+ // A ramp needs music to leave: refused before Moontide starts, a tab opens or anything reaches the page.
492
+ if (glideBars !== undefined && (!session.hasActivePage() || session.isStopped())) {
493
+ throw new Error(GLIDE_NOTHING_PLAYS_MESSAGE);
494
+ }
432
495
  // The first change of the music starts Moontide and waits for the page of the tab it opens.
433
496
  await starter.ensureStarted();
434
497
  await starter.waitForPage();
435
498
  // What played before this call, read before the page changes it: the new-piece note compares the two.
436
499
  const previousCode = session.getCode();
437
- const outcome = await session.evaluate(code, { label, at, phraseCycles });
500
+ const outcome = await session.evaluate(code, {
501
+ label,
502
+ at,
503
+ phraseCycles,
504
+ glideBars,
505
+ answering: answeringOpen ? answering : undefined,
506
+ });
438
507
  const { lines, hadRuntimeErrors } = session.drainNotices();
439
508
  // Claude must never be told "ok, playing" when something went wrong —
440
509
  // including when the user pressed Stop while this code was still starting.
@@ -454,6 +523,7 @@ export function createMcpServer(options) {
454
523
  ...lines,
455
524
  ...whenFlagLines(outcome.code),
456
525
  ...newPieceWithoutStructureLines(previousCode, outcome.code),
526
+ ...unknownAnsweringLines(answering, answeringOpen),
457
527
  ...getTimeAnchorLines(outcome.code),
458
528
  ]);
459
529
  return { content: [{ type: 'text', text }] };
@@ -464,7 +534,7 @@ export function createMcpServer(options) {
464
534
  // A failed set_code doesn't clear a pending hand edit, so keep surfacing it.
465
535
  const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
466
536
  const message = err instanceof Error ? err.message : String(err);
467
- throw new Error(withNoticeLines(message, [...notices, ...lines]));
537
+ throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
468
538
  }
469
539
  });
470
540
  // Changes part of the code that will play by exact replacements, through the same Session path as
@@ -502,20 +572,35 @@ export function createMcpServer(options) {
502
572
  .max(MAX_PHRASE_CYCLES)
503
573
  .optional()
504
574
  .describe(PHRASE_CYCLES_FIELD_DESCRIPTION),
575
+ glideBars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS).optional().describe(GLIDE_BARS_FIELD_DESCRIPTION),
576
+ answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
505
577
  },
506
- }, async ({ edits, base, label, at, phraseCycles }) => {
578
+ }, async ({ edits, base, label, at, phraseCycles, glideBars, answering }) => {
579
+ session.noteToolCall();
580
+ const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
507
581
  try {
508
582
  // The same rule and text as set_code, before anything reaches the page.
509
583
  if (phraseCycles !== undefined && at !== 'phrase') {
510
584
  throw new Error("phraseCycles applies only with at: 'phrase'.");
511
585
  }
586
+ // The same rule and text as set_code.
587
+ if (glideBars !== undefined && (!session.hasActivePage() || session.isStopped())) {
588
+ throw new Error(GLIDE_NOTHING_PLAYS_MESSAGE);
589
+ }
512
590
  // Nothing plays before a start, so there is nothing to edit: refuse before a tab opens for a request that cannot work.
513
591
  if (starter.current() === null) {
514
592
  throw new Error(EDIT_NOTHING_PLAYING_MESSAGE);
515
593
  }
516
594
  await starter.ensureStarted();
517
595
  await starter.waitForPage();
518
- const edit = await session.editCode(edits, { base, label, at, phraseCycles });
596
+ const edit = await session.editCode(edits, {
597
+ base,
598
+ label,
599
+ at,
600
+ phraseCycles,
601
+ glideBars,
602
+ answering: answeringOpen ? answering : undefined,
603
+ });
519
604
  const { outcome } = edit;
520
605
  const { lines, hadRuntimeErrors } = session.drainNotices();
521
606
  const status = changeStatus(outcome, at, hadRuntimeErrors, session.getPageUrl());
@@ -535,6 +620,7 @@ export function createMcpServer(options) {
535
620
  ...otherText,
536
621
  ...lines,
537
622
  ...whenFlagLines(outcome.code),
623
+ ...unknownAnsweringLines(answering, answeringOpen),
538
624
  ...getTimeAnchorLines(outcome.code),
539
625
  ]);
540
626
  return { content: [{ type: 'text', text }] };
@@ -543,7 +629,7 @@ export function createMcpServer(options) {
543
629
  const { lines } = session.drainNotices();
544
630
  const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
545
631
  const message = err instanceof Error ? err.message : String(err);
546
- throw new Error(withNoticeLines(message, [...notices, ...lines]));
632
+ throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
547
633
  }
548
634
  });
549
635
  server.registerTool('list_history', {
@@ -558,6 +644,7 @@ export function createMcpServer(options) {
558
644
  favorites: z.boolean().optional().describe('true to list only favorites'),
559
645
  },
560
646
  }, async ({ limit, filter, favorites }) => {
647
+ session.noteToolCall();
561
648
  try {
562
649
  const text = session.listHistory({ limit, filter, favoritesOnly: favorites });
563
650
  const { lines } = session.drainNotices();
@@ -580,6 +667,7 @@ export function createMcpServer(options) {
580
667
  .describe("Name to keep it under, in the user's words"),
581
668
  },
582
669
  }, async ({ id, name }) => {
670
+ session.noteToolCall();
583
671
  try {
584
672
  const text = session.favoriteVersion(id, name);
585
673
  const { lines } = session.drainNotices();
@@ -595,14 +683,17 @@ export function createMcpServer(options) {
595
683
  description: 'Plays a past version again, by its id from list_history, in one call: it takes over within a fraction of a second without stopping the music, with the same checks as set_code. The restore is added to the history as a new version ("restored from #id"); nothing is rewritten. Returns the code as confirmed by the page. A restore always takes over at once and replaces a change that waits for a bar or phrase line. The old version replaces the music at once; when the user asked for gradual changes this session, bring it in with set_code and a transition instead. Like set_code, it starts Moontide first when it is not started yet, but only for an id that exists in the history.',
596
684
  inputSchema: {
597
685
  id: z.number().int().positive().describe('Version id from list_history, for example 41 for #41'),
686
+ answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
598
687
  },
599
- }, async ({ id }) => {
688
+ }, async ({ id, answering }) => {
689
+ session.noteToolCall();
690
+ const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
600
691
  try {
601
692
  // The history needs no page: an id that cannot be restored fails before Moontide starts or a tab opens.
602
693
  session.assertVersionExists(id);
603
694
  await starter.ensureStarted();
604
695
  await starter.waitForPage();
605
- const outcome = await session.restoreVersion(id);
696
+ const outcome = await session.restoreVersion(id, { answering: answeringOpen ? answering : undefined });
606
697
  const { lines, hadRuntimeErrors } = session.drainNotices();
607
698
  // versionId is null only on a history-write failure — the write-failure
608
699
  // note (queued by the same recordConfirmed path set_code uses) explains why below.
@@ -612,7 +703,12 @@ export function createMcpServer(options) {
612
703
  const status = outcome.audio === 'playing' && !hadRuntimeErrors
613
704
  ? `${head} — now playing.`
614
705
  : `${head}. ${playStatus(outcome.audio, hadRuntimeErrors, session.getPageUrl())}`;
615
- const tempo = outcome.tempoChanged && outcome.bpm !== null ? [tempoLine(outcome.bpm)] : [];
706
+ // A restored version that restates the target of a running ramp lets it go on, and says so in place of the tempo line.
707
+ const tempo = outcome.glide !== undefined
708
+ ? [glideLine(outcome.glide)]
709
+ : outcome.tempoChanged && outcome.bpm !== null
710
+ ? [tempoLine(outcome.bpm)]
711
+ : [];
616
712
  const notices = outcome.replacedHandEdit ? [HAND_EDIT_REPLACED_NOTE] : [];
617
713
  // The same t/f flag note and getTime() warn note as set_code, the getTime() note last.
618
714
  const text = withNoticeLines(`${status}\n\n${outcome.code}`, [
@@ -620,6 +716,7 @@ export function createMcpServer(options) {
620
716
  ...notices,
621
717
  ...lines,
622
718
  ...whenFlagLines(outcome.code),
719
+ ...unknownAnsweringLines(answering, answeringOpen),
623
720
  ...getTimeAnchorLines(outcome.code),
624
721
  ]);
625
722
  return { content: [{ type: 'text', text }] };
@@ -631,13 +728,36 @@ export function createMcpServer(options) {
631
728
  const { lines } = session.drainNotices();
632
729
  const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
633
730
  const message = err instanceof Error ? err.message : String(err);
634
- throw new Error(withNoticeLines(message, [...notices, ...lines]));
731
+ throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
732
+ }
733
+ });
734
+ // Needs no started Moontide: a request id is only open while a server runs. It changes no music.
735
+ server.registerTool(ANSWER_REQUEST_TOOL, {
736
+ description: ANSWER_REQUEST_DESCRIPTION,
737
+ annotations: { destructiveHint: false },
738
+ inputSchema: {
739
+ id: z.string().min(1).max(MAX_ASK_ID_LENGTH).describe(ANSWER_REQUEST_ID_DESCRIPTION),
740
+ outcome: z.enum(['answered', 'declined']).describe(ANSWER_REQUEST_OUTCOME_DESCRIPTION),
741
+ text: z.string().max(MAX_ANSWER_TEXT_LENGTH).describe(ANSWER_REQUEST_TEXT_DESCRIPTION),
742
+ },
743
+ }, async ({ id, outcome, text }) => {
744
+ session.noteToolCall();
745
+ try {
746
+ session.answerRequest(id, outcome, text);
747
+ const { lines } = session.drainNotices();
748
+ return { content: [{ type: 'text', text: withNoticeLines(answerRequestReply(outcome), lines) }] };
749
+ }
750
+ catch (err) {
751
+ const { lines } = session.drainNotices();
752
+ const message = err instanceof Error ? err.message : String(err);
753
+ throw new Error(withNoticeLines(message, lines));
635
754
  }
636
755
  });
637
756
  server.registerTool('stop', {
638
757
  description: 'Stops all sound in the Moontide page right away, even when the current code is broken. The code stays in the editor; get_code still returns it, and the next set_code starts playing again. Before Moontide is started it says so and starts nothing.',
639
758
  annotations: { destructiveHint: false, idempotentHint: true },
640
759
  }, async () => {
760
+ session.noteToolCall();
641
761
  if (starter.current() === null) {
642
762
  throw new Error(NOT_STARTED_MESSAGE);
643
763
  }
@@ -665,6 +785,7 @@ export function createMcpServer(options) {
665
785
  instrument: z.string().min(1).max(32).optional().describe('An instrument id from instruments.json, to list its sounds'),
666
786
  },
667
787
  }, async ({ instrument }) => {
788
+ session.noteToolCall();
668
789
  try {
669
790
  const text = session.instrumentsReply(instrument);
670
791
  const { lines } = session.drainNotices();
@@ -685,6 +806,7 @@ export function createMcpServer(options) {
685
806
  category: z.string().max(60).optional().describe('Only sounds of this category; case and spaces do not matter'),
686
807
  },
687
808
  }, async ({ instrument, query, category }) => {
809
+ session.noteToolCall();
688
810
  try {
689
811
  const text = session.searchMidiSounds({ instrument, query, category });
690
812
  const { lines } = session.drainNotices();
@@ -704,6 +826,7 @@ export function createMcpServer(options) {
704
826
  overwrite: z.boolean().optional().describe('true to replace an existing user model with the same id'),
705
827
  },
706
828
  }, async ({ model, confirmed, overwrite }) => {
829
+ session.noteToolCall();
707
830
  try {
708
831
  const text = session.saveMidiModel({ model, confirmed, overwrite });
709
832
  const { lines } = session.drainNotices();
@@ -752,6 +875,7 @@ export function createMcpServer(options) {
752
875
  .describe('The sounds of this part, grouped by bank and category; at most 500 sounds in the call'),
753
876
  },
754
877
  }, async ({ model, confirmed, append, more, source, programNumbering, overwrite, banks }) => {
878
+ session.noteToolCall();
755
879
  try {
756
880
  const text = session.saveMidiCatalog({ model, confirmed, append, more, source, programNumbering, overwrite, banks });
757
881
  const { lines } = session.drainNotices();
@@ -800,6 +924,7 @@ export function createMcpServer(options) {
800
924
  state: z.enum(['on', 'off']).describe(PAGE_REQUESTS_STATE_DESCRIPTION),
801
925
  },
802
926
  }, async ({ state }) => {
927
+ session.noteToolCall();
803
928
  const result = switchPageRequests({ session, state });
804
929
  if ('noPage' in result) {
805
930
  throw new Error(starter.current() === null ? notStartedForRequestsToolText() : noPageForRequestsToolText(session.getPageUrl()));
@@ -826,6 +951,7 @@ export function createMcpServer(options) {
826
951
  description: START_TOOL_DESCRIPTION,
827
952
  annotations: { destructiveHint: false, idempotentHint: true },
828
953
  }, async () => {
954
+ session.noteToolCall();
829
955
  const text = await startMoontide(starter);
830
956
  return { content: [{ type: 'text', text }] };
831
957
  });