@tritard/waterbrother 0.16.24 → 0.16.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -265,6 +265,7 @@ Shared project foundation is now live:
265
265
  - enable it with `waterbrother project share`
266
266
  - inspect it with `waterbrother room status`
267
267
  - control conversation vs execution with `waterbrother room mode chat|plan|execute`
268
+ - manage collaborators with `waterbrother room members`, `waterbrother room add`, and `waterbrother room remove`
268
269
  - claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
269
270
  - shared project metadata lives in `.waterbrother/shared.json`
270
271
  - human collaboration notes live in `ROUNDTABLE.md`
@@ -277,8 +278,9 @@ Current Telegram behavior:
277
278
  - pending pairings are explicit and expire automatically after 12 hours unless approved
278
279
  - paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
279
280
  - Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
280
- - shared projects now support `/room`, `/mode`, `/claim`, and `/release` from Telegram with a single active-operator lock
281
+ - shared projects now support `/room`, `/members`, `/mode`, `/claim`, `/release`, `/invite`, and `/remove-member` from Telegram
281
282
  - shared Telegram execution only runs when the shared room is in `execute` mode
283
+ - room administration is owner-only, and only owners/editors can hold the operator lock
282
284
  - in Telegram groups, Waterbrother only responds when directly targeted: slash commands, `@botname` mentions, or replies to a bot message
283
285
  - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
284
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.24",
3
+ "version": "0.16.26",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -63,9 +63,12 @@ import {
63
63
  enableSharedProject,
64
64
  formatSharedProjectStatus,
65
65
  getSharedProjectPaths,
66
+ listSharedMembers,
66
67
  loadSharedProject,
67
68
  releaseSharedOperator,
68
- setSharedRoomMode
69
+ removeSharedMember,
70
+ setSharedRoomMode,
71
+ upsertSharedMember
69
72
  } from "./shared-project.js";
70
73
 
71
74
  const execFileAsync = promisify(execFile);
@@ -148,6 +151,9 @@ const INTERACTIVE_COMMANDS = [
148
151
  { name: "/share-project", description: "Enable shared-project mode in the current cwd" },
149
152
  { name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
150
153
  { name: "/room", description: "Show shared room status for the current project" },
154
+ { name: "/room members", description: "List shared-project members" },
155
+ { name: "/room add <id> [owner|editor|observer]", description: "Add or update a shared-project member" },
156
+ { name: "/room remove <id>", description: "Remove a shared-project member" },
151
157
  { name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
152
158
  { name: "/room claim", description: "Claim operator control for the shared room" },
153
159
  { name: "/room release", description: "Release operator control for the shared room" },
@@ -272,6 +278,9 @@ Usage:
272
278
  waterbrother project share
273
279
  waterbrother project unshare
274
280
  waterbrother room status
281
+ waterbrother room members
282
+ waterbrother room add <member-id> [owner|editor|observer]
283
+ waterbrother room remove <member-id>
275
284
  waterbrother room mode <chat|plan|execute>
276
285
  waterbrother room claim
277
286
  waterbrother room release
@@ -3758,6 +3767,7 @@ async function runProjectCommand(positional, { cwd = process.cwd(), asJson = fal
3758
3767
 
3759
3768
  async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
3760
3769
  const sub = String(positional[1] || "status").trim().toLowerCase();
3770
+ const operator = getLocalOperatorIdentity();
3761
3771
  if (sub === "status") {
3762
3772
  const project = await loadSharedProject(cwd);
3763
3773
  if (asJson) {
@@ -3768,8 +3778,57 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3768
3778
  return;
3769
3779
  }
3770
3780
 
3781
+ if (sub === "members") {
3782
+ const members = await listSharedMembers(cwd);
3783
+ if (asJson) {
3784
+ printData({ ok: true, members }, true);
3785
+ return;
3786
+ }
3787
+ if (!members.length) {
3788
+ console.log("No shared-project members");
3789
+ return;
3790
+ }
3791
+ for (const member of members) {
3792
+ console.log(`${member.id}\t${member.role}\t${member.name || ""}`);
3793
+ }
3794
+ return;
3795
+ }
3796
+
3797
+ if (sub === "add") {
3798
+ const memberId = String(positional[2] || "").trim();
3799
+ const role = String(positional[3] || "editor").trim().toLowerCase();
3800
+ const name = String(positional.slice(4).join(" ") || memberId).trim();
3801
+ if (!memberId) {
3802
+ throw new Error("Usage: waterbrother room add <member-id> [owner|editor|observer] [display name]");
3803
+ }
3804
+ const project = await upsertSharedMember(
3805
+ cwd,
3806
+ { id: memberId, role, name, paired: true },
3807
+ { actorId: operator.id }
3808
+ );
3809
+ if (asJson) {
3810
+ printData({ ok: true, action: "add", memberId, role, project }, true);
3811
+ return;
3812
+ }
3813
+ console.log(`Shared-project member ${memberId} set to ${role}`);
3814
+ return;
3815
+ }
3816
+
3817
+ if (sub === "remove") {
3818
+ const memberId = String(positional[2] || "").trim();
3819
+ if (!memberId) {
3820
+ throw new Error("Usage: waterbrother room remove <member-id>");
3821
+ }
3822
+ const project = await removeSharedMember(cwd, memberId, { actorId: operator.id });
3823
+ if (asJson) {
3824
+ printData({ ok: true, action: "remove", memberId, project }, true);
3825
+ return;
3826
+ }
3827
+ console.log(`Shared-project member ${memberId} removed`);
3828
+ return;
3829
+ }
3830
+
3771
3831
  if (sub === "claim") {
3772
- const operator = getLocalOperatorIdentity();
3773
3832
  const project = await claimSharedOperator(cwd, operator);
3774
3833
  if (asJson) {
3775
3834
  printData({ ok: true, action: "claim", project }, true);
@@ -3780,8 +3839,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3780
3839
  }
3781
3840
 
3782
3841
  if (sub === "release") {
3783
- const operator = getLocalOperatorIdentity();
3784
- const project = await releaseSharedOperator(cwd, operator.id);
3842
+ const project = await releaseSharedOperator(cwd, operator.id, { actorId: operator.id });
3785
3843
  if (asJson) {
3786
3844
  printData({ ok: true, action: "release", project }, true);
3787
3845
  return;
@@ -3801,7 +3859,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3801
3859
  console.log(project?.roomMode || "shared project: off");
3802
3860
  return;
3803
3861
  }
3804
- const project = await setSharedRoomMode(cwd, nextMode);
3862
+ const project = await setSharedRoomMode(cwd, nextMode, { actorId: operator.id });
3805
3863
  if (asJson) {
3806
3864
  printData({ ok: true, action: "mode", roomMode: project.roomMode, project }, true);
3807
3865
  return;
@@ -3810,7 +3868,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3810
3868
  return;
3811
3869
  }
3812
3870
 
3813
- throw new Error("Usage: waterbrother room status|mode <chat|plan|execute>|claim|release");
3871
+ throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|mode <chat|plan|execute>|claim|release");
3814
3872
  }
3815
3873
 
3816
3874
  async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
@@ -7638,6 +7696,52 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7638
7696
  continue;
7639
7697
  }
7640
7698
 
7699
+ if (line === "/room members") {
7700
+ try {
7701
+ await runRoomCommand(["room", "members"], { cwd: context.cwd, asJson: false });
7702
+ } catch (error) {
7703
+ console.log(`room members failed: ${error instanceof Error ? error.message : String(error)}`);
7704
+ }
7705
+ continue;
7706
+ }
7707
+
7708
+ if (line.startsWith("/room add ")) {
7709
+ const raw = line.replace("/room add", "").trim();
7710
+ if (!raw) {
7711
+ console.log("Usage: /room add <member-id> [owner|editor|observer] [display name]");
7712
+ continue;
7713
+ }
7714
+ const parts = raw.split(/\s+/).filter(Boolean);
7715
+ const memberId = parts.shift() || "";
7716
+ let role = "editor";
7717
+ if (parts.length && ["owner", "editor", "observer"].includes(String(parts[0] || "").toLowerCase())) {
7718
+ role = String(parts.shift() || "editor").toLowerCase();
7719
+ }
7720
+ const displayName = parts.join(" ").trim();
7721
+ try {
7722
+ const positional = ["room", "add", memberId, role];
7723
+ if (displayName) positional.push(...displayName.split(" "));
7724
+ await runRoomCommand(positional, { cwd: context.cwd, asJson: false });
7725
+ } catch (error) {
7726
+ console.log(`room add failed: ${error instanceof Error ? error.message : String(error)}`);
7727
+ }
7728
+ continue;
7729
+ }
7730
+
7731
+ if (line.startsWith("/room remove ")) {
7732
+ const memberId = line.replace("/room remove", "").trim();
7733
+ if (!memberId) {
7734
+ console.log("Usage: /room remove <member-id>");
7735
+ continue;
7736
+ }
7737
+ try {
7738
+ await runRoomCommand(["room", "remove", memberId], { cwd: context.cwd, asJson: false });
7739
+ } catch (error) {
7740
+ console.log(`room remove failed: ${error instanceof Error ? error.message : String(error)}`);
7741
+ }
7742
+ continue;
7743
+ }
7744
+
7641
7745
  if (line.startsWith("/room mode ")) {
7642
7746
  const nextMode = line.replace("/room mode", "").trim().toLowerCase();
7643
7747
  if (!nextMode) {
package/src/gateway.js CHANGED
@@ -8,7 +8,7 @@ import { createSession, listSessions, loadSession, saveSession } from "./session
8
8
  import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayBridge, loadGatewayState, prunePendingPairings, saveGatewayBridge, saveGatewayState } from "./gateway-state.js";
9
9
  import { getGatewayStatus, getChannelSpec } from "./channels.js";
10
10
  import { canonicalizeLoosePath } from "./path-utils.js";
11
- import { claimSharedOperator, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode } from "./shared-project.js";
11
+ import { claimSharedOperator, getSharedMember, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
14
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -26,6 +26,7 @@ const TELEGRAM_COMMANDS = [
26
26
  { command: "cwd", description: "Show the current remote working directory" },
27
27
  { command: "runtime", description: "Show active runtime status" },
28
28
  { command: "room", description: "Show shared room status" },
29
+ { command: "members", description: "List shared room members" },
29
30
  { command: "mode", description: "Show or set shared room mode" },
30
31
  { command: "claim", description: "Claim operator control for a shared project" },
31
32
  { command: "release", description: "Release operator control for a shared project" },
@@ -199,6 +200,9 @@ function buildRemoteHelp() {
199
200
  "<code>/new-project &lt;name&gt;</code> create a folder on Desktop and switch into it",
200
201
  "<code>/runtime</code> show active provider/model/runtime state",
201
202
  "<code>/room</code> show shared project room status",
203
+ "<code>/members</code> list shared project members",
204
+ "<code>/invite &lt;user-id&gt; [owner|editor|observer]</code> add or update a shared project member",
205
+ "<code>/remove-member &lt;user-id&gt;</code> remove a shared project member",
202
206
  "<code>/mode</code> or <code>/mode &lt;chat|plan|execute&gt;</code> inspect or change shared room mode",
203
207
  "<code>/claim</code> claim operator control for a shared project",
204
208
  "<code>/release</code> release operator control for a shared project",
@@ -292,6 +296,30 @@ function formatTelegramRoomMarkup(project) {
292
296
  ].join("\n");
293
297
  }
294
298
 
299
+ function formatTelegramMembersMarkup(project) {
300
+ if (!project?.enabled) {
301
+ return "This project is not shared.";
302
+ }
303
+ const members = Array.isArray(project.members) ? project.members : [];
304
+ if (!members.length) {
305
+ return "<b>Shared members</b>\n• none";
306
+ }
307
+ return [
308
+ "<b>Shared members</b>",
309
+ ...members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i> <code>${escapeTelegramHtml(member.id || "")}</code>`)
310
+ ].join("\n");
311
+ }
312
+
313
+ function parseInviteCommand(text) {
314
+ const parts = String(text || "").trim().split(/\s+/).filter(Boolean);
315
+ const userId = String(parts[1] || "").trim();
316
+ let role = "editor";
317
+ if (parts[2] && ["owner", "editor", "observer"].includes(String(parts[2]).toLowerCase())) {
318
+ role = String(parts[2]).toLowerCase();
319
+ }
320
+ return { userId, role };
321
+ }
322
+
295
323
  function extractRetryDelayMs(error, attempt) {
296
324
  const retryAfter = Number(error?.retryAfterSeconds);
297
325
  if (Number.isFinite(retryAfter) && retryAfter > 0) {
@@ -916,6 +944,12 @@ class TelegramGateway {
916
944
  return;
917
945
  }
918
946
 
947
+ if (text === "/members") {
948
+ const { project } = await this.bindSharedRoomForMessage(message, sessionId);
949
+ await this.sendMessage(message.chat.id, formatTelegramMembersMarkup(project), message.message_id);
950
+ return;
951
+ }
952
+
919
953
  if (text === "/mode") {
920
954
  const { project } = await this.bindSharedRoomForMessage(message, sessionId);
921
955
  await this.sendMessage(
@@ -936,7 +970,7 @@ class TelegramGateway {
936
970
  return;
937
971
  }
938
972
  try {
939
- const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode);
973
+ const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode, { actorId: userId });
940
974
  await this.sendMessage(message.chat.id, `Shared room mode set to <code>${escapeTelegramHtml(next.roomMode)}</code>`, message.message_id);
941
975
  } catch (error) {
942
976
  await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
@@ -969,7 +1003,7 @@ class TelegramGateway {
969
1003
  return;
970
1004
  }
971
1005
  try {
972
- const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
1006
+ const released = await releaseSharedOperator(session.cwd || this.cwd, userId, { actorId: userId });
973
1007
  await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
974
1008
  } catch (error) {
975
1009
  await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
@@ -977,6 +1011,59 @@ class TelegramGateway {
977
1011
  return;
978
1012
  }
979
1013
 
1014
+ if (text.startsWith("/invite ")) {
1015
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1016
+ if (!project?.enabled) {
1017
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1018
+ return;
1019
+ }
1020
+ const { userId: nextUserId, role } = parseInviteCommand(text);
1021
+ if (!nextUserId) {
1022
+ await this.sendMessage(message.chat.id, "Usage: /invite <user-id> [owner|editor|observer]", message.message_id);
1023
+ return;
1024
+ }
1025
+ try {
1026
+ const next = await upsertSharedMember(
1027
+ session.cwd || this.cwd,
1028
+ { id: nextUserId, role, name: nextUserId, paired: true },
1029
+ { actorId: userId }
1030
+ );
1031
+ const member = getSharedMember(next, nextUserId);
1032
+ await this.sendMessage(
1033
+ message.chat.id,
1034
+ `Shared member set: <code>${escapeTelegramHtml(member?.id || nextUserId)}</code> <i>(${escapeTelegramHtml(member?.role || role)})</i>`,
1035
+ message.message_id
1036
+ );
1037
+ } catch (error) {
1038
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1039
+ }
1040
+ return;
1041
+ }
1042
+
1043
+ if (text.startsWith("/remove-member ")) {
1044
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1045
+ if (!project?.enabled) {
1046
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1047
+ return;
1048
+ }
1049
+ const nextUserId = text.replace("/remove-member", "").trim();
1050
+ if (!nextUserId) {
1051
+ await this.sendMessage(message.chat.id, "Usage: /remove-member <user-id>", message.message_id);
1052
+ return;
1053
+ }
1054
+ try {
1055
+ await removeSharedMember(session.cwd || this.cwd, nextUserId, { actorId: userId });
1056
+ await this.sendMessage(
1057
+ message.chat.id,
1058
+ `Removed shared member <code>${escapeTelegramHtml(nextUserId)}</code>`,
1059
+ message.message_id
1060
+ );
1061
+ } catch (error) {
1062
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1063
+ }
1064
+ return;
1065
+ }
1066
+
980
1067
  if (text === "/runtime") {
981
1068
  const status = await this.runRuntimeStatus();
982
1069
  await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
@@ -15,6 +15,14 @@ function normalizeMember(member = {}) {
15
15
  };
16
16
  }
17
17
 
18
+ function memberRoleWeight(role = "") {
19
+ const normalized = String(role || "").trim().toLowerCase();
20
+ if (normalized === "owner") return 3;
21
+ if (normalized === "editor") return 2;
22
+ if (normalized === "observer") return 1;
23
+ return 0;
24
+ }
25
+
18
26
  function normalizeSharedProject(project = {}, cwd = process.cwd()) {
19
27
  const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
20
28
  const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
@@ -200,9 +208,9 @@ export async function setSharedRoom(cwd, room = {}) {
200
208
  return next;
201
209
  }
202
210
 
203
- export async function setSharedRoomMode(cwd, roomMode = "chat") {
211
+ export async function setSharedRoomMode(cwd, roomMode = "chat", options = {}) {
204
212
  const existing = await loadSharedProject(cwd);
205
- if (!existing?.enabled) throw new Error("Project is not shared.");
213
+ requireOwner(existing, options.actorId);
206
214
  const normalized = String(roomMode || "").trim().toLowerCase();
207
215
  if (!["chat", "plan", "execute"].includes(normalized)) {
208
216
  throw new Error("Invalid room mode. Expected one of chat, plan, execute.");
@@ -215,15 +223,95 @@ export async function setSharedRoomMode(cwd, roomMode = "chat") {
215
223
  return next;
216
224
  }
217
225
 
226
+ export function getSharedMember(project, memberId = "") {
227
+ const normalizedId = String(memberId || "").trim();
228
+ if (!normalizedId || !project?.members?.length) return null;
229
+ return project.members.find((member) => String(member?.id || "").trim() === normalizedId) || null;
230
+ }
231
+
232
+ export function memberHasAtLeastRole(project, memberId = "", role = "editor") {
233
+ const member = getSharedMember(project, memberId);
234
+ return memberRoleWeight(member?.role) >= memberRoleWeight(role);
235
+ }
236
+
237
+ function requireSharedProject(project) {
238
+ if (!project?.enabled) {
239
+ throw new Error("Project is not shared.");
240
+ }
241
+ }
242
+
243
+ function requireOwner(project, actorId = "") {
244
+ requireSharedProject(project);
245
+ if (!memberHasAtLeastRole(project, actorId, "owner")) {
246
+ throw new Error("Only a shared-project owner can do that.");
247
+ }
248
+ }
249
+
250
+ export async function listSharedMembers(cwd) {
251
+ const project = await loadSharedProject(cwd);
252
+ requireSharedProject(project);
253
+ return project.members || [];
254
+ }
255
+
256
+ export async function upsertSharedMember(cwd, member = {}, options = {}) {
257
+ const existing = await loadSharedProject(cwd);
258
+ requireOwner(existing, options.actorId);
259
+ const nextMember = normalizeMember(member);
260
+ if (!nextMember.id) throw new Error("member id is required");
261
+ const members = Array.isArray(existing.members) ? [...existing.members] : [];
262
+ const index = members.findIndex((item) => item.id === nextMember.id);
263
+ if (index >= 0) {
264
+ members[index] = { ...members[index], ...nextMember };
265
+ } else {
266
+ members.push(nextMember);
267
+ }
268
+ const next = await saveSharedProject(cwd, {
269
+ ...existing,
270
+ members
271
+ });
272
+ await appendRoundtableEvent(
273
+ cwd,
274
+ `- ${new Date().toISOString()}: member ${nextMember.name || nextMember.id} set to role ${nextMember.role}`
275
+ );
276
+ return next;
277
+ }
278
+
279
+ export async function removeSharedMember(cwd, memberId = "", options = {}) {
280
+ const existing = await loadSharedProject(cwd);
281
+ requireOwner(existing, options.actorId);
282
+ const normalizedId = String(memberId || "").trim();
283
+ if (!normalizedId) throw new Error("member id is required");
284
+ const current = getSharedMember(existing, normalizedId);
285
+ if (!current) throw new Error(`No shared-project member found for ${normalizedId}`);
286
+ const owners = (existing.members || []).filter((member) => member.role === "owner");
287
+ if (current.role === "owner" && owners.length <= 1) {
288
+ throw new Error("Cannot remove the last owner from a shared project.");
289
+ }
290
+ const next = await saveSharedProject(cwd, {
291
+ ...existing,
292
+ members: (existing.members || []).filter((member) => member.id !== normalizedId),
293
+ activeOperator: existing.activeOperator?.id === normalizedId ? null : existing.activeOperator
294
+ });
295
+ await appendRoundtableEvent(
296
+ cwd,
297
+ `- ${new Date().toISOString()}: member removed ${current.name || current.id}`
298
+ );
299
+ return next;
300
+ }
301
+
218
302
  export async function claimSharedOperator(cwd, operator = {}) {
219
303
  const existing = await loadSharedProject(cwd);
220
- if (!existing?.enabled) throw new Error("Project is not shared.");
304
+ requireSharedProject(existing);
221
305
  const nextOperator = {
222
306
  id: String(operator.id || "").trim(),
223
307
  name: String(operator.name || "").trim(),
224
308
  claimedAt: new Date().toISOString()
225
309
  };
226
310
  if (!nextOperator.id) throw new Error("operator id is required");
311
+ const existingMember = getSharedMember(existing, nextOperator.id);
312
+ if (existingMember && !memberHasAtLeastRole(existing, nextOperator.id, "editor")) {
313
+ throw new Error("Observers cannot claim operator control.");
314
+ }
227
315
  if (existing.activeOperator?.id && existing.activeOperator.id !== nextOperator.id) {
228
316
  throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
229
317
  }
@@ -240,11 +328,17 @@ export async function claimSharedOperator(cwd, operator = {}) {
240
328
  return next;
241
329
  }
242
330
 
243
- export async function releaseSharedOperator(cwd, operatorId = "") {
331
+ export async function releaseSharedOperator(cwd, operatorId = "", options = {}) {
244
332
  const existing = await loadSharedProject(cwd);
245
- if (!existing?.enabled) throw new Error("Project is not shared.");
333
+ requireSharedProject(existing);
246
334
  const normalizedId = String(operatorId || "").trim();
247
- if (existing.activeOperator?.id && normalizedId && existing.activeOperator.id !== normalizedId) {
335
+ const actorId = String(options.actorId || "").trim();
336
+ if (
337
+ existing.activeOperator?.id &&
338
+ normalizedId &&
339
+ existing.activeOperator.id !== normalizedId &&
340
+ !memberHasAtLeastRole(existing, actorId, "owner")
341
+ ) {
248
342
  throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
249
343
  }
250
344
  const next = await saveSharedProject(cwd, {