@composer-app/mcp 0.0.1-beta.7 → 0.0.3

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.
@@ -4534,6 +4534,24 @@ function resolveServerAnchor(doc, spec) {
4534
4534
  }
4535
4535
 
4536
4536
  // src/roomState.ts
4537
+ var HAD_UPGRADE_403 = /* @__PURE__ */ Symbol("composer.upgrade403");
4538
+ var TerminalDetectingWS = class extends WebSocket {
4539
+ constructor(...args) {
4540
+ super(...args);
4541
+ this.on("unexpected-response", (_req, res) => {
4542
+ if (res.statusCode === 403) {
4543
+ this[HAD_UPGRADE_403] = true;
4544
+ }
4545
+ });
4546
+ }
4547
+ };
4548
+ var TERMINAL_CLOSE_CODES = /* @__PURE__ */ new Set([4403, 4410]);
4549
+ var MAX_CONSECUTIVE_FAILURES = 15;
4550
+ function reconnectDelayMs(failuresSoFar) {
4551
+ if (failuresSoFar <= 5) return 1e4;
4552
+ if (failuresSoFar <= 10) return 3e4;
4553
+ return 6e4;
4554
+ }
4537
4555
  var RoomState = class _RoomState {
4538
4556
  doc;
4539
4557
  actingAs;
@@ -4569,10 +4587,37 @@ var RoomState = class _RoomState {
4569
4587
  /**
4570
4588
  * Set true when `composer_next_event`'s goodbye branch has fired. Tells
4571
4589
  * `handleNextEvent` to refuse subsequent calls until the user explicitly
4572
- * re-engages (which happens via `composer_create_room` / `composer_join_room`,
4573
- * both of which build a fresh `RoomState` so this flag resets naturally).
4590
+ * re-engages via `composer_create_room` / `composer_join_room` — both of
4591
+ * which clear the latch (create builds a fresh RoomState; rejoin reuses
4592
+ * the cached one and calls `clearGoodbye()`).
4574
4593
  */
4575
4594
  goodbyeIssued = false;
4595
+ /**
4596
+ * Set when the connection enters a terminal state — either the server
4597
+ * explicitly rejected (HTTP 403 / close code 4403 / 4410) or the client
4598
+ * circuit breaker tripped after too many consecutive failures. Once set,
4599
+ * `ensureConnected` / `waitForInitialSync` reject immediately and tool
4600
+ * calls surface a clear error rather than the host silently hanging.
4601
+ */
4602
+ terminalReject = null;
4603
+ /**
4604
+ * Listeners that fire when terminal state is signalled. Used to unblock
4605
+ * pending `waitForInitialSync` promises so the host doesn't hang for the
4606
+ * full 15s timeout after a kick.
4607
+ */
4608
+ terminalRejectListeners = /* @__PURE__ */ new Set();
4609
+ /**
4610
+ * Counter for consecutive failed reconnects. Reset to 0 on a successful
4611
+ * `connected` status event. Per the design, does NOT reset on idle
4612
+ * disconnects — only on a real successful open.
4613
+ */
4614
+ consecutiveFailures = 0;
4615
+ /**
4616
+ * Pending manually-scheduled reconnect timer (we replace y-partyserver's
4617
+ * default exponential backoff with the tiered schedule from the design).
4618
+ * Cleared on destroy and on each new close event.
4619
+ */
4620
+ scheduledReconnect = null;
4576
4621
  constructor(opts) {
4577
4622
  this.roomId = opts.roomId;
4578
4623
  this.actingAs = opts.actingAs;
@@ -4590,7 +4635,56 @@ var RoomState = class _RoomState {
4590
4635
  this.provider = new YProvider(opts.serverHost, opts.roomId, this.doc, {
4591
4636
  party: "composer-room",
4592
4637
  connect: false,
4593
- WebSocketPolyfill: WebSocket
4638
+ WebSocketPolyfill: TerminalDetectingWS
4639
+ });
4640
+ }
4641
+ if (!opts._providerForTest) {
4642
+ this.provider.on(
4643
+ "connection-close",
4644
+ (...args) => {
4645
+ const event = args[0];
4646
+ if (this.terminalReject) return;
4647
+ if (!this.provider.shouldConnect) {
4648
+ return;
4649
+ }
4650
+ const ws = this.provider.ws;
4651
+ const had403 = ws ? !!ws[HAD_UPGRADE_403] : false;
4652
+ if (had403 || TERMINAL_CLOSE_CODES.has(event.code)) {
4653
+ this.signalTerminalReject({
4654
+ kind: "rejected",
4655
+ code: had403 ? 403 : event.code,
4656
+ reason: had403 ? "server refused upgrade (HTTP 403). composer-mcp may be out of date or this client is blocked. Update composer-mcp or contact the room owner." : event.code === 4410 ? `server has killed this client version. ${event.reason || "Please update composer-mcp."}` : `server kicked this client (close code ${event.code}). ${event.reason || "Please update composer-mcp."}`
4657
+ });
4658
+ return;
4659
+ }
4660
+ this.consecutiveFailures++;
4661
+ if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
4662
+ this.signalTerminalReject({
4663
+ kind: "aborted",
4664
+ failures: this.consecutiveFailures
4665
+ });
4666
+ return;
4667
+ }
4668
+ this.provider.shouldConnect = false;
4669
+ if (this.scheduledReconnect) clearTimeout(this.scheduledReconnect);
4670
+ const delay = reconnectDelayMs(this.consecutiveFailures);
4671
+ this.scheduledReconnect = setTimeout(() => {
4672
+ this.scheduledReconnect = null;
4673
+ if (this.terminalReject) return;
4674
+ this.provider.connect?.()?.catch?.((err) => {
4675
+ console.error(
4676
+ `[composer-mcp] reconnect attempt ${this.consecutiveFailures} failed: ${err}`
4677
+ );
4678
+ });
4679
+ }, delay);
4680
+ this.scheduledReconnect.unref?.();
4681
+ }
4682
+ );
4683
+ this.provider.on("status", (...args) => {
4684
+ const status = args[0];
4685
+ if (status.status === "connected") {
4686
+ this.consecutiveFailures = 0;
4687
+ }
4594
4688
  });
4595
4689
  }
4596
4690
  }
@@ -4618,6 +4712,51 @@ var RoomState = class _RoomState {
4618
4712
  _docForTest: doc
4619
4713
  });
4620
4714
  }
4715
+ signalTerminalReject(rej) {
4716
+ if (this.terminalReject) return;
4717
+ this.terminalReject = rej;
4718
+ if (this.scheduledReconnect) {
4719
+ clearTimeout(this.scheduledReconnect);
4720
+ this.scheduledReconnect = null;
4721
+ }
4722
+ const listeners = [...this.terminalRejectListeners];
4723
+ this.terminalRejectListeners.clear();
4724
+ for (const fn of listeners) {
4725
+ try {
4726
+ fn();
4727
+ } catch {
4728
+ }
4729
+ }
4730
+ this.provider.destroy();
4731
+ }
4732
+ terminalRejectError() {
4733
+ const rej = this.terminalReject;
4734
+ if (!rej) {
4735
+ return new Error(
4736
+ `[composer-mcp] room "${this.roomId}" entered terminal state (unknown).`
4737
+ );
4738
+ }
4739
+ if (rej.kind === "aborted") {
4740
+ return new Error(
4741
+ `[composer-mcp] connection to room "${this.roomId}" aborted after ${rej.failures} failed reconnect attempts. Run composer_join_room to retry.`
4742
+ );
4743
+ }
4744
+ return new Error(
4745
+ `[composer-mcp] room "${this.roomId}" rejected by server: ${rej.reason}`
4746
+ );
4747
+ }
4748
+ /** Test/diagnostic accessor — not part of the stable surface. */
4749
+ get isTerminallyRejected() {
4750
+ return this.terminalReject !== null;
4751
+ }
4752
+ /** Test/diagnostic accessor — not part of the stable surface. */
4753
+ get terminalRejectKind() {
4754
+ return this.terminalReject?.kind ?? null;
4755
+ }
4756
+ /** Test/diagnostic accessor — not part of the stable surface. */
4757
+ get failureCount() {
4758
+ return this.consecutiveFailures;
4759
+ }
4621
4760
  /** Test-only accessor for the underlying awareness instance. */
4622
4761
  get awarenessForTest() {
4623
4762
  return this.provider.awareness;
@@ -4629,6 +4768,7 @@ var RoomState = class _RoomState {
4629
4768
  * of hanging the MCP tool call indefinitely.
4630
4769
  */
4631
4770
  async waitForInitialSync(timeoutMs = 15e3) {
4771
+ if (this.terminalReject) throw this.terminalRejectError();
4632
4772
  if (this.provider.synced) return;
4633
4773
  if (!this.provider.on || !this.provider.off) return;
4634
4774
  const provider = this.provider;
@@ -4638,12 +4778,20 @@ var RoomState = class _RoomState {
4638
4778
  const onSync = (...args) => {
4639
4779
  const synced = args[0];
4640
4780
  if (!synced) return;
4781
+ cleanup();
4782
+ resolve();
4783
+ };
4784
+ const onTerminal = () => {
4785
+ cleanup();
4786
+ reject(this.terminalRejectError());
4787
+ };
4788
+ const cleanup = () => {
4641
4789
  clearTimeout(timer);
4642
4790
  offSub("sync", onSync);
4643
- resolve();
4791
+ this.terminalRejectListeners.delete(onTerminal);
4644
4792
  };
4645
4793
  const timer = setTimeout(() => {
4646
- offSub("sync", onSync);
4794
+ cleanup();
4647
4795
  reject(
4648
4796
  new Error(
4649
4797
  `timed out after ${timeoutMs}ms waiting for sync handshake on room "${this.roomId}"`
@@ -4651,6 +4799,7 @@ var RoomState = class _RoomState {
4651
4799
  );
4652
4800
  }, timeoutMs);
4653
4801
  onSub("sync", onSync);
4802
+ this.terminalRejectListeners.add(onTerminal);
4654
4803
  });
4655
4804
  }
4656
4805
  idleDisconnectTimer = null;
@@ -4661,6 +4810,7 @@ var RoomState = class _RoomState {
4661
4810
  * disconnect because we're back in active use.
4662
4811
  */
4663
4812
  async ensureConnected(timeoutMs = 15e3) {
4813
+ if (this.terminalReject) throw this.terminalRejectError();
4664
4814
  if (this.idleDisconnectTimer) {
4665
4815
  clearTimeout(this.idleDisconnectTimer);
4666
4816
  this.idleDisconnectTimer = null;
@@ -4694,7 +4844,7 @@ var RoomState = class _RoomState {
4694
4844
  this.idleDisconnectTimer = setTimeout(() => {
4695
4845
  this.idleDisconnectTimer = null;
4696
4846
  this.setMonitoring(false);
4697
- this.provider.disconnect?.();
4847
+ this.disconnect();
4698
4848
  }, ms);
4699
4849
  this.idleDisconnectTimer.unref?.();
4700
4850
  }
@@ -4702,13 +4852,18 @@ var RoomState = class _RoomState {
4702
4852
  * Close the socket immediately. Available for explicit teardown
4703
4853
  * (e.g., destroy()). Does not touch monitoring state — the socket close
4704
4854
  * causes y-partyserver to broadcast the awareness removal, which is the
4705
- * visual signal peers care about.
4855
+ * visual signal peers care about. Clears any pending circuit-breaker
4856
+ * reconnect so the user's intent to disconnect isn't silently undone.
4706
4857
  */
4707
4858
  disconnect() {
4708
4859
  if (this.idleDisconnectTimer) {
4709
4860
  clearTimeout(this.idleDisconnectTimer);
4710
4861
  this.idleDisconnectTimer = null;
4711
4862
  }
4863
+ if (this.scheduledReconnect) {
4864
+ clearTimeout(this.scheduledReconnect);
4865
+ this.scheduledReconnect = null;
4866
+ }
4712
4867
  this.provider.disconnect?.();
4713
4868
  }
4714
4869
  /**
@@ -4717,9 +4872,9 @@ var RoomState = class _RoomState {
4717
4872
  * socket must already be open (caller should have ensured this).
4718
4873
  *
4719
4874
  * setMonitoring(true) — called once on first composer_next_event entry.
4720
- * setMonitoring(false) — called on the goodbye branch; clears awareness
4721
- * immediately so the avatar disappears as the
4722
- * farewell lands.
4875
+ * setMonitoring(false) — called on the silent-exit branch; clears
4876
+ * awareness immediately so the avatar disappears
4877
+ * as the agent leaves.
4723
4878
  *
4724
4879
  * Subsequent calls with the same value are no-ops.
4725
4880
  */
@@ -4754,6 +4909,15 @@ var RoomState = class _RoomState {
4754
4909
  markGoodbyeIssued() {
4755
4910
  this.goodbyeIssued = true;
4756
4911
  }
4912
+ /**
4913
+ * Reset the goodbye latch on rejoin. `composer_join_room` reuses the
4914
+ * cached RoomState when one is still in the rooms map, so without this
4915
+ * the latch would remain set and the next `composer_next_event` would
4916
+ * be refused — even though the user explicitly asked to re-engage.
4917
+ */
4918
+ clearGoodbye() {
4919
+ this.goodbyeIssued = false;
4920
+ }
4757
4921
  get hasIssuedGoodbye() {
4758
4922
  return this.goodbyeIssued;
4759
4923
  }
@@ -4822,6 +4986,11 @@ var RoomState = class _RoomState {
4822
4986
  clearTimeout(this.idleDisconnectTimer);
4823
4987
  this.idleDisconnectTimer = null;
4824
4988
  }
4989
+ if (this.scheduledReconnect) {
4990
+ clearTimeout(this.scheduledReconnect);
4991
+ this.scheduledReconnect = null;
4992
+ }
4993
+ this.terminalRejectListeners.clear();
4825
4994
  this.provider.destroy();
4826
4995
  this.doc.destroy();
4827
4996
  }
@@ -5269,15 +5438,18 @@ async function resolveActingAs(actingAsArg) {
5269
5438
  if (!actingAsArg) {
5270
5439
  throw new Error(
5271
5440
  [
5272
- "First Composer room on this machine \u2014 you need a persistent name before proceeding. STOP and ask the user what to call you.",
5441
+ "First Composer room on this machine \u2014 orient the user, then ask what to call you. STOP and output ONE short message before retrying.",
5273
5442
  "",
5274
- "Offer ONE suggested default they can accept with a tap:",
5275
- ` - If you know the user's first name from conversation context, suggest "<FirstName>'s Agent" (e.g. "Josh's Agent").`,
5276
- " - Otherwise pick something playful from outside the model-family space (e.g. Monty, Gerty, Rosie, Otto, Pip). Do NOT suggest Claude / Gemini / GPT / Sonnet / Opus / Haiku / other model names.",
5443
+ "The message should cover four things, in order:",
5444
+ ' 1. GREET \u2014 open with "Welcome to Composer!"',
5445
+ ' 2. WHAT COMPOSER IS \u2014 "Composer is a real-time markdown editor for humans and agents."',
5446
+ ' 3. WHAT YOU CAN DO IN IT \u2014 "I can join your docs with you, respond to comments, and post inline suggestions."',
5447
+ " 4. ASK FOR A NAME \u2014 suggest ONE default they can accept in a single word.",
5448
+ ` - If you know the user's first name from conversation context, suggest "<FirstName>'s Agent" (e.g. "Josh's Agent").`,
5449
+ " - Otherwise pick something playful from outside the model-family space (e.g. Monty, Gerty, Rosie, Otto, Pip). Do NOT suggest Claude / Gemini / GPT / Sonnet / Opus / Haiku / other model names.",
5450
+ ` - Phrase it as a check, e.g. "I'll go by <suggested name> in the doc if that's okay with you?"`,
5277
5451
  "",
5278
- `Phrase it like: "I'll go by <suggested name> in Composer docs \u2014 sound good, or pick your own?"`,
5279
- "",
5280
- "Retry with their answer as `actingAs`. It persists to ~/.composer/user.json and is reused for every future room on this machine, so only ask once."
5452
+ "Retry the call with their answer as `actingAs`. The name persists to ~/.composer/user.json and is reused for every future room on this machine, so this orientation only happens once per machine."
5281
5453
  ].join("\n")
5282
5454
  );
5283
5455
  }
@@ -5351,7 +5523,7 @@ var TOOL_DEFS = [
5351
5523
  },
5352
5524
  {
5353
5525
  name: "composer_next_event",
5354
- description: "Block for up to `timeoutSec` (default 30s) waiting for a remote event. Returns `{ kind: 'mention' | 'timeout', ... }`. **The monitor loop is always-on** \u2014 every return carries a directive you must follow without waiting for user input. On `mention`, the `reason` is one of: `direct_mention` (sidecar or text named you \u2014 always reply), `active_thread` (plain reply on a thread you're already in \u2014 reply if the content invites one), or `solo_room` (you're alone with one human who didn't explicitly tag anyone \u2014 default to a helpful reply, but skip if the text reads like a note-to-self, acknowledgement, or aside). Handle the event, then execute the return's `requiredNextToolCall`. On `timeout`, `recentActivity` tells you whether to keep monitoring or exit with the goodbye line from `userMessage`.",
5526
+ description: "Block for up to `timeoutSec` (default 30s) waiting for a remote event. Returns `{ kind: 'mention' | 'timeout', ... }`. **The monitor loop is always-on** \u2014 every return carries a directive you must follow without waiting for user input. On `mention`, the `reason` is one of: `direct_mention` (sidecar or text named you \u2014 always reply), `active_thread` (plain reply on a thread you're already in \u2014 reply if the content invites one), or `solo_room` (you're alone with one human who didn't explicitly tag anyone \u2014 default to a helpful reply, but skip if the text reads like a note-to-self, acknowledgement, or aside). Handle the event, then execute the return's `requiredNextToolCall`. On a final `timeout` (no `requiredNextToolCall`), the return carries `userMessage` \u2014 the agent's goodbye line in its own voice. Print `userMessage` verbatim as your final output before exiting; do NOT post anything in the doc.",
5355
5527
  inputSchema: {
5356
5528
  type: "object",
5357
5529
  properties: {
@@ -5518,6 +5690,21 @@ var TOOL_DEFS = [
5518
5690
  required: ["roomId", "threadId"]
5519
5691
  }
5520
5692
  },
5693
+ {
5694
+ name: "composer_export_to_file",
5695
+ description: "Dump the current Composer doc to a local markdown file. ONE-WAY ONLY: Composer is the source of truth, the file is OVERWRITTEN on every call. The path must be absolute. Use when the user asks to 'save this to a file', 'export to disk', or runs /composer:export. Returns { roomId, path, bytesWritten }.",
5696
+ inputSchema: {
5697
+ type: "object",
5698
+ properties: {
5699
+ roomId: { type: "string" },
5700
+ path: {
5701
+ type: "string",
5702
+ description: "Absolute filesystem path to write to. Existing files are overwritten. Parent directory must already exist."
5703
+ }
5704
+ },
5705
+ required: ["roomId", "path"]
5706
+ }
5707
+ },
5521
5708
  {
5522
5709
  name: "composer_agent_status",
5523
5710
  description: 'Drive the live state indicator on an in-flight agent-authored record (a reply, or the thread-head Comment / Suggestion). Use AFTER posting an ack with `state: "thinking"` via the reply/add tools \u2014 this tool advances state mid-work without creating a new reply, and optionally rewrites text in place at completion. Transitions: thinking \u2192 working (tool use started) \u2192 replying (assembling final answer, optional) \u2192 ready (terminal; text now holds the final content OR a thin pointer to a standalone artifact). When `replyId` is set, updates that reply inside `threadId`; when absent, updates the thread-head record (use `kind` to disambiguate). Returns { replyId } for reply updates or { id } for thread-head updates.',
@@ -5644,11 +5831,12 @@ function asAnchor(value) {
5644
5831
  }
5645
5832
  function buildMonitorHandoff(roomId, browserUrl, actingAs, opts) {
5646
5833
  const mention = `@${actingAs}`;
5647
- const step1_sayToUser = opts.isFirstRun ? `${browserUrl}
5834
+ const opener = opts.mode === "create" ? "I've created a new doc for you" : "I've joined the doc";
5835
+ const step1_sayToUser = opts.isFirstRun ? `${opener} and am launching another agent to watch for comments. Tag me (${mention}) in any Composer comment to bring me in \u2014 and share the link to invite other people (or their agents) to collaborate with us.
5648
5836
 
5649
- First time using Composer? Here's how it works: I'm monitoring this doc right here in your terminal. Reach me anytime by tagging ${mention} in any comment or reply \u2014 I'll answer right in the thread, and I can also post suggestions you accept or reject inline.` : `${browserUrl}
5837
+ ${browserUrl}` : `${opener} and am launching another agent to watch for comments. Tag me (${mention}) in any Composer comment to bring me in.
5650
5838
 
5651
- I'm monitoring this doc from the terminal \u2014 tag me with ${mention} in any comment and I'll reply right there.`;
5839
+ ${browserUrl}`;
5652
5840
  return {
5653
5841
  step1_sayToUser,
5654
5842
  step2_callTool: {
@@ -5697,6 +5885,7 @@ async function handleCreateRoom(args) {
5697
5885
  identity
5698
5886
  });
5699
5887
  await state.ensureConnected();
5888
+ state.setMonitoring(true);
5700
5889
  if (seedMarkdown) {
5701
5890
  writeMarkdownToFragment(state.doc.getXmlFragment("default"), seedMarkdown);
5702
5891
  }
@@ -5708,7 +5897,10 @@ async function handleCreateRoom(args) {
5708
5897
  browserUrl,
5709
5898
  actingAs,
5710
5899
  snapshot: state.snapshot(),
5711
- ...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
5900
+ ...buildMonitorHandoff(roomId, browserUrl, actingAs, {
5901
+ isFirstRun,
5902
+ mode: "create"
5903
+ })
5712
5904
  });
5713
5905
  }
5714
5906
  async function handleJoinRoom(args) {
@@ -5723,6 +5915,9 @@ async function handleJoinRoom(args) {
5723
5915
  roomId,
5724
5916
  actingAs: existing.actingAs
5725
5917
  });
5918
+ existing.clearGoodbye();
5919
+ await existing.ensureConnected();
5920
+ existing.setMonitoring(true);
5726
5921
  existing.scheduleIdleDisconnect(3e4);
5727
5922
  return okResult({
5728
5923
  roomId,
@@ -5733,7 +5928,8 @@ async function handleJoinRoom(args) {
5733
5928
  // first-run situation — we already wrote a name to disk at least
5734
5929
  // once in this process lifetime to have created `existing`.
5735
5930
  ...buildMonitorHandoff(roomId, browserUrl, existing.actingAs, {
5736
- isFirstRun: false
5931
+ isFirstRun: false,
5932
+ mode: "join"
5737
5933
  })
5738
5934
  });
5739
5935
  }
@@ -5746,6 +5942,7 @@ async function handleJoinRoom(args) {
5746
5942
  identity
5747
5943
  });
5748
5944
  await state.ensureConnected();
5945
+ state.setMonitoring(true);
5749
5946
  rooms.set(roomId, state);
5750
5947
  log(`composer room joined \u2192 ${browserUrl}`, { roomId, actingAs });
5751
5948
  state.scheduleIdleDisconnect(3e4);
@@ -5754,7 +5951,10 @@ async function handleJoinRoom(args) {
5754
5951
  browserUrl,
5755
5952
  actingAs,
5756
5953
  snapshot: state.snapshot(),
5757
- ...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
5954
+ ...buildMonitorHandoff(roomId, browserUrl, actingAs, {
5955
+ isFirstRun,
5956
+ mode: "join"
5957
+ })
5758
5958
  });
5759
5959
  }
5760
5960
  function handleAttachRoom(args) {
@@ -5769,7 +5969,14 @@ var ACTIVITY_WINDOW_MS = (() => {
5769
5969
  const n = Number(raw);
5770
5970
  return Number.isFinite(n) && n > 0 ? n : 15 * 60 * 1e3;
5771
5971
  })();
5772
- var LEAVE_MESSAGE = "I've left the document. You can ask me to rejoin anytime and I'll continue replying.";
5972
+ var goodbyeIdleTimeout = (roomId) => `Run \`/composer:join\` when you want me back \u2014 or open the doc again at ${browserUrlFor(roomId)}.`;
5973
+ var GOODBYE_SERVER_KICKED = `I'm having trouble reaching the Composer doc. Try restarting your Claude session, or try again in a bit.`;
5974
+ var GOODBYE_RECONNECT_ABORTED = `I'm having trouble reaching the Composer doc. Make sure you're connected to the internet or try running \`/composer:join\` in a bit to have me join again.`;
5975
+ function terminalRejectGoodbye(state) {
5976
+ if (state.terminalRejectKind === "rejected") return GOODBYE_SERVER_KICKED;
5977
+ if (state.terminalRejectKind === "aborted") return GOODBYE_RECONNECT_ABORTED;
5978
+ return null;
5979
+ }
5773
5980
  async function handleNextEvent(args, signal) {
5774
5981
  const a = asObject(args);
5775
5982
  const roomId = asString(a.roomId, "roomId");
@@ -5780,6 +5987,16 @@ async function handleNextEvent(args, signal) {
5780
5987
  "Already said goodbye for this room. The user must explicitly ask you to rejoin (which will trigger a fresh `composer_join_room`). Do not call `composer_next_event` again on this roomId."
5781
5988
  );
5782
5989
  }
5990
+ const earlyGoodbye = terminalRejectGoodbye(state);
5991
+ if (earlyGoodbye) {
5992
+ state.markGoodbyeIssued();
5993
+ return okResult({
5994
+ kind: "timeout",
5995
+ recentActivity: false,
5996
+ userMessage: earlyGoodbye,
5997
+ instruction: `Print \`userMessage\` verbatim as your final output, then exit. Do NOT post anything in the doc \u2014 the connection is gone, doc-side awareness is already cleared. Stop calling composer_next_event on this room until the user explicitly rejoins.`
5998
+ });
5999
+ }
5783
6000
  state.setMonitoring(true);
5784
6001
  const onAbort = () => {
5785
6002
  state.setMonitoring(false);
@@ -5808,6 +6025,16 @@ async function handleNextEvent(args, signal) {
5808
6025
  }
5809
6026
  });
5810
6027
  }
6028
+ const lateGoodbye = terminalRejectGoodbye(state);
6029
+ if (lateGoodbye) {
6030
+ state.markGoodbyeIssued();
6031
+ return okResult({
6032
+ kind: "timeout",
6033
+ recentActivity: false,
6034
+ userMessage: lateGoodbye,
6035
+ instruction: `Print \`userMessage\` verbatim as your final output, then exit. Do NOT post anything in the doc \u2014 the connection is gone. Stop calling composer_next_event on this room until the user explicitly rejoins.`
6036
+ });
6037
+ }
5811
6038
  const msSinceActivity = Date.now() - state.lastRemoteActivityAt;
5812
6039
  const recentActivity = msSinceActivity < ACTIVITY_WINDOW_MS;
5813
6040
  const minutesSince = Math.max(1, Math.round(msSinceActivity / 6e4));
@@ -5830,8 +6057,8 @@ async function handleNextEvent(args, signal) {
5830
6057
  kind: "timeout",
5831
6058
  recentActivity: false,
5832
6059
  secondsSinceActivity: Math.round(msSinceActivity / 1e3),
5833
- userMessage: LEAVE_MESSAGE,
5834
- instruction: `Say \`userMessage\` EXACTLY as written \u2014 do not paraphrase the goodbye. Then stop calling composer_next_event until the user asks you to rejoin. No requiredNextToolCall field: the loop is intentionally over.`
6060
+ userMessage: goodbyeIdleTimeout(roomId),
6061
+ instruction: `Print \`userMessage\` verbatim as your final output, then exit. Do NOT post anything in the doc \u2014 the avatar has already cleared. Stop calling composer_next_event on this room until the user explicitly rejoins.`
5835
6062
  });
5836
6063
  }
5837
6064
  function handleGetSection(args) {
@@ -6286,6 +6513,29 @@ function handleDone(args) {
6286
6513
  const state = getOrError(roomId);
6287
6514
  return performDone(state, a);
6288
6515
  }
6516
+ async function handleExportToFile(args) {
6517
+ const a = asObject(args);
6518
+ const roomId = asString(a.roomId, "roomId");
6519
+ const filePath = asString(a.path, "path");
6520
+ if (!path3.isAbsolute(filePath)) {
6521
+ return errorResult(
6522
+ `path must be absolute, got: ${filePath}. Resolve to an absolute path before calling.`
6523
+ );
6524
+ }
6525
+ const state = getOrError(roomId);
6526
+ const markdown = serializeDocAsMarkdown(state.doc);
6527
+ try {
6528
+ await fs3.writeFile(filePath, markdown, "utf8");
6529
+ } catch (err) {
6530
+ const message = err instanceof Error ? err.message : String(err);
6531
+ return errorResult(`failed to write ${filePath}: ${message}`);
6532
+ }
6533
+ return okResult({
6534
+ roomId,
6535
+ path: filePath,
6536
+ bytesWritten: Buffer.byteLength(markdown, "utf8")
6537
+ });
6538
+ }
6289
6539
  function _registerRoomForTest(roomId, state) {
6290
6540
  rooms.set(roomId, state);
6291
6541
  }
@@ -6326,6 +6576,8 @@ async function dispatchTool(name, args, signal) {
6326
6576
  return handleReplySuggestion(args);
6327
6577
  case "composer_resolve_thread":
6328
6578
  return handleResolveThread(args);
6579
+ case "composer_export_to_file":
6580
+ return await handleExportToFile(args);
6329
6581
  case "composer_agent_status":
6330
6582
  return handleAgentStatus(args);
6331
6583
  case "composer_done":
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  logError,
5
5
  startMcpHttpServer,
6
6
  startMcpServer
7
- } from "./chunk-EJUJPBX2.js";
7
+ } from "./chunk-GPFWLOYB.js";
8
8
 
9
9
  // src/setup.ts
10
10
  import * as fs from "fs/promises";
package/dist/mcp.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  startMcpHttpServer,
16
16
  startMcpServer,
17
17
  teardownAllRooms
18
- } from "./chunk-EJUJPBX2.js";
18
+ } from "./chunk-GPFWLOYB.js";
19
19
  export {
20
20
  __test_clearRooms,
21
21
  __test_dispatch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@composer-app/mcp",
3
- "version": "0.0.1-beta.7",
3
+ "version": "0.0.3",
4
4
  "description": "Composer MCP",
5
5
  "license": "MIT",
6
6
  "author": "Josh Philpott",