@tritard/waterbrother 0.16.21 → 0.16.23
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 +10 -0
- package/package.json +1 -1
- package/src/cli.js +185 -0
- package/src/gateway.js +153 -0
- package/src/shared-project.js +279 -0
package/README.md
CHANGED
|
@@ -261,6 +261,14 @@ Rollout order:
|
|
|
261
261
|
3. approvals over messaging
|
|
262
262
|
4. only then group DM collaboration
|
|
263
263
|
|
|
264
|
+
Shared project foundation is now live:
|
|
265
|
+
- enable it with `waterbrother project share`
|
|
266
|
+
- inspect it with `waterbrother room status`
|
|
267
|
+
- control conversation vs execution with `waterbrother room mode chat|plan|execute`
|
|
268
|
+
- claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
|
|
269
|
+
- shared project metadata lives in `.waterbrother/shared.json`
|
|
270
|
+
- human collaboration notes live in `ROUNDTABLE.md`
|
|
271
|
+
|
|
264
272
|
Current Telegram behavior:
|
|
265
273
|
- if the TUI is open, Telegram prompts are injected into the live TUI session and the work is visible in the terminal
|
|
266
274
|
- if no live TUI session is attached, Telegram falls back to a remote run with `approval=never`
|
|
@@ -269,6 +277,8 @@ Current Telegram behavior:
|
|
|
269
277
|
- pending pairings are explicit and expire automatically after 12 hours unless approved
|
|
270
278
|
- paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
|
|
271
279
|
- 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 Telegram execution only runs when the shared room is in `execute` mode
|
|
272
282
|
- pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
|
|
273
283
|
|
|
274
284
|
## Release flow
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -57,6 +57,16 @@ import { runQualityChecks, formatQualityFindings, buildQualityFixPrompt } from "
|
|
|
57
57
|
import { scanForInitiatives, formatInitiatives, buildInitiativeFixPrompt } from "./initiative.js";
|
|
58
58
|
import { formatPlanForDisplay } from "./planner.js";
|
|
59
59
|
import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
|
|
60
|
+
import {
|
|
61
|
+
claimSharedOperator,
|
|
62
|
+
disableSharedProject,
|
|
63
|
+
enableSharedProject,
|
|
64
|
+
formatSharedProjectStatus,
|
|
65
|
+
getSharedProjectPaths,
|
|
66
|
+
loadSharedProject,
|
|
67
|
+
releaseSharedOperator,
|
|
68
|
+
setSharedRoomMode
|
|
69
|
+
} from "./shared-project.js";
|
|
60
70
|
|
|
61
71
|
const execFileAsync = promisify(execFile);
|
|
62
72
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -135,6 +145,12 @@ const INTERACTIVE_COMMANDS = [
|
|
|
135
145
|
{ name: "/gateway unpair <user-id>", description: "Remove a paired Telegram user id" },
|
|
136
146
|
{ name: "/gateway stop", description: "Stop the tracked Telegram gateway process" },
|
|
137
147
|
{ name: "/channels", description: "Show messaging channel readiness" },
|
|
148
|
+
{ name: "/share-project", description: "Enable shared-project mode in the current cwd" },
|
|
149
|
+
{ name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
|
|
150
|
+
{ name: "/room", description: "Show shared room status for the current project" },
|
|
151
|
+
{ name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
|
|
152
|
+
{ name: "/room claim", description: "Claim operator control for the shared room" },
|
|
153
|
+
{ name: "/room release", description: "Release operator control for the shared room" },
|
|
138
154
|
{ name: "/cwd", description: "Show current working directory" },
|
|
139
155
|
{ name: "/use <path>", description: "Switch the live session to a different working directory" },
|
|
140
156
|
{ name: "/desktop", description: "Switch the live session to ~/Desktop" },
|
|
@@ -253,6 +269,12 @@ Usage:
|
|
|
253
269
|
waterbrother gateway pairings [telegram]
|
|
254
270
|
waterbrother gateway pair [telegram] <user-id>
|
|
255
271
|
waterbrother gateway unpair [telegram] <user-id>
|
|
272
|
+
waterbrother project share
|
|
273
|
+
waterbrother project unshare
|
|
274
|
+
waterbrother room status
|
|
275
|
+
waterbrother room mode <chat|plan|execute>
|
|
276
|
+
waterbrother room claim
|
|
277
|
+
waterbrother room release
|
|
256
278
|
waterbrother mcp list
|
|
257
279
|
waterbrother commit [--push]
|
|
258
280
|
waterbrother pr [--branch=<name>]
|
|
@@ -3697,6 +3719,100 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3697
3719
|
throw new Error("Usage: waterbrother gateway status|run <telegram>|stop [telegram]|pairings [telegram]|pair [telegram] <user-id>|unpair [telegram] <user-id>");
|
|
3698
3720
|
}
|
|
3699
3721
|
|
|
3722
|
+
function getLocalOperatorIdentity() {
|
|
3723
|
+
const userName = String(process.env.USER || process.env.USERNAME || "local").trim() || "local";
|
|
3724
|
+
return {
|
|
3725
|
+
id: `local:${userName}`,
|
|
3726
|
+
name: userName
|
|
3727
|
+
};
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
async function runProjectCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
|
|
3731
|
+
const sub = String(positional[1] || "").trim().toLowerCase();
|
|
3732
|
+
if (sub === "share") {
|
|
3733
|
+
const operator = getLocalOperatorIdentity();
|
|
3734
|
+
const project = await enableSharedProject(cwd, { userId: operator.id, userName: operator.name, role: "owner" });
|
|
3735
|
+
const paths = getSharedProjectPaths(cwd);
|
|
3736
|
+
if (asJson) {
|
|
3737
|
+
printData({ ok: true, action: "share", project, paths }, true);
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
console.log(`Shared project enabled in ${cwd}`);
|
|
3741
|
+
console.log(`shared metadata: ${paths.sharedJson}`);
|
|
3742
|
+
console.log(`roundtable: ${paths.roundtable}`);
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
if (sub === "unshare") {
|
|
3747
|
+
const project = await disableSharedProject(cwd);
|
|
3748
|
+
if (asJson) {
|
|
3749
|
+
printData({ ok: true, action: "unshare", project }, true);
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
console.log(project ? `Shared project disabled in ${cwd}` : "Project was not shared");
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
throw new Error("Usage: waterbrother project share|unshare");
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
|
|
3760
|
+
const sub = String(positional[1] || "status").trim().toLowerCase();
|
|
3761
|
+
if (sub === "status") {
|
|
3762
|
+
const project = await loadSharedProject(cwd);
|
|
3763
|
+
if (asJson) {
|
|
3764
|
+
printData(project || { enabled: false, cwd }, true);
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
console.log(formatSharedProjectStatus(project));
|
|
3768
|
+
return;
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
if (sub === "claim") {
|
|
3772
|
+
const operator = getLocalOperatorIdentity();
|
|
3773
|
+
const project = await claimSharedOperator(cwd, operator);
|
|
3774
|
+
if (asJson) {
|
|
3775
|
+
printData({ ok: true, action: "claim", project }, true);
|
|
3776
|
+
return;
|
|
3777
|
+
}
|
|
3778
|
+
console.log(`Room claimed by ${operator.name}`);
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
if (sub === "release") {
|
|
3783
|
+
const operator = getLocalOperatorIdentity();
|
|
3784
|
+
const project = await releaseSharedOperator(cwd, operator.id);
|
|
3785
|
+
if (asJson) {
|
|
3786
|
+
printData({ ok: true, action: "release", project }, true);
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
console.log(`Room released by ${operator.name}`);
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
if (sub === "mode") {
|
|
3794
|
+
const nextMode = String(positional[2] || "").trim().toLowerCase();
|
|
3795
|
+
if (!nextMode) {
|
|
3796
|
+
const project = await loadSharedProject(cwd);
|
|
3797
|
+
if (asJson) {
|
|
3798
|
+
printData({ ok: true, roomMode: project?.roomMode || null, project }, true);
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
console.log(project?.roomMode || "shared project: off");
|
|
3802
|
+
return;
|
|
3803
|
+
}
|
|
3804
|
+
const project = await setSharedRoomMode(cwd, nextMode);
|
|
3805
|
+
if (asJson) {
|
|
3806
|
+
printData({ ok: true, action: "mode", roomMode: project.roomMode, project }, true);
|
|
3807
|
+
return;
|
|
3808
|
+
}
|
|
3809
|
+
console.log(`Room mode set to ${project.roomMode}`);
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
throw new Error("Usage: waterbrother room status|mode <chat|plan|execute>|claim|release");
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3700
3816
|
async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
|
|
3701
3817
|
const sub = positional[1] || "list";
|
|
3702
3818
|
if (sub !== "list") {
|
|
@@ -7495,6 +7611,65 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
7495
7611
|
continue;
|
|
7496
7612
|
}
|
|
7497
7613
|
|
|
7614
|
+
if (line === "/share-project") {
|
|
7615
|
+
try {
|
|
7616
|
+
await runProjectCommand(["project", "share"], { cwd: context.cwd, asJson: false });
|
|
7617
|
+
} catch (error) {
|
|
7618
|
+
console.log(`share-project failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7619
|
+
}
|
|
7620
|
+
continue;
|
|
7621
|
+
}
|
|
7622
|
+
|
|
7623
|
+
if (line === "/unshare-project") {
|
|
7624
|
+
try {
|
|
7625
|
+
await runProjectCommand(["project", "unshare"], { cwd: context.cwd, asJson: false });
|
|
7626
|
+
} catch (error) {
|
|
7627
|
+
console.log(`unshare-project failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7628
|
+
}
|
|
7629
|
+
continue;
|
|
7630
|
+
}
|
|
7631
|
+
|
|
7632
|
+
if (line === "/room") {
|
|
7633
|
+
try {
|
|
7634
|
+
await runRoomCommand(["room", "status"], { cwd: context.cwd, asJson: false });
|
|
7635
|
+
} catch (error) {
|
|
7636
|
+
console.log(`room status failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7637
|
+
}
|
|
7638
|
+
continue;
|
|
7639
|
+
}
|
|
7640
|
+
|
|
7641
|
+
if (line.startsWith("/room mode ")) {
|
|
7642
|
+
const nextMode = line.replace("/room mode", "").trim().toLowerCase();
|
|
7643
|
+
if (!nextMode) {
|
|
7644
|
+
console.log("Usage: /room mode <chat|plan|execute>");
|
|
7645
|
+
continue;
|
|
7646
|
+
}
|
|
7647
|
+
try {
|
|
7648
|
+
await runRoomCommand(["room", "mode", nextMode], { cwd: context.cwd, asJson: false });
|
|
7649
|
+
} catch (error) {
|
|
7650
|
+
console.log(`room mode failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7651
|
+
}
|
|
7652
|
+
continue;
|
|
7653
|
+
}
|
|
7654
|
+
|
|
7655
|
+
if (line === "/room claim") {
|
|
7656
|
+
try {
|
|
7657
|
+
await runRoomCommand(["room", "claim"], { cwd: context.cwd, asJson: false });
|
|
7658
|
+
} catch (error) {
|
|
7659
|
+
console.log(`room claim failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7660
|
+
}
|
|
7661
|
+
continue;
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
if (line === "/room release") {
|
|
7665
|
+
try {
|
|
7666
|
+
await runRoomCommand(["room", "release"], { cwd: context.cwd, asJson: false });
|
|
7667
|
+
} catch (error) {
|
|
7668
|
+
console.log(`room release failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7669
|
+
}
|
|
7670
|
+
continue;
|
|
7671
|
+
}
|
|
7672
|
+
|
|
7498
7673
|
if (line === "/gateway pairings") {
|
|
7499
7674
|
const ttlMinutes = Number.isFinite(Number(context.runtime.channels?.telegram?.pairingExpiryMinutes))
|
|
7500
7675
|
? Math.max(1, Math.floor(Number(context.runtime.channels.telegram.pairingExpiryMinutes)))
|
|
@@ -9311,6 +9486,16 @@ export async function runCli(argv) {
|
|
|
9311
9486
|
return;
|
|
9312
9487
|
}
|
|
9313
9488
|
|
|
9489
|
+
if (command === "project") {
|
|
9490
|
+
await runProjectCommand(positional, { cwd: startupCwd, asJson });
|
|
9491
|
+
return;
|
|
9492
|
+
}
|
|
9493
|
+
|
|
9494
|
+
if (command === "room") {
|
|
9495
|
+
await runRoomCommand(positional, { cwd: startupCwd, asJson });
|
|
9496
|
+
return;
|
|
9497
|
+
}
|
|
9498
|
+
|
|
9314
9499
|
if (command === "gateway") {
|
|
9315
9500
|
await runGatewayCommand(positional, runtime, { cwd: startupCwd, asJson });
|
|
9316
9501
|
return;
|
package/src/gateway.js
CHANGED
|
@@ -8,6 +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
12
|
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -24,6 +25,10 @@ const TELEGRAM_COMMANDS = [
|
|
|
24
25
|
{ command: "status", description: "Show the linked remote session" },
|
|
25
26
|
{ command: "cwd", description: "Show the current remote working directory" },
|
|
26
27
|
{ command: "runtime", description: "Show active runtime status" },
|
|
28
|
+
{ command: "room", description: "Show shared room status" },
|
|
29
|
+
{ command: "mode", description: "Show or set shared room mode" },
|
|
30
|
+
{ command: "claim", description: "Claim operator control for a shared project" },
|
|
31
|
+
{ command: "release", description: "Release operator control for a shared project" },
|
|
27
32
|
{ command: "sessions", description: "List recent remote sessions" },
|
|
28
33
|
{ command: "new", description: "Start a fresh remote session" },
|
|
29
34
|
{ command: "clear", description: "Clear current remote conversation" }
|
|
@@ -193,6 +198,10 @@ function buildRemoteHelp() {
|
|
|
193
198
|
"<code>/desktop</code> switch the linked session to <code>~/Desktop</code>",
|
|
194
199
|
"<code>/new-project <name></code> create a folder on Desktop and switch into it",
|
|
195
200
|
"<code>/runtime</code> show active provider/model/runtime state",
|
|
201
|
+
"<code>/room</code> show shared project room status",
|
|
202
|
+
"<code>/mode</code> or <code>/mode <chat|plan|execute></code> inspect or change shared room mode",
|
|
203
|
+
"<code>/claim</code> claim operator control for a shared project",
|
|
204
|
+
"<code>/release</code> release operator control for a shared project",
|
|
196
205
|
"<code>/sessions</code> list recent linked remote sessions",
|
|
197
206
|
"<code>/resume <session-id></code> switch the linked remote session",
|
|
198
207
|
"<code>/new</code> start a fresh remote session",
|
|
@@ -257,6 +266,32 @@ function formatSessionListMarkup(currentSessionId, sessions = []) {
|
|
|
257
266
|
return lines.join("\n");
|
|
258
267
|
}
|
|
259
268
|
|
|
269
|
+
function formatTelegramRoomMarkup(project) {
|
|
270
|
+
if (!project?.enabled) {
|
|
271
|
+
return "<b>Shared room</b>\nThis project is not shared.";
|
|
272
|
+
}
|
|
273
|
+
const members = Array.isArray(project.members) ? project.members : [];
|
|
274
|
+
const active = project.activeOperator?.id
|
|
275
|
+
? `${project.activeOperator.name || project.activeOperator.id} (${project.activeOperator.id})`
|
|
276
|
+
: "none";
|
|
277
|
+
const roomLabel = project.room?.chatId
|
|
278
|
+
? `${project.room.provider || "telegram"} ${project.room.chatId}${project.room.title ? ` (${project.room.title})` : ""}`
|
|
279
|
+
: "not linked";
|
|
280
|
+
const memberLines = members.length
|
|
281
|
+
? members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i>`).join("\n")
|
|
282
|
+
: "• none";
|
|
283
|
+
return [
|
|
284
|
+
"<b>Shared room</b>",
|
|
285
|
+
`project: <code>${escapeTelegramHtml(project.projectName || "")}</code>`,
|
|
286
|
+
`mode: <code>${escapeTelegramHtml(project.mode || "single-operator")}</code>`,
|
|
287
|
+
`room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
|
|
288
|
+
`room: <code>${escapeTelegramHtml(roomLabel)}</code>`,
|
|
289
|
+
`active operator: <code>${escapeTelegramHtml(active)}</code>`,
|
|
290
|
+
"<b>Members</b>",
|
|
291
|
+
memberLines
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
function extractRetryDelayMs(error, attempt) {
|
|
261
296
|
const retryAfter = Number(error?.retryAfterSeconds);
|
|
262
297
|
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
@@ -565,6 +600,52 @@ class TelegramGateway {
|
|
|
565
600
|
return session.cwd;
|
|
566
601
|
}
|
|
567
602
|
|
|
603
|
+
async loadSharedProjectForSession(sessionId) {
|
|
604
|
+
const session = await loadSession(sessionId);
|
|
605
|
+
const project = await loadSharedProject(session.cwd || this.cwd);
|
|
606
|
+
return { session, project };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async bindSharedRoomForMessage(message, sessionId) {
|
|
610
|
+
const { session, project } = await this.loadSharedProjectForSession(sessionId);
|
|
611
|
+
if (!project?.enabled) {
|
|
612
|
+
return { session, project };
|
|
613
|
+
}
|
|
614
|
+
const next = await setSharedRoom(session.cwd || this.cwd, {
|
|
615
|
+
provider: "telegram",
|
|
616
|
+
chatId: String(message.chat.id),
|
|
617
|
+
title: String(message.chat.title || "").trim()
|
|
618
|
+
});
|
|
619
|
+
return { session, project: next };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async ensureSharedOperator(message, sessionId) {
|
|
623
|
+
const { session, project } = await this.loadSharedProjectForSession(sessionId);
|
|
624
|
+
if (!project?.enabled) return { ok: true, project: null, session };
|
|
625
|
+
const bound = await this.bindSharedRoomForMessage(message, sessionId);
|
|
626
|
+
if (bound.project?.roomMode !== "execute") {
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
project: bound.project,
|
|
630
|
+
session: bound.session,
|
|
631
|
+
reason: `Shared room is in ${bound.project?.roomMode || "chat"} mode. Switch to /mode execute before running code.`
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const userId = String(message?.from?.id || "").trim();
|
|
635
|
+
if (!bound.project?.activeOperator?.id) {
|
|
636
|
+
return { ok: false, project: bound.project, session: bound.session, reason: "No active operator. Use /claim first." };
|
|
637
|
+
}
|
|
638
|
+
if (bound.project.activeOperator.id !== userId) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
project: bound.project,
|
|
642
|
+
session: bound.session,
|
|
643
|
+
reason: `Room is currently claimed by ${bound.project.activeOperator.name || bound.project.activeOperator.id}. Use /claim after they release it.`
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return { ok: true, project: bound.project, session: bound.session };
|
|
647
|
+
}
|
|
648
|
+
|
|
568
649
|
async getLiveBridgeHost() {
|
|
569
650
|
const bridge = await loadGatewayBridge("telegram");
|
|
570
651
|
const host = bridge.activeHost || {};
|
|
@@ -793,6 +874,73 @@ class TelegramGateway {
|
|
|
793
874
|
return;
|
|
794
875
|
}
|
|
795
876
|
|
|
877
|
+
if (text === "/room") {
|
|
878
|
+
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
879
|
+
await this.sendMessage(message.chat.id, project?.enabled ? formatTelegramRoomMarkup(project) : "<b>Shared room</b>\nThis project is not shared.", message.message_id);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (text === "/mode") {
|
|
884
|
+
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
885
|
+
await this.sendMessage(
|
|
886
|
+
message.chat.id,
|
|
887
|
+
project?.enabled
|
|
888
|
+
? `<b>Shared room mode</b>\n<code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`
|
|
889
|
+
: "This project is not shared.",
|
|
890
|
+
message.message_id
|
|
891
|
+
);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (text.startsWith("/mode ")) {
|
|
896
|
+
const requestedMode = text.replace("/mode", "").trim().toLowerCase();
|
|
897
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
898
|
+
if (!project?.enabled) {
|
|
899
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode);
|
|
904
|
+
await this.sendMessage(message.chat.id, `Shared room mode set to <code>${escapeTelegramHtml(next.roomMode)}</code>`, message.message_id);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
907
|
+
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (text === "/claim") {
|
|
912
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
913
|
+
if (!project?.enabled) {
|
|
914
|
+
await this.sendMessage(message.chat.id, "This project is not shared. Enable sharing from the terminal first with <code>waterbrother project share</code>.", message.message_id);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const claimed = await claimSharedOperator(session.cwd || this.cwd, {
|
|
919
|
+
id: userId,
|
|
920
|
+
name: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId
|
|
921
|
+
});
|
|
922
|
+
await this.sendMessage(message.chat.id, `Shared room claimed by <code>${escapeTelegramHtml(claimed.activeOperator?.name || claimed.activeOperator?.id || userId)}</code>`, message.message_id);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (text === "/release") {
|
|
930
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
931
|
+
if (!project?.enabled) {
|
|
932
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
|
|
937
|
+
await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
940
|
+
}
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
796
944
|
if (text === "/runtime") {
|
|
797
945
|
const status = await this.runRuntimeStatus();
|
|
798
946
|
await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
|
|
@@ -903,6 +1051,11 @@ class TelegramGateway {
|
|
|
903
1051
|
const stopTyping = await this.startTypingLoop(message.chat.id);
|
|
904
1052
|
let previewMessage = null;
|
|
905
1053
|
try {
|
|
1054
|
+
const operatorGate = await this.ensureSharedOperator(message, sessionId);
|
|
1055
|
+
if (!operatorGate.ok) {
|
|
1056
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(operatorGate.reason || "Shared room is not available."), message.message_id, { parseMode: "HTML" });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
906
1059
|
previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
|
|
907
1060
|
const content = (await this.runPromptViaBridge(message, sessionId, text))
|
|
908
1061
|
?? (await this.runPromptFallback(sessionId, text));
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const SHARED_FILE = path.join(".waterbrother", "shared.json");
|
|
7
|
+
const ROUNDTABLE_FILE = "ROUNDTABLE.md";
|
|
8
|
+
|
|
9
|
+
function normalizeMember(member = {}) {
|
|
10
|
+
return {
|
|
11
|
+
id: String(member.id || "").trim(),
|
|
12
|
+
name: String(member.name || "").trim(),
|
|
13
|
+
role: ["owner", "editor", "observer"].includes(String(member.role || "").trim()) ? String(member.role).trim() : "editor",
|
|
14
|
+
paired: member.paired !== false
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeSharedProject(project = {}, cwd = process.cwd()) {
|
|
19
|
+
const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
|
|
20
|
+
const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
|
|
21
|
+
? {
|
|
22
|
+
id: String(project.activeOperator.id || "").trim(),
|
|
23
|
+
name: String(project.activeOperator.name || "").trim(),
|
|
24
|
+
claimedAt: String(project.activeOperator.claimedAt || "").trim()
|
|
25
|
+
}
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
enabled: project.enabled !== false,
|
|
31
|
+
projectId: String(project.projectId || `proj_${crypto.randomBytes(4).toString("hex")}`).trim(),
|
|
32
|
+
projectName: String(project.projectName || path.basename(cwd || process.cwd()) || "project").trim(),
|
|
33
|
+
cwd: String(cwd || project.cwd || process.cwd()).trim(),
|
|
34
|
+
room: {
|
|
35
|
+
provider: String(project.room?.provider || "").trim(),
|
|
36
|
+
chatId: String(project.room?.chatId || "").trim(),
|
|
37
|
+
title: String(project.room?.title || "").trim()
|
|
38
|
+
},
|
|
39
|
+
mode: String(project.mode || "single-operator").trim() || "single-operator",
|
|
40
|
+
roomMode: ["chat", "plan", "execute"].includes(String(project.roomMode || "").trim())
|
|
41
|
+
? String(project.roomMode).trim()
|
|
42
|
+
: "chat",
|
|
43
|
+
members,
|
|
44
|
+
activeOperator: activeOperator?.id ? activeOperator : null,
|
|
45
|
+
approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
|
|
46
|
+
createdAt: String(project.createdAt || new Date().toISOString()).trim(),
|
|
47
|
+
updatedAt: String(project.updatedAt || new Date().toISOString()).trim()
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sharedFilePath(cwd) {
|
|
52
|
+
return path.join(cwd, SHARED_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function roundtablePath(cwd) {
|
|
56
|
+
return path.join(cwd, ROUNDTABLE_FILE);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ensureProjectStateDir(cwd) {
|
|
60
|
+
await fs.mkdir(path.join(cwd, ".waterbrother"), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pathExists(target) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.access(target);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeJsonAtomically(targetPath, value) {
|
|
73
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
74
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
75
|
+
await fs.rename(tempPath, targetPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defaultRoundtableContent(project) {
|
|
79
|
+
const memberLines = project.members.length
|
|
80
|
+
? project.members.map((member) => `- ${member.name || member.id} (${member.role})`).join("\n")
|
|
81
|
+
: "- none yet";
|
|
82
|
+
const activeOperator = project.activeOperator?.id
|
|
83
|
+
? `${project.activeOperator.name || project.activeOperator.id}`
|
|
84
|
+
: "none";
|
|
85
|
+
return [
|
|
86
|
+
"# Roundtable",
|
|
87
|
+
"",
|
|
88
|
+
"## Project",
|
|
89
|
+
`- Name: ${project.projectName}`,
|
|
90
|
+
`- Mode: ${project.mode}`,
|
|
91
|
+
`- Room mode: ${project.roomMode}`,
|
|
92
|
+
`- Approval policy: ${project.approvalPolicy}`,
|
|
93
|
+
project.room?.provider ? `- Room: ${project.room.provider} ${project.room.chatId || ""}`.trim() : "- Room: not linked",
|
|
94
|
+
"",
|
|
95
|
+
"## Members",
|
|
96
|
+
memberLines,
|
|
97
|
+
"",
|
|
98
|
+
"## Current Goal",
|
|
99
|
+
"-",
|
|
100
|
+
"",
|
|
101
|
+
"## Active Operator",
|
|
102
|
+
`- ${activeOperator}`,
|
|
103
|
+
"",
|
|
104
|
+
"## Decisions",
|
|
105
|
+
"-",
|
|
106
|
+
"",
|
|
107
|
+
"## Open Questions",
|
|
108
|
+
"-",
|
|
109
|
+
"",
|
|
110
|
+
"## Task Queue",
|
|
111
|
+
"- open: -",
|
|
112
|
+
"- active: -",
|
|
113
|
+
"- blocked: -",
|
|
114
|
+
"- done: -",
|
|
115
|
+
""
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function ensureRoundtable(cwd, project) {
|
|
120
|
+
const target = roundtablePath(cwd);
|
|
121
|
+
if (await pathExists(target)) return target;
|
|
122
|
+
await fs.writeFile(target, defaultRoundtableContent(project), "utf8");
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function appendRoundtableEvent(cwd, line) {
|
|
127
|
+
const target = roundtablePath(cwd);
|
|
128
|
+
if (!(await pathExists(target))) return null;
|
|
129
|
+
await fs.appendFile(target, `${String(line || "").trim()}\n`, "utf8");
|
|
130
|
+
return target;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function loadSharedProject(cwd) {
|
|
134
|
+
try {
|
|
135
|
+
const raw = await fs.readFile(sharedFilePath(cwd), "utf8");
|
|
136
|
+
return normalizeSharedProject(JSON.parse(raw), cwd);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error?.code === "ENOENT") return null;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function saveSharedProject(cwd, project) {
|
|
144
|
+
await ensureProjectStateDir(cwd);
|
|
145
|
+
const next = normalizeSharedProject({
|
|
146
|
+
...project,
|
|
147
|
+
updatedAt: new Date().toISOString()
|
|
148
|
+
}, cwd);
|
|
149
|
+
await writeJsonAtomically(sharedFilePath(cwd), next);
|
|
150
|
+
await ensureRoundtable(cwd, next);
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function enableSharedProject(cwd, options = {}) {
|
|
155
|
+
const existing = await loadSharedProject(cwd);
|
|
156
|
+
const userName = String(options.userName || process.env.USER || os.userInfo().username || "local").trim();
|
|
157
|
+
const userId = String(options.userId || `local:${userName}`).trim();
|
|
158
|
+
const initialRole = String(options.role || "owner").trim() || "owner";
|
|
159
|
+
const member = normalizeMember({ id: userId, name: userName, role: initialRole, paired: true });
|
|
160
|
+
const members = existing?.members?.length ? [...existing.members] : [];
|
|
161
|
+
if (!members.some((item) => item.id === member.id)) members.unshift(member);
|
|
162
|
+
const project = await saveSharedProject(cwd, {
|
|
163
|
+
...existing,
|
|
164
|
+
enabled: true,
|
|
165
|
+
projectName: existing?.projectName || path.basename(cwd),
|
|
166
|
+
members,
|
|
167
|
+
activeOperator: existing?.activeOperator || {
|
|
168
|
+
id: member.id,
|
|
169
|
+
name: member.name,
|
|
170
|
+
claimedAt: new Date().toISOString()
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing enabled by ${member.name || member.id}`);
|
|
174
|
+
return project;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function disableSharedProject(cwd) {
|
|
178
|
+
const existing = await loadSharedProject(cwd);
|
|
179
|
+
if (!existing) return null;
|
|
180
|
+
const next = await saveSharedProject(cwd, {
|
|
181
|
+
...existing,
|
|
182
|
+
enabled: false,
|
|
183
|
+
activeOperator: null
|
|
184
|
+
});
|
|
185
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing disabled`);
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function setSharedRoom(cwd, room = {}) {
|
|
190
|
+
const existing = await loadSharedProject(cwd);
|
|
191
|
+
if (!existing) throw new Error("Project is not shared. Run `waterbrother project share` first.");
|
|
192
|
+
const next = await saveSharedProject(cwd, {
|
|
193
|
+
...existing,
|
|
194
|
+
room: {
|
|
195
|
+
provider: String(room.provider || existing.room?.provider || "").trim(),
|
|
196
|
+
chatId: String(room.chatId || existing.room?.chatId || "").trim(),
|
|
197
|
+
title: String(room.title || existing.room?.title || "").trim()
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return next;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function setSharedRoomMode(cwd, roomMode = "chat") {
|
|
204
|
+
const existing = await loadSharedProject(cwd);
|
|
205
|
+
if (!existing?.enabled) throw new Error("Project is not shared.");
|
|
206
|
+
const normalized = String(roomMode || "").trim().toLowerCase();
|
|
207
|
+
if (!["chat", "plan", "execute"].includes(normalized)) {
|
|
208
|
+
throw new Error("Invalid room mode. Expected one of chat, plan, execute.");
|
|
209
|
+
}
|
|
210
|
+
const next = await saveSharedProject(cwd, {
|
|
211
|
+
...existing,
|
|
212
|
+
roomMode: normalized
|
|
213
|
+
});
|
|
214
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: room mode set to ${normalized}`);
|
|
215
|
+
return next;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function claimSharedOperator(cwd, operator = {}) {
|
|
219
|
+
const existing = await loadSharedProject(cwd);
|
|
220
|
+
if (!existing?.enabled) throw new Error("Project is not shared.");
|
|
221
|
+
const nextOperator = {
|
|
222
|
+
id: String(operator.id || "").trim(),
|
|
223
|
+
name: String(operator.name || "").trim(),
|
|
224
|
+
claimedAt: new Date().toISOString()
|
|
225
|
+
};
|
|
226
|
+
if (!nextOperator.id) throw new Error("operator id is required");
|
|
227
|
+
if (existing.activeOperator?.id && existing.activeOperator.id !== nextOperator.id) {
|
|
228
|
+
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
229
|
+
}
|
|
230
|
+
const members = [...(existing.members || [])];
|
|
231
|
+
if (!members.some((item) => item.id === nextOperator.id)) {
|
|
232
|
+
members.push(normalizeMember({ id: nextOperator.id, name: nextOperator.name, role: "editor", paired: true }));
|
|
233
|
+
}
|
|
234
|
+
const next = await saveSharedProject(cwd, {
|
|
235
|
+
...existing,
|
|
236
|
+
members,
|
|
237
|
+
activeOperator: nextOperator
|
|
238
|
+
});
|
|
239
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator claimed by ${nextOperator.name || nextOperator.id}`);
|
|
240
|
+
return next;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function releaseSharedOperator(cwd, operatorId = "") {
|
|
244
|
+
const existing = await loadSharedProject(cwd);
|
|
245
|
+
if (!existing?.enabled) throw new Error("Project is not shared.");
|
|
246
|
+
const normalizedId = String(operatorId || "").trim();
|
|
247
|
+
if (existing.activeOperator?.id && normalizedId && existing.activeOperator.id !== normalizedId) {
|
|
248
|
+
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
249
|
+
}
|
|
250
|
+
const next = await saveSharedProject(cwd, {
|
|
251
|
+
...existing,
|
|
252
|
+
activeOperator: null
|
|
253
|
+
});
|
|
254
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator released`);
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function formatSharedProjectStatus(project) {
|
|
259
|
+
if (!project) return "shared project: off";
|
|
260
|
+
return JSON.stringify({
|
|
261
|
+
enabled: project.enabled,
|
|
262
|
+
projectId: project.projectId,
|
|
263
|
+
projectName: project.projectName,
|
|
264
|
+
cwd: project.cwd,
|
|
265
|
+
room: project.room,
|
|
266
|
+
mode: project.mode,
|
|
267
|
+
roomMode: project.roomMode,
|
|
268
|
+
approvalPolicy: project.approvalPolicy,
|
|
269
|
+
activeOperator: project.activeOperator,
|
|
270
|
+
members: project.members
|
|
271
|
+
}, null, 2);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getSharedProjectPaths(cwd) {
|
|
275
|
+
return {
|
|
276
|
+
sharedJson: sharedFilePath(cwd),
|
|
277
|
+
roundtable: roundtablePath(cwd)
|
|
278
|
+
};
|
|
279
|
+
}
|