@composer-app/mcp 0.0.1-beta.4 → 0.0.1-beta.5

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.
@@ -4185,6 +4185,14 @@ var RoomState = class {
4185
4185
  * on every remote edit, comment, suggestion, or activity-feed write.
4186
4186
  */
4187
4187
  _lastRemoteActivityAt = Date.now();
4188
+ monitoringOn = false;
4189
+ /**
4190
+ * Set true when `composer_next_event`'s goodbye branch has fired. Tells
4191
+ * `handleNextEvent` to refuse subsequent calls until the user explicitly
4192
+ * re-engages (which happens via `composer_create_room` / `composer_join_room`,
4193
+ * both of which build a fresh `RoomState` so this flag resets naturally).
4194
+ */
4195
+ goodbyeIssued = false;
4188
4196
  constructor(opts) {
4189
4197
  this.roomId = opts.roomId;
4190
4198
  this.actingAs = opts.actingAs;
@@ -4197,46 +4205,9 @@ var RoomState = class {
4197
4205
  });
4198
4206
  this.provider = new YProvider(opts.serverHost, opts.roomId, this.doc, {
4199
4207
  party: "composer-room",
4200
- connect: true,
4208
+ connect: false,
4201
4209
  WebSocketPolyfill: WebSocket
4202
4210
  });
4203
- this.provider.awareness.setLocalStateField("user", {
4204
- name: opts.actingAs,
4205
- color: opts.identity.color,
4206
- userId: opts.identity.userId,
4207
- isAgent: true
4208
- });
4209
- this.installAwarenessHeartbeat();
4210
- }
4211
- /**
4212
- * Re-broadcast the MCP's awareness every 15s.
4213
- *
4214
- * y-partyserver's provider disables the y-protocols awareness
4215
- * `_checkInterval` (see `clearInterval(awareness._checkInterval)` in
4216
- * `y-partyserver/dist/provider/index.js`), so the MCP sends its awareness
4217
- * exactly once — on connect — and never heartbeats after that. Combined
4218
- * with Cloudflare Durable Object hibernation (which evicts the server's
4219
- * in-memory `document.awareness` Map on wake), this means a browser that
4220
- * connects more than ~60s after the MCP sees an empty awareness dump in
4221
- * `onConnect` and never learns the agent is there. The user's own
4222
- * awareness flows the other direction fine (they send on connect, server
4223
- * broadcasts to the MCP), which is why the failure is asymmetric.
4224
- *
4225
- * y-partyserver's provider listens to `awareness.on("change", ...)`, and
4226
- * y-protocols only fires `change` when the new state is deep-unequal to
4227
- * the previous one. Re-setting an identical state emits `update` but NOT
4228
- * `change`, so the provider never sends a wire frame. We bump a throwaway
4229
- * `_hb` field each tick to guarantee deep-inequality, forcing the change
4230
- * event and a broadcast. 15s is well under any realistic hibernation gap.
4231
- */
4232
- installAwarenessHeartbeat() {
4233
- const heartbeat = setInterval(() => {
4234
- const local = this.provider.awareness.getLocalState();
4235
- if (local !== null) {
4236
- this.provider.awareness.setLocalState({ ...local, _hb: Date.now() });
4237
- }
4238
- }, 15e3);
4239
- heartbeat.unref?.();
4240
4211
  }
4241
4212
  /**
4242
4213
  * Resolves when the provider has completed its first sync handshake.
@@ -4264,6 +4235,117 @@ var RoomState = class {
4264
4235
  this.provider.on("sync", onSync);
4265
4236
  });
4266
4237
  }
4238
+ idleDisconnectTimer = null;
4239
+ /**
4240
+ * Open the WebSocket (if not already) and await the first sync handshake.
4241
+ * Idempotent — no-op if already connected and synced. Call at the top of
4242
+ * every tool handler that touches the doc. Cancels any pending idle
4243
+ * disconnect because we're back in active use.
4244
+ */
4245
+ async ensureConnected(timeoutMs = 15e3) {
4246
+ if (this.idleDisconnectTimer) {
4247
+ clearTimeout(this.idleDisconnectTimer);
4248
+ this.idleDisconnectTimer = null;
4249
+ }
4250
+ if (this.provider.wsconnected && this.provider.synced) return;
4251
+ await this.provider.connect();
4252
+ await this.waitForInitialSync(timeoutMs);
4253
+ }
4254
+ /**
4255
+ * Arm a timer that turns monitoring off and closes the socket after `ms`
4256
+ * of no further tool-handler activity. Cancelled by `ensureConnected`.
4257
+ * Call at the end of every tool handler. Defaults to 30s — long enough
4258
+ * to bridge a normal multi-step monitor turn (mention → reply →
4259
+ * `composer_next_event` again), short enough that an abandoned monitor
4260
+ * loop fades the avatar within the "within a minute" window the user
4261
+ * requested.
4262
+ *
4263
+ * Both presence cleanup paths converge here: setMonitoring(false) clears
4264
+ * our local awareness state, then disconnect() closes the socket which
4265
+ * triggers y-partyserver's onClose → removeAwarenessStates. Either alone
4266
+ * would suffice; doing both keeps local and server state consistent.
4267
+ */
4268
+ scheduleIdleDisconnect(ms = 3e4) {
4269
+ if (this.idleDisconnectTimer) {
4270
+ clearTimeout(this.idleDisconnectTimer);
4271
+ this.idleDisconnectTimer = null;
4272
+ }
4273
+ if (!this.provider.wsconnected) return;
4274
+ this.idleDisconnectTimer = setTimeout(() => {
4275
+ this.idleDisconnectTimer = null;
4276
+ this.setMonitoring(false);
4277
+ this.provider.disconnect();
4278
+ }, ms);
4279
+ this.idleDisconnectTimer.unref?.();
4280
+ }
4281
+ /**
4282
+ * Close the socket immediately. Available for explicit teardown
4283
+ * (e.g., destroy()). Does not touch monitoring state — the socket close
4284
+ * causes y-partyserver to broadcast the awareness removal, which is the
4285
+ * visual signal peers care about.
4286
+ */
4287
+ disconnect() {
4288
+ if (this.idleDisconnectTimer) {
4289
+ clearTimeout(this.idleDisconnectTimer);
4290
+ this.idleDisconnectTimer = null;
4291
+ }
4292
+ this.provider.disconnect();
4293
+ }
4294
+ /**
4295
+ * Toggle whether the agent is visibly monitoring the room. Broadcast via
4296
+ * the `user` awareness field — present ↔ avatar visible to peers. The
4297
+ * socket must already be open (caller should have ensured this).
4298
+ *
4299
+ * setMonitoring(true) — called once on first composer_next_event entry.
4300
+ * setMonitoring(false) — called on the goodbye branch; clears awareness
4301
+ * immediately so the avatar disappears as the
4302
+ * farewell lands.
4303
+ *
4304
+ * Subsequent calls with the same value are no-ops.
4305
+ */
4306
+ setMonitoring(on) {
4307
+ if (this.monitoringOn === on) return;
4308
+ this.monitoringOn = on;
4309
+ if (on) {
4310
+ const current = this.provider.awareness.getLocalState() ?? {};
4311
+ this.provider.awareness.setLocalState({
4312
+ ...current,
4313
+ user: {
4314
+ name: this.actingAs,
4315
+ color: this.identity.color,
4316
+ userId: this.identity.userId,
4317
+ isAgent: true
4318
+ }
4319
+ });
4320
+ } else {
4321
+ this.provider.awareness.setLocalState(null);
4322
+ }
4323
+ }
4324
+ get isMonitoring() {
4325
+ return this.monitoringOn;
4326
+ }
4327
+ /**
4328
+ * Latch flipped by `composer_next_event`'s goodbye branch. Once set, the
4329
+ * model is expected to stop calling `composer_next_event` until the user
4330
+ * explicitly asks to rejoin — which builds a fresh RoomState, resetting
4331
+ * the latch. The MCP refuses further next_event calls on this RoomState
4332
+ * instead of silently re-entering the monitor loop.
4333
+ */
4334
+ markGoodbyeIssued() {
4335
+ this.goodbyeIssued = true;
4336
+ }
4337
+ get hasIssuedGoodbye() {
4338
+ return this.goodbyeIssued;
4339
+ }
4340
+ /**
4341
+ * Read-only view of this client's local awareness state. Exposed for
4342
+ * tests/diagnostics only — production code should not depend on this.
4343
+ * Returns `null` when monitoring is off (local state cleared) or when
4344
+ * the provider hasn't written any fields yet.
4345
+ */
4346
+ getLocalAwareness() {
4347
+ return this.provider.awareness.getLocalState();
4348
+ }
4267
4349
  snapshot() {
4268
4350
  return {
4269
4351
  fullDoc: serializeDocAsMarkdown(this.doc),
@@ -4272,23 +4354,38 @@ var RoomState = class {
4272
4354
  threadsBacklog: []
4273
4355
  };
4274
4356
  }
4275
- async nextEvent(timeoutMs) {
4357
+ async nextEvent(timeoutMs, signal) {
4276
4358
  const pending = this.queue.shift();
4277
4359
  if (pending) return pending;
4360
+ if (signal?.aborted) return { kind: "timeout" };
4278
4361
  return new Promise((resolve) => {
4279
- const timer = setTimeout(() => {
4362
+ const cleanup = () => {
4363
+ clearTimeout(timer);
4364
+ signal?.removeEventListener("abort", onAbort);
4280
4365
  const idx = this.waiters.indexOf(waiter);
4281
4366
  if (idx >= 0) this.waiters.splice(idx, 1);
4367
+ };
4368
+ const timer = setTimeout(() => {
4369
+ cleanup();
4282
4370
  resolve({ kind: "timeout" });
4283
4371
  }, timeoutMs);
4372
+ const onAbort = () => {
4373
+ cleanup();
4374
+ resolve({ kind: "timeout" });
4375
+ };
4376
+ signal?.addEventListener("abort", onAbort, { once: true });
4284
4377
  const waiter = (ev) => {
4285
- clearTimeout(timer);
4378
+ cleanup();
4286
4379
  resolve(ev);
4287
4380
  };
4288
4381
  this.waiters.push(waiter);
4289
4382
  });
4290
4383
  }
4291
4384
  destroy() {
4385
+ if (this.idleDisconnectTimer) {
4386
+ clearTimeout(this.idleDisconnectTimer);
4387
+ this.idleDisconnectTimer = null;
4388
+ }
4292
4389
  this.provider.destroy();
4293
4390
  this.doc.destroy();
4294
4391
  }
@@ -4605,6 +4702,18 @@ var SERVER_HOST = process.env.COMPOSER_SERVER_HOST ?? "usecomposer.app";
4605
4702
  var APP_BASE = process.env.COMPOSER_APP_BASE ?? "https://usecomposer.app";
4606
4703
  var rooms = /* @__PURE__ */ new Map();
4607
4704
  var identityCache = null;
4705
+ function teardownAllRooms() {
4706
+ for (const [roomId, state] of rooms) {
4707
+ try {
4708
+ state.setMonitoring(false);
4709
+ state.disconnect();
4710
+ state.destroy();
4711
+ } catch (err) {
4712
+ logError(`teardown error for room ${roomId}`, err);
4713
+ }
4714
+ }
4715
+ rooms.clear();
4716
+ }
4608
4717
  async function getIdentity() {
4609
4718
  if (!identityCache) {
4610
4719
  identityCache = await loadOrCreateIdentity(COMPOSER_DIR2);
@@ -4699,12 +4808,12 @@ var TOOL_DEFS = [
4699
4808
  },
4700
4809
  {
4701
4810
  name: "composer_next_event",
4702
- description: "Block for up to `timeoutSec` (default 600 / 10 min) 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`.",
4811
+ 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`.",
4703
4812
  inputSchema: {
4704
4813
  type: "object",
4705
4814
  properties: {
4706
4815
  roomId: { type: "string" },
4707
- timeoutSec: { type: "number", default: 600 }
4816
+ timeoutSec: { type: "number", default: 30 }
4708
4817
  },
4709
4818
  required: ["roomId"]
4710
4819
  }
@@ -4927,12 +5036,13 @@ async function handleCreateRoom(args) {
4927
5036
  actingAs,
4928
5037
  identity
4929
5038
  });
4930
- await state.waitForInitialSync();
5039
+ await state.ensureConnected();
4931
5040
  if (seedMarkdown) {
4932
5041
  writeMarkdownToFragment(state.doc.getXmlFragment("default"), seedMarkdown);
4933
5042
  }
4934
5043
  rooms.set(roomId, state);
4935
5044
  log(`composer room created \u2192 ${browserUrl}`, { roomId, actingAs });
5045
+ state.scheduleIdleDisconnect(3e4);
4936
5046
  return okResult({
4937
5047
  roomId,
4938
5048
  browserUrl,
@@ -4953,6 +5063,7 @@ async function handleJoinRoom(args) {
4953
5063
  roomId,
4954
5064
  actingAs: existing.actingAs
4955
5065
  });
5066
+ existing.scheduleIdleDisconnect(3e4);
4956
5067
  return okResult({
4957
5068
  roomId,
4958
5069
  browserUrl,
@@ -4974,9 +5085,10 @@ async function handleJoinRoom(args) {
4974
5085
  actingAs,
4975
5086
  identity
4976
5087
  });
4977
- await state.waitForInitialSync();
5088
+ await state.ensureConnected();
4978
5089
  rooms.set(roomId, state);
4979
5090
  log(`composer room joined \u2192 ${browserUrl}`, { roomId, actingAs });
5091
+ state.scheduleIdleDisconnect(3e4);
4980
5092
  return okResult({
4981
5093
  roomId,
4982
5094
  browserUrl,
@@ -4991,14 +5103,41 @@ function handleAttachRoom(args) {
4991
5103
  const state = getOrError(roomId);
4992
5104
  return okResult({ roomId, snapshot: state.snapshot() });
4993
5105
  }
4994
- var ACTIVITY_WINDOW_MS = 10 * 60 * 1e3;
5106
+ var ACTIVITY_WINDOW_MS = (() => {
5107
+ const raw = process.env.COMPOSER_ACTIVITY_WINDOW_MS;
5108
+ if (!raw) return 15 * 60 * 1e3;
5109
+ const n = Number(raw);
5110
+ return Number.isFinite(n) && n > 0 ? n : 15 * 60 * 1e3;
5111
+ })();
4995
5112
  var LEAVE_MESSAGE = "I've left the document. You can ask me to rejoin anytime and I'll continue replying.";
4996
- async function handleNextEvent(args) {
5113
+ async function handleNextEvent(args, signal) {
4997
5114
  const a = asObject(args);
4998
5115
  const roomId = asString(a.roomId, "roomId");
4999
- const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 600;
5116
+ const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 30;
5000
5117
  const state = getOrError(roomId);
5001
- const event = await state.nextEvent(timeoutSec * 1e3);
5118
+ if (state.hasIssuedGoodbye) {
5119
+ return errorResult(
5120
+ "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."
5121
+ );
5122
+ }
5123
+ state.setMonitoring(true);
5124
+ const onAbort = () => {
5125
+ state.setMonitoring(false);
5126
+ state.disconnect();
5127
+ };
5128
+ if (signal) {
5129
+ if (signal.aborted) onAbort();
5130
+ else signal.addEventListener("abort", onAbort, { once: true });
5131
+ }
5132
+ let event;
5133
+ try {
5134
+ event = await state.nextEvent(timeoutSec * 1e3, signal);
5135
+ } finally {
5136
+ signal?.removeEventListener("abort", onAbort);
5137
+ }
5138
+ if (signal?.aborted) {
5139
+ return okResult({ kind: "aborted" });
5140
+ }
5002
5141
  if (event.kind === "mention") {
5003
5142
  return okResult({
5004
5143
  ...event,
@@ -5024,6 +5163,9 @@ async function handleNextEvent(args) {
5024
5163
  }
5025
5164
  });
5026
5165
  }
5166
+ state.setMonitoring(false);
5167
+ state.disconnect();
5168
+ state.markGoodbyeIssued();
5027
5169
  return okResult({
5028
5170
  kind: "timeout",
5029
5171
  recentActivity: false,
@@ -5256,34 +5398,44 @@ function handleResolveThread(args) {
5256
5398
  comments.set(threadId, { ...existing, resolved: true });
5257
5399
  return okResult({ threadId, resolved: true });
5258
5400
  }
5259
- async function dispatchTool(name, args) {
5260
- switch (name) {
5261
- case "composer_create_room":
5262
- return handleCreateRoom(args);
5263
- case "composer_join_room":
5264
- return handleJoinRoom(args);
5265
- case "composer_attach_room":
5266
- return handleAttachRoom(args);
5267
- case "composer_next_event":
5268
- return handleNextEvent(args);
5269
- case "composer_get_section":
5270
- return handleGetSection(args);
5271
- case "composer_get_full_doc":
5272
- return handleGetFullDoc(args);
5273
- case "composer_get_thread":
5274
- return handleGetThread(args);
5275
- case "composer_add_comment":
5276
- return handleAddComment(args);
5277
- case "composer_reply_comment":
5278
- return handleReplyComment(args);
5279
- case "composer_add_suggestion":
5280
- return handleAddSuggestion(args);
5281
- case "composer_reply_suggestion":
5282
- return handleReplySuggestion(args);
5283
- case "composer_resolve_thread":
5284
- return handleResolveThread(args);
5285
- default:
5286
- return errorResult(`unknown tool: ${name}`);
5401
+ async function dispatchTool(name, args, signal) {
5402
+ const roomId = args && typeof args === "object" && "roomId" in args ? args.roomId : void 0;
5403
+ const needsExistingRoom = name !== "composer_create_room" && name !== "composer_join_room";
5404
+ const state = needsExistingRoom && typeof roomId === "string" ? rooms.get(roomId) : void 0;
5405
+ if (state) {
5406
+ await state.ensureConnected();
5407
+ }
5408
+ try {
5409
+ switch (name) {
5410
+ case "composer_create_room":
5411
+ return await handleCreateRoom(args);
5412
+ case "composer_join_room":
5413
+ return await handleJoinRoom(args);
5414
+ case "composer_attach_room":
5415
+ return handleAttachRoom(args);
5416
+ case "composer_next_event":
5417
+ return await handleNextEvent(args, signal);
5418
+ case "composer_get_section":
5419
+ return handleGetSection(args);
5420
+ case "composer_get_full_doc":
5421
+ return handleGetFullDoc(args);
5422
+ case "composer_get_thread":
5423
+ return handleGetThread(args);
5424
+ case "composer_add_comment":
5425
+ return handleAddComment(args);
5426
+ case "composer_reply_comment":
5427
+ return handleReplyComment(args);
5428
+ case "composer_add_suggestion":
5429
+ return handleAddSuggestion(args);
5430
+ case "composer_reply_suggestion":
5431
+ return handleReplySuggestion(args);
5432
+ case "composer_resolve_thread":
5433
+ return handleResolveThread(args);
5434
+ default:
5435
+ return errorResult(`unknown tool: ${name}`);
5436
+ }
5437
+ } finally {
5438
+ state?.scheduleIdleDisconnect(3e4);
5287
5439
  }
5288
5440
  }
5289
5441
  function buildServer() {
@@ -5294,13 +5446,13 @@ function buildServer() {
5294
5446
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
5295
5447
  tools: TOOL_DEFS
5296
5448
  }));
5297
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
5449
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
5298
5450
  const { name, arguments: args } = req.params;
5299
5451
  const roomId = args && typeof args === "object" && "roomId" in args ? args.roomId : void 0;
5300
5452
  const started = Date.now();
5301
5453
  log(`tool ${name} call`, { roomId });
5302
5454
  try {
5303
- const result = await dispatchTool(name, args);
5455
+ const result = await dispatchTool(name, args, extra.signal);
5304
5456
  const elapsedMs = Date.now() - started;
5305
5457
  if (result.isError) {
5306
5458
  const detail = result.content[0]?.text ?? "";
@@ -5327,7 +5479,7 @@ async function startMcpServer() {
5327
5479
  log("mcp server starting", {
5328
5480
  pid: process.pid,
5329
5481
  node: process.version,
5330
- build: "awareness-heartbeat-v1"
5482
+ build: "socket-driven-v1"
5331
5483
  });
5332
5484
  const server = buildServer();
5333
5485
  const transport = new StdioServerTransport();
@@ -5339,6 +5491,28 @@ async function startMcpServer() {
5339
5491
  logFile: LOG_FILE_PATH,
5340
5492
  pid: process.pid
5341
5493
  });
5494
+ installShutdownHandlers("stdio");
5495
+ }
5496
+ function installShutdownHandlers(transport) {
5497
+ let shuttingDown = false;
5498
+ const shutdown = (reason) => {
5499
+ if (shuttingDown) return;
5500
+ shuttingDown = true;
5501
+ log(`mcp shutdown \u2014 ${reason}`, { transport });
5502
+ try {
5503
+ teardownAllRooms();
5504
+ } catch (err) {
5505
+ logError("teardownAllRooms failed", err);
5506
+ }
5507
+ setTimeout(() => process.exit(0), 200).unref?.();
5508
+ };
5509
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
5510
+ process.on("SIGINT", () => shutdown("SIGINT"));
5511
+ process.on("SIGHUP", () => shutdown("SIGHUP"));
5512
+ if (transport === "stdio") {
5513
+ process.stdin.on("end", () => shutdown("stdin-end"));
5514
+ process.stdin.on("close", () => shutdown("stdin-close"));
5515
+ }
5342
5516
  }
5343
5517
  async function startMcpHttpServer(opts) {
5344
5518
  installCrashHandlers();
@@ -5346,7 +5520,7 @@ async function startMcpHttpServer(opts) {
5346
5520
  port: opts.port,
5347
5521
  pid: process.pid,
5348
5522
  node: process.version,
5349
- build: "awareness-heartbeat-v1"
5523
+ build: "socket-driven-v1"
5350
5524
  });
5351
5525
  const server = buildServer();
5352
5526
  const transport = new StreamableHTTPServerTransport({
@@ -5392,18 +5566,30 @@ async function startMcpHttpServer(opts) {
5392
5566
  appBase=${APP_BASE}`
5393
5567
  );
5394
5568
  });
5395
- const shutdown = () => {
5396
- log("mcp http server shutting down");
5569
+ installShutdownHandlers("http");
5570
+ const closeServer = () => {
5397
5571
  httpServer.close(() => process.exit(0));
5398
5572
  setTimeout(() => process.exit(0), 500).unref();
5399
5573
  };
5400
- process.on("SIGTERM", shutdown);
5401
- process.on("SIGINT", shutdown);
5574
+ process.on("SIGTERM", closeServer);
5575
+ process.on("SIGINT", closeServer);
5576
+ process.on("SIGHUP", closeServer);
5402
5577
  }
5578
+ var __test_dispatch = dispatchTool;
5579
+ var __test_setRoom = (id, state) => {
5580
+ rooms.set(id, state);
5581
+ };
5582
+ var __test_clearRooms = () => {
5583
+ rooms.clear();
5584
+ };
5403
5585
 
5404
5586
  export {
5405
5587
  loadOrCreateIdentity,
5406
5588
  logError,
5589
+ teardownAllRooms,
5407
5590
  startMcpServer,
5408
- startMcpHttpServer
5591
+ startMcpHttpServer,
5592
+ __test_dispatch,
5593
+ __test_setRoom,
5594
+ __test_clearRooms
5409
5595
  };
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  logError,
5
5
  startMcpHttpServer,
6
6
  startMcpServer
7
- } from "./chunk-UVXQZ2TN.js";
7
+ } from "./chunk-A5KBJAJW.js";
8
8
 
9
9
  // src/setup.ts
10
10
  import * as fs from "fs/promises";
package/dist/mcp.js CHANGED
@@ -1,8 +1,16 @@
1
1
  import {
2
+ __test_clearRooms,
3
+ __test_dispatch,
4
+ __test_setRoom,
2
5
  startMcpHttpServer,
3
- startMcpServer
4
- } from "./chunk-UVXQZ2TN.js";
6
+ startMcpServer,
7
+ teardownAllRooms
8
+ } from "./chunk-A5KBJAJW.js";
5
9
  export {
10
+ __test_clearRooms,
11
+ __test_dispatch,
12
+ __test_setRoom,
6
13
  startMcpHttpServer,
7
- startMcpServer
14
+ startMcpServer,
15
+ teardownAllRooms
8
16
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@composer-app/mcp",
3
- "version": "0.0.1-beta.4",
3
+ "version": "0.0.1-beta.5",
4
4
  "description": "Composer MCP",
5
5
  "license": "MIT",
6
6
  "author": "Josh Philpott",
package/skill/SKILL.md CHANGED
@@ -36,13 +36,14 @@ two ordered steps — the field names encode the order:
36
36
  also carries the `@<your-name>` tagging hint. Relay it; you can
37
37
  paraphrase lightly but do not drop the URL or the mention syntax.
38
38
  2. `step2_callTool` — a structured `{ tool, args, why }` directive for
39
- the follow-up `composer_next_event` call. Execute it AFTER step 1,
40
- before ending your turn.
39
+ the `composer_next_event` loop. **Do not run it inline.** Hand the
40
+ loop to a background subagent (see "Monitor — runs in a subagent"
41
+ below). End your turn once the subagent is spawned.
41
42
 
42
43
  Skipping step 1 leaves the user without the URL — they have no way into
43
- the doc. Skipping step 2 leaves the room attached but silent; saying
44
- "I'm monitoring" without making the call is a lie, the agent will miss
45
- every mention.
44
+ the doc. Skipping the subagent spawn leaves the room attached but
45
+ silent; saying "I'm monitoring" without spawning the loop is a lie,
46
+ every mention gets missed.
46
47
 
47
48
  **Seeding — prefer a file path when one exists.** Pick exactly one:
48
49
 
@@ -64,16 +65,63 @@ Triggers: a share prompt with a Composer URL, "/composer join <url>".
64
65
  Action: extract the URL from the prompt and call `composer_join_room({ url })`.
65
66
  Same first-run rule as Create. On success, the return carries the same
66
67
  ordered pair: output `step1_sayToUser` first (confirms the URL the user
67
- just joined), then execute `step2_callTool`.
68
-
69
- ### 3. Monitor
70
- Triggers: "watch this doc", or automatically after join/create.
71
- Action: call `composer_next_event({ roomId })` in a loop (default timeout
72
- is 10 minutes). **The loop is always-on.** Every return carries a
73
- structured directive — follow it without waiting for user input.
68
+ just joined), then spawn the monitor subagent — same flow as Create
69
+ (see "Monitor — runs in a subagent" below).
70
+
71
+ ### 3. Monitor runs in a subagent
72
+
73
+ Triggers: "watch this doc", or automatically after create/join.
74
+
75
+ The monitor loop is delegated to a background subagent. Polling
76
+ `composer_next_event` from the main thread fills the conversation
77
+ context with idle-tick chatter and mention-handling that belongs in
78
+ the doc, not the terminal. Spawn the subagent, end your turn, let the
79
+ doc be the conversation.
80
+
81
+ **How to spawn (Claude Code).** Use the `Agent` tool with:
82
+
83
+ - `subagent_type: "general-purpose"`
84
+ - `run_in_background: true`
85
+ - `description: "Composer monitor: <roomId>"` — short, identifies the room
86
+ - `prompt`: the template below, with `{roomId}` and `{actingAs}` filled in
87
+
88
+ Prompt template:
89
+
90
+ > Invoke the composer skill, then run the monitor loop for room
91
+ > `{roomId}` as `{actingAs}`. Your only job is the in-doc conversation:
92
+ > call `composer_next_event({ roomId: "{roomId}" })` and follow each
93
+ > return's `requiredNextToolCall` directive verbatim, looping until the
94
+ > goodbye branch fires.
95
+ >
96
+ > All write tools (`composer_add_comment`, `composer_add_suggestion`,
97
+ > `composer_reply_comment`, `composer_reply_suggestion`,
98
+ > `composer_resolve_thread`) are yours to use as the skill describes.
99
+ > The doc IS your conversation — do not narrate to the parent between
100
+ > ticks.
101
+ >
102
+ > Exit and return when ANY of these happen:
103
+ > 1. `composer_next_event` returns `kind: "timeout"` with
104
+ > `recentActivity: false` — say `userMessage` EXACTLY in the doc
105
+ > (it's the goodbye line), then exit.
106
+ > 2. A request inside the doc clearly needs the parent terminal
107
+ > (a code change, a shell command, an external action the parent
108
+ > would do). Post a short reply: "I'll get on this in the terminal,"
109
+ > then exit with a one-sentence summary of the ask.
110
+ > 3. An unrecoverable error (auth lost, room destroyed). Exit with
111
+ > the error.
112
+
113
+ **Main thread after spawning.** Turn ends. Do **not** also call
114
+ `composer_next_event` — two listeners on the same room means duplicated
115
+ replies. If the user asks "what's happening in Composer?", check the
116
+ subagent's status (`TaskList` / `TaskOutput`) rather than re-entering
117
+ the loop yourself.
118
+
119
+ **Inside the loop (what the subagent does).** Default timeout is 30
120
+ seconds. Every return carries a structured directive — follow it
121
+ without waiting for user input.
74
122
 
75
123
  On `mention`: handle the event (reply / suggestion / resolve as needed),
76
- output any user-facing text, then execute `requiredNextToolCall` — which
124
+ output any in-doc action, then execute `requiredNextToolCall` — which
77
125
  is another `composer_next_event` call. Do not pause for the user to
78
126
  acknowledge. The doc is the conversation.
79
127
 
@@ -82,9 +130,8 @@ On `timeout`: check `recentActivity`.
82
130
  Execute it — the user is still working, just not tagging you.
83
131
  - `recentActivity: false` → the return includes `userMessage` and
84
132
  `instruction` but NO `requiredNextToolCall`. Say `userMessage`
85
- EXACTLY ("I've left the document…") and stop calling
86
- `composer_next_event` until the user asks you to rejoin. Do not
87
- paraphrase — users recognize the line across sessions.
133
+ EXACTLY ("I've left the document…") and exit (per exit rule 1
134
+ above). Do not paraphrase users recognize the line across sessions.
88
135
 
89
136
  On `mention`, the event contains everything you need to act in one turn:
90
137
 
@@ -134,8 +181,12 @@ need to catch up on what's already been said.
134
181
  When in doubt, reply — the user can always ignore you.
135
182
 
136
183
  ### 4. Act
137
- Triggers: direct requests like "add a summary to section 2".
138
- Action: already attached; call the write tools and report back concisely.
184
+ Triggers: direct requests in the terminal like "add a summary to section 2".
185
+ Action: the main thread is also attached to the room call the write
186
+ tools from here and report back concisely. Don't hand terminal directives
187
+ to the monitor subagent; the subagent handles in-doc mentions, the main
188
+ thread handles in-terminal asks. They share the same MCP, so writes from
189
+ either show up in the doc.
139
190
 
140
191
  ## Tools
141
192