@tritard/waterbrother 0.16.23 → 0.16.25

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,10 @@ 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
284
+ - in Telegram groups, Waterbrother only responds when directly targeted: slash commands, `@botname` mentions, or replies to a bot message
282
285
  - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
283
286
 
284
287
  ## Release flow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.23",
3
+ "version": "0.16.25",
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) {
@@ -319,6 +347,7 @@ class TelegramGateway {
319
347
  this.channel = runtime.channels?.telegram || {};
320
348
  this.gateway = runtime.gateway || {};
321
349
  this.state = null;
350
+ this.me = null;
322
351
  }
323
352
 
324
353
  get botToken() {
@@ -428,6 +457,40 @@ class TelegramGateway {
428
457
  return allowed.includes(userId);
429
458
  }
430
459
 
460
+ isGroupChat(message) {
461
+ const type = String(message?.chat?.type || "").trim().toLowerCase();
462
+ return type === "group" || type === "supergroup";
463
+ }
464
+
465
+ normalizeTelegramCommandText(text) {
466
+ const value = String(text || "").trim();
467
+ const username = String(this.me?.username || "").trim();
468
+ if (!value || !username) return value;
469
+ return value.replace(new RegExp(`^(/\\w+)@${username}\\b`, "i"), "$1");
470
+ }
471
+
472
+ messageTargetsBot(message) {
473
+ if (!message?.chat) return false;
474
+ if (String(message.chat.type || "").trim() === "private") return true;
475
+ const username = String(this.me?.username || "").trim().toLowerCase();
476
+ const text = String(message?.text || "").trim();
477
+ if (!text) return false;
478
+ const normalized = this.normalizeTelegramCommandText(text);
479
+ if (normalized.startsWith("/")) return true;
480
+ if (username && new RegExp(`(^|\\s)@${username}(\\b|\\s|$)`, "i").test(text)) return true;
481
+ const replyFromId = String(message?.reply_to_message?.from?.id || "").trim();
482
+ const myId = String(this.me?.id || "").trim();
483
+ if (replyFromId && myId && replyFromId === myId) return true;
484
+ return false;
485
+ }
486
+
487
+ stripBotMention(text) {
488
+ const value = String(text || "").trim();
489
+ const username = String(this.me?.username || "").trim();
490
+ if (!value || !username) return value;
491
+ return value.replace(new RegExp(`(^|\\s)@${username}(\\b|\\s|$)`, "ig"), " ").replace(/\s+/g, " ").trim();
492
+ }
493
+
431
494
  prunePairings() {
432
495
  const { state, pruned } = prunePendingPairings(this.state, this.pairingExpiryMinutes);
433
496
  this.state = state;
@@ -831,15 +894,16 @@ class TelegramGateway {
831
894
  }
832
895
 
833
896
  async handleTextMessage(message) {
834
- if (!message?.chat || message.chat.type !== "private") return;
897
+ if (!message?.chat) return;
835
898
  if (!message?.text) return;
899
+ if (!this.messageTargetsBot(message)) return;
836
900
  this.prunePairings();
837
901
  if (!this.isAuthorizedUser(message.from)) {
838
902
  await this.rejectUnpairedMessage(message);
839
903
  return;
840
904
  }
841
905
 
842
- const text = String(message.text || "").trim();
906
+ const text = this.normalizeTelegramCommandText(String(message.text || "").trim());
843
907
  const userId = String(message.from.id);
844
908
 
845
909
  if (text === "/help" || text === "/start") {
@@ -880,6 +944,12 @@ class TelegramGateway {
880
944
  return;
881
945
  }
882
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
+
883
953
  if (text === "/mode") {
884
954
  const { project } = await this.bindSharedRoomForMessage(message, sessionId);
885
955
  await this.sendMessage(
@@ -900,7 +970,7 @@ class TelegramGateway {
900
970
  return;
901
971
  }
902
972
  try {
903
- const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode);
973
+ const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode, { actorId: userId });
904
974
  await this.sendMessage(message.chat.id, `Shared room mode set to <code>${escapeTelegramHtml(next.roomMode)}</code>`, message.message_id);
905
975
  } catch (error) {
906
976
  await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
@@ -933,7 +1003,7 @@ class TelegramGateway {
933
1003
  return;
934
1004
  }
935
1005
  try {
936
- const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
1006
+ const released = await releaseSharedOperator(session.cwd || this.cwd, userId, { actorId: userId });
937
1007
  await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
938
1008
  } catch (error) {
939
1009
  await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
@@ -941,6 +1011,59 @@ class TelegramGateway {
941
1011
  return;
942
1012
  }
943
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
+
944
1067
  if (text === "/runtime") {
945
1068
  const status = await this.runRuntimeStatus();
946
1069
  await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
@@ -1057,8 +1180,9 @@ class TelegramGateway {
1057
1180
  return;
1058
1181
  }
1059
1182
  previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
1060
- const content = (await this.runPromptViaBridge(message, sessionId, text))
1061
- ?? (await this.runPromptFallback(sessionId, text));
1183
+ const promptText = this.stripBotMention(text);
1184
+ const content = (await this.runPromptViaBridge(message, sessionId, promptText))
1185
+ ?? (await this.runPromptFallback(sessionId, promptText));
1062
1186
  await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
1063
1187
  } catch (error) {
1064
1188
  await this.deliverPromptFailure(message.chat.id, message.message_id, previewMessage, error);
@@ -1084,6 +1208,7 @@ class TelegramGateway {
1084
1208
  this.state = await loadGatewayState("telegram");
1085
1209
  this.prunePairings();
1086
1210
  const me = await this.getMe();
1211
+ this.me = me;
1087
1212
  await this.registerCommands();
1088
1213
  await this.persistState();
1089
1214
  this.io.log?.(`Telegram gateway connected as @${me.username || me.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, {