@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.
- package/dist/{chunk-VVYEIOFH.js → chunk-A5KBJAJW.js} +636 -136
- package/dist/cli.js +1 -1
- package/dist/mcp.js +11 -3
- package/package.json +1 -1
- package/skill/SKILL.md +284 -28
|
@@ -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
|
-
|
|
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
|
-
|
|
3928
|
-
for (
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
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
|
|
4073
|
-
if (
|
|
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
|
|
4120
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4249
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
return
|
|
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:
|
|
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
|
|
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: {
|
|
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"
|
|
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
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
4735
|
-
|
|
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
|
|
5057
|
+
const actingAsArg = asOptionalString(a.actingAs, "actingAs");
|
|
4742
5058
|
const roomId = parseRoomIdFromUrl(url);
|
|
4743
|
-
const
|
|
5059
|
+
const browserUrl = browserUrlFor(roomId);
|
|
4744
5060
|
const existing = rooms.get(roomId);
|
|
4745
5061
|
if (existing) {
|
|
4746
|
-
|
|
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.
|
|
5088
|
+
await state.ensureConnected();
|
|
4755
5089
|
rooms.set(roomId, state);
|
|
4756
|
-
|
|
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
|
-
|
|
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 :
|
|
5116
|
+
const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 30;
|
|
4768
5117
|
const state = getOrError(roomId);
|
|
4769
|
-
|
|
4770
|
-
|
|
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
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
5082
|
-
|
|
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",
|
|
5087
|
-
process.on("SIGINT",
|
|
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
|
};
|