@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 +3 -1
- package/package.json +1 -1
- package/src/cli.js +110 -6
- package/src/gateway.js +90 -3
- package/src/shared-project.js +100 -6
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 `/
|
|
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
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
|
-
|
|
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
|
|
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 <name></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 <user-id> [owner|editor|observer]</code> add or update a shared project member",
|
|
205
|
+
"<code>/remove-member <user-id></code> remove a shared project member",
|
|
202
206
|
"<code>/mode</code> or <code>/mode <chat|plan|execute></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);
|
package/src/shared-project.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
requireSharedProject(existing);
|
|
246
334
|
const normalizedId = String(operatorId || "").trim();
|
|
247
|
-
|
|
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, {
|