@composer-app/mcp 0.0.1-beta.3 → 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.
@@ -3833,7 +3833,9 @@ function getElementText(el) {
3833
3833
  let out = "";
3834
3834
  for (const child of el.toArray()) {
3835
3835
  if (child instanceof Y2.XmlText) {
3836
- out += child.toString();
3836
+ for (const op of child.toDelta()) {
3837
+ if (typeof op.insert === "string") out += op.insert;
3838
+ }
3837
3839
  } else if (isXmlElement(child)) {
3838
3840
  out += getElementText(child);
3839
3841
  }
@@ -3879,6 +3881,31 @@ function getOutline(doc) {
3879
3881
  });
3880
3882
  return outline;
3881
3883
  }
3884
+ function getSectionBlockRange(doc, headingId) {
3885
+ const blocks = topLevelBlocks(doc);
3886
+ let startIndex = -1;
3887
+ let startLevel = 0;
3888
+ for (let i = 0; i < blocks.length; i++) {
3889
+ const block = blocks[i];
3890
+ if (block.nodeName !== "heading") continue;
3891
+ const id = makeHeadingId(getElementText(block), i);
3892
+ if (id === headingId) {
3893
+ startIndex = i;
3894
+ startLevel = getHeadingLevel(block);
3895
+ break;
3896
+ }
3897
+ }
3898
+ if (startIndex === -1) return null;
3899
+ let endIndex = blocks.length;
3900
+ for (let i = startIndex + 1; i < blocks.length; i++) {
3901
+ const block = blocks[i];
3902
+ if (block.nodeName === "heading" && getHeadingLevel(block) <= startLevel) {
3903
+ endIndex = i;
3904
+ break;
3905
+ }
3906
+ }
3907
+ return { start: startIndex, end: endIndex };
3908
+ }
3882
3909
  function getSection(doc, headingId) {
3883
3910
  const blocks = topLevelBlocks(doc);
3884
3911
  let startIndex = -1;
@@ -3922,17 +3949,33 @@ import * as Y3 from "yjs";
3922
3949
  function buildFlatMap(fragment) {
3923
3950
  let flat = "";
3924
3951
  const map = [];
3952
+ const blockFlatStarts = [];
3925
3953
  const walk = (node) => {
3926
3954
  if (node instanceof Y3.XmlText) {
3927
- const text = node.toString();
3928
- for (let i = 0; i < text.length; i++) {
3929
- map.push({
3930
- xmlText: node,
3931
- offsetInText: i,
3932
- flatIndex: flat.length + i
3933
- });
3955
+ let localOffset = 0;
3956
+ for (const op of node.toDelta()) {
3957
+ const value = op.insert;
3958
+ if (typeof value === "string") {
3959
+ const base = flat.length;
3960
+ for (let i = 0; i < value.length; i++) {
3961
+ map.push({
3962
+ xmlText: node,
3963
+ offsetInText: localOffset + i,
3964
+ flatIndex: base + i
3965
+ });
3966
+ }
3967
+ flat += value;
3968
+ localOffset += value.length;
3969
+ } else if (value !== void 0) {
3970
+ map.push({
3971
+ xmlText: node,
3972
+ offsetInText: localOffset,
3973
+ flatIndex: flat.length
3974
+ });
3975
+ flat += "\uFFFC";
3976
+ localOffset += 1;
3977
+ }
3934
3978
  }
3935
- flat += text;
3936
3979
  return;
3937
3980
  }
3938
3981
  for (const child of node.toArray()) {
@@ -3943,6 +3986,7 @@ function buildFlatMap(fragment) {
3943
3986
  };
3944
3987
  const topLevel = fragment.toArray();
3945
3988
  topLevel.forEach((node, idx) => {
3989
+ blockFlatStarts.push(flat.length);
3946
3990
  if (node instanceof Y3.XmlText || node instanceof Y3.XmlElement) {
3947
3991
  walk(node);
3948
3992
  }
@@ -3950,7 +3994,7 @@ function buildFlatMap(fragment) {
3950
3994
  flat += "\n";
3951
3995
  }
3952
3996
  });
3953
- return { flat, map };
3997
+ return { flat, map, blockFlatStarts };
3954
3998
  }
3955
3999
  function findNthOccurrence(flat, needle, n) {
3956
4000
  if (needle.length === 0) return null;
@@ -4068,11 +4112,23 @@ function resolveServerAnchor(doc, spec) {
4068
4112
  return { ok: false, error: "text_not_found", currentSectionText };
4069
4113
  }
4070
4114
  const fragment = doc.getXmlFragment("default");
4071
- const { flat, map } = buildFlatMap(fragment);
4072
- const flatStart = findNthOccurrence(flat, spec.textToFind, occurrence);
4073
- if (flatStart === null) {
4115
+ const { flat, map, blockFlatStarts } = buildFlatMap(fragment);
4116
+ const range = getSectionBlockRange(doc, spec.headingId);
4117
+ if (!range) {
4118
+ return {
4119
+ ok: false,
4120
+ error: "section_not_found",
4121
+ currentSectionText
4122
+ };
4123
+ }
4124
+ const sectionFlatStart = blockFlatStarts[range.start] ?? 0;
4125
+ const sectionFlatEnd = range.end < blockFlatStarts.length ? blockFlatStarts[range.end] : flat.length;
4126
+ const sectionFlat = flat.slice(sectionFlatStart, sectionFlatEnd);
4127
+ const sectionRelStart = findNthOccurrence(sectionFlat, spec.textToFind, occurrence);
4128
+ if (sectionRelStart === null) {
4074
4129
  return { ok: false, error: "text_not_found", currentSectionText };
4075
4130
  }
4131
+ const flatStart = sectionFlatStart + sectionRelStart;
4076
4132
  const flatEnd = flatStart + spec.textToFind.length;
4077
4133
  const startEntry = lookupFlatIndex(map, flatStart);
4078
4134
  if (!startEntry) {
@@ -4116,58 +4172,42 @@ var RoomState = class {
4116
4172
  /**
4117
4173
  * Threads the agent has already written to (created a comment, added a
4118
4174
  * suggestion, or replied). Once a thread is "active", subsequent remote
4119
- * replies on it are surfaced to the model even if they don't say
4120
- * `@agent` — the conversation is already in progress, and requiring a
4175
+ * replies on it are surfaced to the model even if they don't name the
4176
+ * agent — the conversation is already in progress, and requiring a
4121
4177
  * re-mention every turn is bad UX.
4122
4178
  */
4123
4179
  activeThreads = /* @__PURE__ */ new Set();
4180
+ /**
4181
+ * Timestamp (ms since epoch) of the most recent non-local transaction on
4182
+ * this doc. Initialized to construction time so a freshly-attached room
4183
+ * with an idle user bails out after the first full timeout window rather
4184
+ * than looking "fresh" forever. Bumped by `attachRemoteActivityTracker`
4185
+ * on every remote edit, comment, suggestion, or activity-feed write.
4186
+ */
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;
4124
4196
  constructor(opts) {
4125
4197
  this.roomId = opts.roomId;
4126
4198
  this.actingAs = opts.actingAs;
4127
4199
  this.identity = opts.identity;
4128
4200
  this.watchMentions();
4201
+ attachRemoteActivityTracker(this.doc, {
4202
+ onActivity: (at) => {
4203
+ this._lastRemoteActivityAt = at;
4204
+ }
4205
+ });
4129
4206
  this.provider = new YProvider(opts.serverHost, opts.roomId, this.doc, {
4130
4207
  party: "composer-room",
4131
- connect: true,
4208
+ connect: false,
4132
4209
  WebSocketPolyfill: WebSocket
4133
4210
  });
4134
- this.provider.awareness.setLocalStateField("user", {
4135
- name: opts.actingAs,
4136
- color: opts.identity.color,
4137
- userId: opts.identity.userId,
4138
- isAgent: true
4139
- });
4140
- this.installAwarenessHeartbeat();
4141
- }
4142
- /**
4143
- * Re-broadcast the MCP's awareness every 15s.
4144
- *
4145
- * y-partyserver's provider disables the y-protocols awareness
4146
- * `_checkInterval` (see `clearInterval(awareness._checkInterval)` in
4147
- * `y-partyserver/dist/provider/index.js`), so the MCP sends its awareness
4148
- * exactly once — on connect — and never heartbeats after that. Combined
4149
- * with Cloudflare Durable Object hibernation (which evicts the server's
4150
- * in-memory `document.awareness` Map on wake), this means a browser that
4151
- * connects more than ~60s after the MCP sees an empty awareness dump in
4152
- * `onConnect` and never learns the agent is there. The user's own
4153
- * awareness flows the other direction fine (they send on connect, server
4154
- * broadcasts to the MCP), which is why the failure is asymmetric.
4155
- *
4156
- * y-partyserver's provider listens to `awareness.on("change", ...)`, and
4157
- * y-protocols only fires `change` when the new state is deep-unequal to
4158
- * the previous one. Re-setting an identical state emits `update` but NOT
4159
- * `change`, so the provider never sends a wire frame. We bump a throwaway
4160
- * `_hb` field each tick to guarantee deep-inequality, forcing the change
4161
- * event and a broadcast. 15s is well under any realistic hibernation gap.
4162
- */
4163
- installAwarenessHeartbeat() {
4164
- const heartbeat = setInterval(() => {
4165
- const local = this.provider.awareness.getLocalState();
4166
- if (local !== null) {
4167
- this.provider.awareness.setLocalState({ ...local, _hb: Date.now() });
4168
- }
4169
- }, 15e3);
4170
- heartbeat.unref?.();
4171
4211
  }
4172
4212
  /**
4173
4213
  * Resolves when the provider has completed its first sync handshake.
@@ -4195,6 +4235,117 @@ var RoomState = class {
4195
4235
  this.provider.on("sync", onSync);
4196
4236
  });
4197
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
+ }
4198
4349
  snapshot() {
4199
4350
  return {
4200
4351
  fullDoc: serializeDocAsMarkdown(this.doc),
@@ -4203,34 +4354,53 @@ var RoomState = class {
4203
4354
  threadsBacklog: []
4204
4355
  };
4205
4356
  }
4206
- async nextEvent(timeoutMs) {
4357
+ async nextEvent(timeoutMs, signal) {
4207
4358
  const pending = this.queue.shift();
4208
4359
  if (pending) return pending;
4360
+ if (signal?.aborted) return { kind: "timeout" };
4209
4361
  return new Promise((resolve) => {
4210
- const timer = setTimeout(() => {
4362
+ const cleanup = () => {
4363
+ clearTimeout(timer);
4364
+ signal?.removeEventListener("abort", onAbort);
4211
4365
  const idx = this.waiters.indexOf(waiter);
4212
4366
  if (idx >= 0) this.waiters.splice(idx, 1);
4367
+ };
4368
+ const timer = setTimeout(() => {
4369
+ cleanup();
4213
4370
  resolve({ kind: "timeout" });
4214
4371
  }, timeoutMs);
4372
+ const onAbort = () => {
4373
+ cleanup();
4374
+ resolve({ kind: "timeout" });
4375
+ };
4376
+ signal?.addEventListener("abort", onAbort, { once: true });
4215
4377
  const waiter = (ev) => {
4216
- clearTimeout(timer);
4378
+ cleanup();
4217
4379
  resolve(ev);
4218
4380
  };
4219
4381
  this.waiters.push(waiter);
4220
4382
  });
4221
4383
  }
4222
4384
  destroy() {
4385
+ if (this.idleDisconnectTimer) {
4386
+ clearTimeout(this.idleDisconnectTimer);
4387
+ this.idleDisconnectTimer = null;
4388
+ }
4223
4389
  this.provider.destroy();
4224
4390
  this.doc.destroy();
4225
4391
  }
4226
4392
  /**
4227
4393
  * Mark a thread as active so subsequent remote replies on it surface as
4228
- * mentions even without an explicit `@agent`. Called by MCP write-tool
4229
- * handlers right after the agent creates or replies on a thread.
4394
+ * mentions even without an explicit mention of this agent. Called by MCP
4395
+ * write-tool handlers right after the agent creates or replies on a thread.
4230
4396
  */
4231
4397
  markThreadActive(threadId) {
4232
4398
  this.activeThreads.add(threadId);
4233
4399
  }
4400
+ /** Timestamp (ms) of the most recent non-local transaction on this doc. */
4401
+ get lastRemoteActivityAt() {
4402
+ return this._lastRemoteActivityAt;
4403
+ }
4234
4404
  enqueue(ev) {
4235
4405
  const waiter = this.waiters.shift();
4236
4406
  if (waiter) waiter(ev);
@@ -4241,20 +4411,60 @@ var RoomState = class {
4241
4411
  enqueue: (ev) => this.enqueue(ev),
4242
4412
  seen: this.seen,
4243
4413
  activeThreads: this.activeThreads,
4244
- identityUserId: this.identity.userId
4414
+ identityUserId: this.identity.userId,
4415
+ actingAs: this.actingAs,
4416
+ getSoloHumanAuthorId: () => this.computeSoloHumanAuthorId()
4245
4417
  });
4246
4418
  }
4419
+ /**
4420
+ * Read the provider's awareness map and decide whether the room is "solo"
4421
+ * right now — exactly one agent (us) and exactly one human. Returns the
4422
+ * sole human's `userId` in that case, else `undefined`. Any other shape
4423
+ * (multiple humans, multiple agents, empty, no `user` payload) returns
4424
+ * `undefined` so the observer stays silent unless the gate passes.
4425
+ */
4426
+ computeSoloHumanAuthorId() {
4427
+ const states = this.provider.awareness.getStates();
4428
+ let humanCount = 0;
4429
+ let agentCount = 0;
4430
+ let soloHuman;
4431
+ for (const state of states.values()) {
4432
+ const user = state?.user;
4433
+ if (!user || typeof user.userId !== "string") continue;
4434
+ if (user.isAgent) {
4435
+ agentCount++;
4436
+ } else {
4437
+ humanCount++;
4438
+ soloHuman = user.userId;
4439
+ }
4440
+ }
4441
+ if (humanCount !== 1 || agentCount !== 1) return void 0;
4442
+ return soloHuman;
4443
+ }
4247
4444
  };
4248
- var AGENT_MENTION_RE = /@agent/i;
4249
- var hasAgentMention = (text) => AGENT_MENTION_RE.test(text);
4445
+ function buildActingAsMatcher(actingAs) {
4446
+ if (!actingAs) return () => false;
4447
+ const escaped = actingAs.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4448
+ const re = new RegExp(`@${escaped}(?![\\w])`, "i");
4449
+ return (text) => re.test(text);
4450
+ }
4451
+ function checkMentionsSidecar(mentions, identityUserId) {
4452
+ if (!Array.isArray(mentions) || mentions.length === 0) return "absent";
4453
+ if (identityUserId && mentions.includes(identityUserId)) return "hit";
4454
+ return "miss";
4455
+ }
4456
+ var ANY_AT_MENTION_RE = /@\w/;
4250
4457
  function attachMentionObserver(doc, opts) {
4251
4458
  const seen = opts.seen ?? /* @__PURE__ */ new Set();
4252
4459
  const activeThreads = opts.activeThreads ?? /* @__PURE__ */ new Set();
4253
4460
  const enqueue = opts.enqueue;
4254
4461
  const identityUserId = opts.identityUserId;
4462
+ const hasActingAsMention = buildActingAsMatcher(opts.actingAs);
4463
+ const getSoloHumanAuthorId = opts.getSoloHumanAuthorId ?? (() => void 0);
4255
4464
  const scan = (kind, threadId, entry, isLocal) => {
4256
4465
  if (!entry || typeof entry !== "object") return;
4257
4466
  const record = entry;
4467
+ const bodyAuthorUserId = typeof record.authorUserId === "string" ? record.authorUserId : void 0;
4258
4468
  const replies = Array.isArray(record.replies) ? record.replies : [];
4259
4469
  let lastAgentIdx = -1;
4260
4470
  if (identityUserId !== void 0) {
@@ -4268,7 +4478,9 @@ function attachMentionObserver(doc, opts) {
4268
4478
  }
4269
4479
  const bodyAnswered = lastAgentIdx >= 0;
4270
4480
  const body = typeof record.text === "string" ? record.text : typeof record.replacementText === "string" ? record.replacementText : "";
4271
- if (hasAgentMention(body) && !seen.has(threadId)) {
4481
+ const bodySidecar = checkMentionsSidecar(record.mentions, identityUserId);
4482
+ const bodyHit = bodySidecar === "hit" || bodySidecar === "absent" && hasActingAsMention(body);
4483
+ if (bodyHit && !seen.has(threadId)) {
4272
4484
  seen.add(threadId);
4273
4485
  if (!isLocal && !bodyAnswered) {
4274
4486
  enqueue({
@@ -4280,6 +4492,19 @@ function attachMentionObserver(doc, opts) {
4280
4492
  ...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
4281
4493
  });
4282
4494
  }
4495
+ } else if (!seen.has(threadId) && !isLocal && !bodyAnswered && bodySidecar !== "miss" && !ANY_AT_MENTION_RE.test(body)) {
4496
+ const soloHuman = getSoloHumanAuthorId();
4497
+ if (soloHuman && bodyAuthorUserId === soloHuman) {
4498
+ seen.add(threadId);
4499
+ enqueue({
4500
+ kind: "mention",
4501
+ threadId,
4502
+ threadKind: kind,
4503
+ threadText: body,
4504
+ reason: "solo_room",
4505
+ ...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
4506
+ });
4507
+ }
4283
4508
  }
4284
4509
  for (let i = 0; i < replies.length; i++) {
4285
4510
  const r = replies[i];
@@ -4292,16 +4517,25 @@ function attachMentionObserver(doc, opts) {
4292
4517
  seen.add(key);
4293
4518
  if (isLocal) continue;
4294
4519
  if (i <= lastAgentIdx) continue;
4295
- const isDirect = hasAgentMention(reply.text);
4520
+ const replySidecar = checkMentionsSidecar(reply.mentions, identityUserId);
4521
+ const isDirect = replySidecar === "hit" || replySidecar === "absent" && hasActingAsMention(reply.text);
4296
4522
  const inActiveThread = activeThreads.has(threadId);
4297
- if (!isDirect && !inActiveThread) continue;
4523
+ let reason = isDirect ? "direct_mention" : inActiveThread ? "active_thread" : null;
4524
+ if (!reason) {
4525
+ const replyAuthor = typeof reply.authorUserId === "string" ? reply.authorUserId : void 0;
4526
+ const soloHuman = getSoloHumanAuthorId();
4527
+ if (replySidecar !== "miss" && !ANY_AT_MENTION_RE.test(reply.text) && soloHuman && replyAuthor === soloHuman) {
4528
+ reason = "solo_room";
4529
+ }
4530
+ }
4531
+ if (!reason) continue;
4298
4532
  enqueue({
4299
4533
  kind: "mention",
4300
4534
  threadId,
4301
4535
  threadKind: kind,
4302
4536
  threadText: reply.text,
4303
4537
  replyId: reply.id,
4304
- reason: isDirect ? "direct_mention" : "active_thread",
4538
+ reason,
4305
4539
  ...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
4306
4540
  });
4307
4541
  }
@@ -4320,6 +4554,12 @@ function attachMentionObserver(doc, opts) {
4320
4554
  function hashState(doc) {
4321
4555
  return Buffer.from(Y4.encodeStateVector(doc)).toString("base64");
4322
4556
  }
4557
+ function attachRemoteActivityTracker(doc, opts) {
4558
+ const now = opts.now ?? (() => Date.now());
4559
+ doc.on("afterTransaction", (tr) => {
4560
+ if (!tr.local) opts.onActivity(now());
4561
+ });
4562
+ }
4323
4563
 
4324
4564
  // src/identity.ts
4325
4565
  import * as fs from "fs/promises";
@@ -4352,7 +4592,9 @@ function pickColor() {
4352
4592
  function isValidIdentity(value) {
4353
4593
  if (typeof value !== "object" || value === null) return false;
4354
4594
  const v = value;
4355
- return typeof v.userId === "string" && typeof v.color === "string";
4595
+ if (typeof v.userId !== "string" || typeof v.color !== "string") return false;
4596
+ if (v.name !== void 0 && typeof v.name !== "string") return false;
4597
+ return true;
4356
4598
  }
4357
4599
  async function loadOrCreateIdentity(dir) {
4358
4600
  const filePath = path.join(dir, FILE_NAME);
@@ -4360,18 +4602,16 @@ async function loadOrCreateIdentity(dir) {
4360
4602
  const raw = await fs.readFile(filePath, "utf8");
4361
4603
  const parsed = JSON.parse(raw);
4362
4604
  if (isValidIdentity(parsed)) {
4363
- if (isPaletteColor(parsed.color)) {
4364
- return { userId: parsed.userId, color: parsed.color };
4365
- }
4366
- const migrated = {
4605
+ const base = {
4367
4606
  userId: parsed.userId,
4368
- color: pickColor()
4607
+ color: isPaletteColor(parsed.color) ? parsed.color : pickColor(),
4608
+ ...parsed.name ? { name: parsed.name } : {}
4369
4609
  };
4370
- await fs.mkdir(dir, { recursive: true });
4371
- await fs.writeFile(filePath, JSON.stringify(migrated, null, 2), {
4372
- mode: FILE_MODE
4373
- });
4374
- return migrated;
4610
+ if (base.color === parsed.color) {
4611
+ return base;
4612
+ }
4613
+ await saveIdentity(dir, base);
4614
+ return base;
4375
4615
  }
4376
4616
  } catch (err) {
4377
4617
  const code = err.code;
@@ -4382,11 +4622,15 @@ async function loadOrCreateIdentity(dir) {
4382
4622
  userId: nanoid2(),
4383
4623
  color: pickColor()
4384
4624
  };
4625
+ await saveIdentity(dir, identity);
4626
+ return identity;
4627
+ }
4628
+ async function saveIdentity(dir, identity) {
4629
+ const filePath = path.join(dir, FILE_NAME);
4385
4630
  await fs.mkdir(dir, { recursive: true });
4386
4631
  await fs.writeFile(filePath, JSON.stringify(identity, null, 2), {
4387
4632
  mode: FILE_MODE
4388
4633
  });
4389
- return identity;
4390
4634
  }
4391
4635
 
4392
4636
  // src/mdToFragment.ts
@@ -4458,12 +4702,48 @@ var SERVER_HOST = process.env.COMPOSER_SERVER_HOST ?? "usecomposer.app";
4458
4702
  var APP_BASE = process.env.COMPOSER_APP_BASE ?? "https://usecomposer.app";
4459
4703
  var rooms = /* @__PURE__ */ new Map();
4460
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
+ }
4461
4717
  async function getIdentity() {
4462
4718
  if (!identityCache) {
4463
4719
  identityCache = await loadOrCreateIdentity(COMPOSER_DIR2);
4464
4720
  }
4465
4721
  return identityCache;
4466
4722
  }
4723
+ async function resolveActingAs(actingAsArg) {
4724
+ const identity = await getIdentity();
4725
+ if (identity.name) return { actingAs: identity.name, isFirstRun: false };
4726
+ if (!actingAsArg) {
4727
+ throw new Error(
4728
+ [
4729
+ "First Composer room on this machine \u2014 you need a persistent name before proceeding. STOP and ask the user what to call you.",
4730
+ "",
4731
+ "Offer ONE suggested default they can accept with a tap:",
4732
+ ` - If you know the user's first name from conversation context, suggest "<FirstName>'s Agent" (e.g. "Josh's Agent").`,
4733
+ " - 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.",
4734
+ "",
4735
+ `Phrase it like: "I'll go by <suggested name> in Composer docs \u2014 sound good, or pick your own?"`,
4736
+ "",
4737
+ "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."
4738
+ ].join("\n")
4739
+ );
4740
+ }
4741
+ const next = { ...identity, name: actingAsArg };
4742
+ await saveIdentity(COMPOSER_DIR2, next);
4743
+ identityCache = next;
4744
+ log("agent name persisted", { name: actingAsArg, userId: identity.userId });
4745
+ return { actingAs: actingAsArg, isFirstRun: true };
4746
+ }
4467
4747
  function getOrError(roomId) {
4468
4748
  const state = rooms.get(roomId);
4469
4749
  if (!state) throw new Error(`not attached to room: ${roomId}`);
@@ -4482,7 +4762,7 @@ function parseRoomIdFromUrl(url) {
4482
4762
  var TOOL_DEFS = [
4483
4763
  {
4484
4764
  name: "composer_create_room",
4485
- description: "Create a new Composer room. Returns { roomId, browserUrl, snapshot }. Seed the doc by passing either `seedMarkdownPath` (absolute path to a markdown file on disk \u2014 preferred when the markdown already lives in a file, avoids streaming the content through the model) OR `seedMarkdown` (inline string, for content you generated in-turn). Pass exactly one. The seed file is read once at creation and never written back \u2014 edits in Composer stay in the room.",
4765
+ description: "Create a new Composer room. Returns { roomId, browserUrl, snapshot, actingAs, step1_sayToUser, step2_callTool }. EXECUTE step1 AND step2 IN ORDER before ending your turn: first output `step1_sayToUser` (it contains the browserUrl \u2014 the user needs that link or they can't open the doc), then call the tool named in `step2_callTool`. Skipping step2 means your agent is silent in the room; skipping step1 means the user has no way in. Seed the doc by passing either `seedMarkdownPath` (absolute path to a markdown file on disk \u2014 preferred when the markdown already lives in a file, avoids streaming the content through the model) OR `seedMarkdown` (inline string, for content you generated in-turn). Pass exactly one. The seed file is read once at creation and never written back \u2014 edits in Composer stay in the room.",
4486
4766
  inputSchema: {
4487
4767
  type: "object",
4488
4768
  properties: {
@@ -4497,22 +4777,24 @@ var TOOL_DEFS = [
4497
4777
  },
4498
4778
  actingAs: {
4499
4779
  type: "string",
4500
- description: `Agent display name for this room, e.g. "Josh's Agent".`
4780
+ description: 'Your display name in the doc. FIRST-RUN FLOW: if you haven\'t saved a name yet, do NOT guess \u2014 call without `actingAs`, the MCP returns an error that instructs you to ask the user. Ask them ("what should I be called in Composer?") and retry with their answer. The name persists to ~/.composer/user.json. OPTIONAL on subsequent calls \u2014 the stored name wins and any value here is ignored.'
4501
4781
  }
4502
- },
4503
- required: ["actingAs"]
4782
+ }
4504
4783
  }
4505
4784
  },
4506
4785
  {
4507
4786
  name: "composer_join_room",
4508
- description: "Join an existing Composer room by browser URL. Returns the attach snapshot.",
4787
+ description: "Join an existing Composer room by browser URL. Returns { roomId, browserUrl, snapshot, actingAs, step1_sayToUser, step2_callTool }. EXECUTE step1 AND step2 IN ORDER before ending your turn: first output `step1_sayToUser` (it contains the browserUrl so the user can confirm which doc you joined), then call the tool named in `step2_callTool`. Skipping step2 means your agent is silent in the room; skipping step1 leaves the user guessing which doc you landed in.",
4509
4788
  inputSchema: {
4510
4789
  type: "object",
4511
4790
  properties: {
4512
4791
  url: { type: "string" },
4513
- actingAs: { type: "string" }
4792
+ actingAs: {
4793
+ type: "string",
4794
+ description: "Your display name in the doc. FIRST-RUN FLOW: if you haven't saved a name yet, call without `actingAs`, get the error, ASK THE USER what to be called, then retry with their answer. Persists to ~/.composer/user.json. OPTIONAL on subsequent calls \u2014 the stored name wins."
4795
+ }
4514
4796
  },
4515
- required: ["url", "actingAs"]
4797
+ required: ["url"]
4516
4798
  }
4517
4799
  },
4518
4800
  {
@@ -4526,12 +4808,12 @@ var TOOL_DEFS = [
4526
4808
  },
4527
4809
  {
4528
4810
  name: "composer_next_event",
4529
- description: "Block until a remote @agent mention arrives or the timeout elapses.",
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`.",
4530
4812
  inputSchema: {
4531
4813
  type: "object",
4532
4814
  properties: {
4533
4815
  roomId: { type: "string" },
4534
- timeoutSec: { type: "number", default: 300 }
4816
+ timeoutSec: { type: "number", default: 30 }
4535
4817
  },
4536
4818
  required: ["roomId"]
4537
4819
  }
@@ -4557,9 +4839,21 @@ var TOOL_DEFS = [
4557
4839
  required: ["roomId"]
4558
4840
  }
4559
4841
  },
4842
+ {
4843
+ name: "composer_get_thread",
4844
+ description: "Read the full state of a comment or suggestion thread by id. Returns the body (or replacementText), every reply (with author, text, timestamp, optional mentions sidecar), the thread's anchored text, and the containing section as markdown. Use when `composer_next_event` surfaces an event and you need history: the event only gives you the triggering message, so call this to catch up on everything said before you were tagged.",
4845
+ inputSchema: {
4846
+ type: "object",
4847
+ properties: {
4848
+ roomId: { type: "string" },
4849
+ threadId: { type: "string" }
4850
+ },
4851
+ required: ["roomId", "threadId"]
4852
+ }
4853
+ },
4560
4854
  {
4561
4855
  name: "composer_add_comment",
4562
- description: "Post a comment anchored to a text span. Anchor is { headingId, textToFind, occurrence? }. Returns { id } on success or an isError result if the anchor cannot be resolved.",
4856
+ description: "Post a new top-level comment anchored to a text span anywhere in the doc. Anchor is { headingId, textToFind, occurrence? }. Use this to flag something the user didn't ask about \u2014 cross-referencing related sections, raising a concern elsewhere in the doc, or seeding a thread on a new span. Use `composer_reply_comment` instead when continuing an existing thread. Returns { id } on success or an isError result if the anchor cannot be resolved.",
4563
4857
  inputSchema: {
4564
4858
  type: "object",
4565
4859
  properties: {
@@ -4593,7 +4887,7 @@ var TOOL_DEFS = [
4593
4887
  },
4594
4888
  {
4595
4889
  name: "composer_add_suggestion",
4596
- description: "Post a text replacement suggestion. When responding to a thread, default to passing fromThreadId \u2014 the suggestion inherits the source thread's exact stored anchor, which is the right span whenever the user's request is scoped to what they selected (the common case). Supply an `anchor` instead when the user's request explicitly targets a different span (e.g. they highlight one word but ask to rewrite the whole paragraph), or for proactive suggestions with no source thread. Pick one: supplying both is rejected. Returns { id } on success or an isError result if the anchor cannot be resolved.",
4890
+ description: "Post a text replacement suggestion. A suggestion can target ANY span anywhere in the doc \u2014 not just the span of the thread that triggered you. Pick exactly one of:\n - `fromThreadId` \u2014 inherit the source thread's exact stored anchor. Right when the user's request is scoped to what they selected (the common case: 'rewrite this', 'make this shorter').\n - `anchor` \u2014 specify a span yourself via `{ headingId, textToFind, occurrence? }`. Use this when the user's request targets different text ('also update the intro', 'the bullet list in Section 3 is stale') OR for proactive suggestions with no source thread.\n**Anchor = what gets deleted.** Your `textToFind` is literally cut when the user accepts and `replacementText` is inserted in its place. Anchor the whole unit you're changing (full sentence including terminal punctuation; full list item text; full paragraph), match your replacementText's shape (inline for mid-paragraph edits, full markdown block for block replacements), end replacement at the same boundary as the anchor, and include any formatting you want preserved in the replacement itself \u2014 the anchor's bold / link / heading level is gone on accept. A too-narrow or mid-token anchor leaves broken spacing or smashed-together words. See SKILL.md 'Pick the right span' for the full rubric.\n**Ripple coverage is your responsibility.** If the change requires edits nearby or elsewhere (enumeration counts, cross-references, subject/verb agreement, restated facts), call this tool MULTIPLE times in the same turn \u2014 one suggestion per span \u2014 so accepting leaves the doc correct. If you're unsure whether ripples exist elsewhere in the doc, call `composer_get_full_doc` first. Returns { id } on success or an isError result if the anchor cannot be resolved.",
4597
4891
  inputSchema: {
4598
4892
  type: "object",
4599
4893
  properties: {
@@ -4688,9 +4982,25 @@ function asAnchor(value) {
4688
4982
  }
4689
4983
  return { headingId, textToFind, occurrence };
4690
4984
  }
4985
+ function buildMonitorHandoff(roomId, browserUrl, actingAs, opts) {
4986
+ const mention = `@${actingAs}`;
4987
+ const step1_sayToUser = opts.isFirstRun ? `${browserUrl}
4988
+
4989
+ 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}
4990
+
4991
+ I'm monitoring this doc from the terminal \u2014 tag me with ${mention} in any comment and I'll reply right there.`;
4992
+ return {
4993
+ step1_sayToUser,
4994
+ step2_callTool: {
4995
+ tool: "composer_next_event",
4996
+ args: { roomId },
4997
+ why: "MANDATORY and STRICTLY AFTER step 1. First output `step1_sayToUser` to the user \u2014 they need the URL to open the doc \u2014 THEN call this tool. The room is attached but NOT being watched until you enter the composer_next_event loop. Two failure modes to avoid: (a) outputting 'I'm monitoring' without calling this tool = silent agent; (b) calling this tool without first outputting the URL = user has no way in."
4998
+ }
4999
+ };
5000
+ }
4691
5001
  async function handleCreateRoom(args) {
4692
5002
  const a = asObject(args);
4693
- const actingAs = asString(a.actingAs, "actingAs");
5003
+ const actingAsArg = asOptionalString(a.actingAs, "actingAs");
4694
5004
  const seedMarkdownInline = asOptionalString(a.seedMarkdown, "seedMarkdown");
4695
5005
  const seedMarkdownPath = asOptionalString(
4696
5006
  a.seedMarkdownPath,
@@ -4716,44 +5026,76 @@ async function handleCreateRoom(args) {
4716
5026
  return errorResult(`failed to read seedMarkdownPath: ${message}`);
4717
5027
  }
4718
5028
  }
5029
+ const { actingAs, isFirstRun } = await resolveActingAs(actingAsArg);
4719
5030
  const identity = await getIdentity();
4720
5031
  const roomId = nanoid3(10);
5032
+ const browserUrl = browserUrlFor(roomId);
4721
5033
  const state = new RoomState({
4722
5034
  roomId,
4723
5035
  serverHost: SERVER_HOST,
4724
5036
  actingAs,
4725
5037
  identity
4726
5038
  });
4727
- await state.waitForInitialSync();
5039
+ await state.ensureConnected();
4728
5040
  if (seedMarkdown) {
4729
5041
  writeMarkdownToFragment(state.doc.getXmlFragment("default"), seedMarkdown);
4730
5042
  }
4731
5043
  rooms.set(roomId, state);
5044
+ log(`composer room created \u2192 ${browserUrl}`, { roomId, actingAs });
5045
+ state.scheduleIdleDisconnect(3e4);
4732
5046
  return okResult({
4733
5047
  roomId,
4734
- browserUrl: browserUrlFor(roomId),
4735
- snapshot: state.snapshot()
5048
+ browserUrl,
5049
+ actingAs,
5050
+ snapshot: state.snapshot(),
5051
+ ...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
4736
5052
  });
4737
5053
  }
4738
5054
  async function handleJoinRoom(args) {
4739
5055
  const a = asObject(args);
4740
5056
  const url = asString(a.url, "url");
4741
- const actingAs = asString(a.actingAs, "actingAs");
5057
+ const actingAsArg = asOptionalString(a.actingAs, "actingAs");
4742
5058
  const roomId = parseRoomIdFromUrl(url);
4743
- const identity = await getIdentity();
5059
+ const browserUrl = browserUrlFor(roomId);
4744
5060
  const existing = rooms.get(roomId);
4745
5061
  if (existing) {
4746
- return okResult({ roomId, snapshot: existing.snapshot() });
5062
+ log(`composer room rejoined \u2192 ${browserUrl}`, {
5063
+ roomId,
5064
+ actingAs: existing.actingAs
5065
+ });
5066
+ existing.scheduleIdleDisconnect(3e4);
5067
+ return okResult({
5068
+ roomId,
5069
+ browserUrl,
5070
+ actingAs: existing.actingAs,
5071
+ snapshot: existing.snapshot(),
5072
+ // Rejoining a room already attached in this process is never a
5073
+ // first-run situation — we already wrote a name to disk at least
5074
+ // once in this process lifetime to have created `existing`.
5075
+ ...buildMonitorHandoff(roomId, browserUrl, existing.actingAs, {
5076
+ isFirstRun: false
5077
+ })
5078
+ });
4747
5079
  }
5080
+ const { actingAs, isFirstRun } = await resolveActingAs(actingAsArg);
5081
+ const identity = await getIdentity();
4748
5082
  const state = new RoomState({
4749
5083
  roomId,
4750
5084
  serverHost: SERVER_HOST,
4751
5085
  actingAs,
4752
5086
  identity
4753
5087
  });
4754
- await state.waitForInitialSync();
5088
+ await state.ensureConnected();
4755
5089
  rooms.set(roomId, state);
4756
- return okResult({ roomId, snapshot: state.snapshot() });
5090
+ log(`composer room joined \u2192 ${browserUrl}`, { roomId, actingAs });
5091
+ state.scheduleIdleDisconnect(3e4);
5092
+ return okResult({
5093
+ roomId,
5094
+ browserUrl,
5095
+ actingAs,
5096
+ snapshot: state.snapshot(),
5097
+ ...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
5098
+ });
4757
5099
  }
4758
5100
  function handleAttachRoom(args) {
4759
5101
  const a = asObject(args);
@@ -4761,13 +5103,76 @@ function handleAttachRoom(args) {
4761
5103
  const state = getOrError(roomId);
4762
5104
  return okResult({ roomId, snapshot: state.snapshot() });
4763
5105
  }
4764
- async function handleNextEvent(args) {
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
+ })();
5112
+ var LEAVE_MESSAGE = "I've left the document. You can ask me to rejoin anytime and I'll continue replying.";
5113
+ async function handleNextEvent(args, signal) {
4765
5114
  const a = asObject(args);
4766
5115
  const roomId = asString(a.roomId, "roomId");
4767
- const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 300;
5116
+ const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 30;
4768
5117
  const state = getOrError(roomId);
4769
- const event = await state.nextEvent(timeoutSec * 1e3);
4770
- return okResult(event);
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
+ }
5141
+ if (event.kind === "mention") {
5142
+ return okResult({
5143
+ ...event,
5144
+ requiredNextToolCall: {
5145
+ tool: "composer_next_event",
5146
+ args: { roomId },
5147
+ why: "MANDATORY \u2014 call this immediately after you've posted your reply / suggestion / resolve. The monitor loop is always-on; ending your turn without this call drops you out of the room and the user will think you stopped listening."
5148
+ }
5149
+ });
5150
+ }
5151
+ const msSinceActivity = Date.now() - state.lastRemoteActivityAt;
5152
+ const recentActivity = msSinceActivity < ACTIVITY_WINDOW_MS;
5153
+ const minutesSince = Math.max(1, Math.round(msSinceActivity / 6e4));
5154
+ if (recentActivity) {
5155
+ return okResult({
5156
+ kind: "timeout",
5157
+ recentActivity: true,
5158
+ secondsSinceActivity: Math.round(msSinceActivity / 1e3),
5159
+ requiredNextToolCall: {
5160
+ tool: "composer_next_event",
5161
+ args: { roomId },
5162
+ why: `MANDATORY \u2014 call this immediately. The user was active in the doc ~${minutesSince} minute${minutesSince === 1 ? "" : "s"} ago; do NOT exit the monitor loop.`
5163
+ }
5164
+ });
5165
+ }
5166
+ state.setMonitoring(false);
5167
+ state.disconnect();
5168
+ state.markGoodbyeIssued();
5169
+ return okResult({
5170
+ kind: "timeout",
5171
+ recentActivity: false,
5172
+ secondsSinceActivity: Math.round(msSinceActivity / 1e3),
5173
+ userMessage: LEAVE_MESSAGE,
5174
+ 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.`
5175
+ });
4771
5176
  }
4772
5177
  function handleGetSection(args) {
4773
5178
  const a = asObject(args);
@@ -4783,6 +5188,55 @@ function handleGetFullDoc(args) {
4783
5188
  const state = getOrError(roomId);
4784
5189
  return okResult({ markdown: serializeDocAsMarkdown(state.doc) });
4785
5190
  }
5191
+ function handleGetThread(args) {
5192
+ const a = asObject(args);
5193
+ const roomId = asString(a.roomId, "roomId");
5194
+ const threadId = asString(a.threadId, "threadId");
5195
+ const state = getOrError(roomId);
5196
+ const commentRaw = state.doc.getMap("comments").get(threadId);
5197
+ const suggestionRaw = state.doc.getMap("suggestions").get(threadId);
5198
+ const raw = commentRaw ?? suggestionRaw;
5199
+ if (!raw) {
5200
+ return errorResult(`thread not found: ${threadId}`);
5201
+ }
5202
+ const kind = commentRaw ? "comment" : "suggestion";
5203
+ const anchoredContext = resolveAnchoredContext(
5204
+ state.doc,
5205
+ raw.anchorFrom,
5206
+ raw.anchorTo
5207
+ );
5208
+ const replies = Array.isArray(raw.replies) ? raw.replies : [];
5209
+ const shapedReplies = replies.filter(
5210
+ (r) => !!r && typeof r === "object" && typeof r.id === "string" && typeof r.text === "string"
5211
+ ).map((r) => ({
5212
+ id: r.id,
5213
+ text: r.text,
5214
+ authorName: typeof r.authorName === "string" ? r.authorName : void 0,
5215
+ authorUserId: typeof r.authorUserId === "string" ? r.authorUserId : void 0,
5216
+ authorIsAgent: typeof r.authorIsAgent === "boolean" ? r.authorIsAgent : void 0,
5217
+ mentions: Array.isArray(r.mentions) ? r.mentions.filter((m) => typeof m === "string") : void 0,
5218
+ createdAt: typeof r.createdAt === "number" ? r.createdAt : void 0
5219
+ }));
5220
+ return okResult({
5221
+ threadId,
5222
+ kind,
5223
+ body: typeof raw.text === "string" ? raw.text : void 0,
5224
+ replacementText: kind === "suggestion" && typeof raw.replacementText === "string" ? raw.replacementText : void 0,
5225
+ originalText: kind === "suggestion" && typeof raw.originalText === "string" ? raw.originalText : void 0,
5226
+ authorName: typeof raw.authorName === "string" ? raw.authorName : void 0,
5227
+ authorUserId: typeof raw.authorUserId === "string" ? raw.authorUserId : void 0,
5228
+ authorIsAgent: typeof raw.authorIsAgent === "boolean" ? raw.authorIsAgent : void 0,
5229
+ createdAt: typeof raw.createdAt === "number" ? raw.createdAt : void 0,
5230
+ resolved: kind === "comment" && typeof raw.resolved === "boolean" ? raw.resolved : void 0,
5231
+ status: kind === "suggestion" && (raw.status === "pending" || raw.status === "accepted" || raw.status === "rejected") ? raw.status : void 0,
5232
+ mentions: Array.isArray(raw.mentions) ? raw.mentions.filter((m) => typeof m === "string") : void 0,
5233
+ anchoredText: anchoredContext.anchoredText,
5234
+ headingId: anchoredContext.headingId,
5235
+ headingText: anchoredContext.headingText,
5236
+ sectionMarkdown: anchoredContext.sectionMarkdown,
5237
+ replies: shapedReplies
5238
+ });
5239
+ }
4786
5240
  function handleAddComment(args) {
4787
5241
  const a = asObject(args);
4788
5242
  const roomId = asString(a.roomId, "roomId");
@@ -4944,32 +5398,44 @@ function handleResolveThread(args) {
4944
5398
  comments.set(threadId, { ...existing, resolved: true });
4945
5399
  return okResult({ threadId, resolved: true });
4946
5400
  }
4947
- async function dispatchTool(name, args) {
4948
- switch (name) {
4949
- case "composer_create_room":
4950
- return handleCreateRoom(args);
4951
- case "composer_join_room":
4952
- return handleJoinRoom(args);
4953
- case "composer_attach_room":
4954
- return handleAttachRoom(args);
4955
- case "composer_next_event":
4956
- return handleNextEvent(args);
4957
- case "composer_get_section":
4958
- return handleGetSection(args);
4959
- case "composer_get_full_doc":
4960
- return handleGetFullDoc(args);
4961
- case "composer_add_comment":
4962
- return handleAddComment(args);
4963
- case "composer_reply_comment":
4964
- return handleReplyComment(args);
4965
- case "composer_add_suggestion":
4966
- return handleAddSuggestion(args);
4967
- case "composer_reply_suggestion":
4968
- return handleReplySuggestion(args);
4969
- case "composer_resolve_thread":
4970
- return handleResolveThread(args);
4971
- default:
4972
- 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);
4973
5439
  }
4974
5440
  }
4975
5441
  function buildServer() {
@@ -4980,13 +5446,13 @@ function buildServer() {
4980
5446
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
4981
5447
  tools: TOOL_DEFS
4982
5448
  }));
4983
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
5449
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
4984
5450
  const { name, arguments: args } = req.params;
4985
5451
  const roomId = args && typeof args === "object" && "roomId" in args ? args.roomId : void 0;
4986
5452
  const started = Date.now();
4987
5453
  log(`tool ${name} call`, { roomId });
4988
5454
  try {
4989
- const result = await dispatchTool(name, args);
5455
+ const result = await dispatchTool(name, args, extra.signal);
4990
5456
  const elapsedMs = Date.now() - started;
4991
5457
  if (result.isError) {
4992
5458
  const detail = result.content[0]?.text ?? "";
@@ -5013,7 +5479,7 @@ async function startMcpServer() {
5013
5479
  log("mcp server starting", {
5014
5480
  pid: process.pid,
5015
5481
  node: process.version,
5016
- build: "awareness-heartbeat-v1"
5482
+ build: "socket-driven-v1"
5017
5483
  });
5018
5484
  const server = buildServer();
5019
5485
  const transport = new StdioServerTransport();
@@ -5025,6 +5491,28 @@ async function startMcpServer() {
5025
5491
  logFile: LOG_FILE_PATH,
5026
5492
  pid: process.pid
5027
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
+ }
5028
5516
  }
5029
5517
  async function startMcpHttpServer(opts) {
5030
5518
  installCrashHandlers();
@@ -5032,7 +5520,7 @@ async function startMcpHttpServer(opts) {
5032
5520
  port: opts.port,
5033
5521
  pid: process.pid,
5034
5522
  node: process.version,
5035
- build: "awareness-heartbeat-v1"
5523
+ build: "socket-driven-v1"
5036
5524
  });
5037
5525
  const server = buildServer();
5038
5526
  const transport = new StreamableHTTPServerTransport({
@@ -5078,18 +5566,30 @@ async function startMcpHttpServer(opts) {
5078
5566
  appBase=${APP_BASE}`
5079
5567
  );
5080
5568
  });
5081
- const shutdown = () => {
5082
- log("mcp http server shutting down");
5569
+ installShutdownHandlers("http");
5570
+ const closeServer = () => {
5083
5571
  httpServer.close(() => process.exit(0));
5084
5572
  setTimeout(() => process.exit(0), 500).unref();
5085
5573
  };
5086
- process.on("SIGTERM", shutdown);
5087
- process.on("SIGINT", shutdown);
5574
+ process.on("SIGTERM", closeServer);
5575
+ process.on("SIGINT", closeServer);
5576
+ process.on("SIGHUP", closeServer);
5088
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
+ };
5089
5585
 
5090
5586
  export {
5091
5587
  loadOrCreateIdentity,
5092
5588
  logError,
5589
+ teardownAllRooms,
5093
5590
  startMcpServer,
5094
- startMcpHttpServer
5591
+ startMcpHttpServer,
5592
+ __test_dispatch,
5593
+ __test_setRoom,
5594
+ __test_clearRooms
5095
5595
  };