@tritard/waterbrother 0.16.21 → 0.16.22
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 +8 -0
- package/package.json +1 -1
- package/src/cli.js +148 -0
- package/src/gateway.js +114 -0
- package/src/shared-project.js +259 -0
package/README.md
CHANGED
|
@@ -261,6 +261,13 @@ 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
|
+
- claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
|
|
268
|
+
- shared project metadata lives in `.waterbrother/shared.json`
|
|
269
|
+
- human collaboration notes live in `ROUNDTABLE.md`
|
|
270
|
+
|
|
264
271
|
Current Telegram behavior:
|
|
265
272
|
- if the TUI is open, Telegram prompts are injected into the live TUI session and the work is visible in the terminal
|
|
266
273
|
- if no live TUI session is attached, Telegram falls back to a remote run with `approval=never`
|
|
@@ -269,6 +276,7 @@ Current Telegram behavior:
|
|
|
269
276
|
- pending pairings are explicit and expire automatically after 12 hours unless approved
|
|
270
277
|
- paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
|
|
271
278
|
- Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
|
|
279
|
+
- shared projects now support `/room`, `/claim`, and `/release` from Telegram with a single active-operator lock
|
|
272
280
|
- pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
|
|
273
281
|
|
|
274
282
|
## Release flow
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -57,6 +57,15 @@ 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
|
+
} from "./shared-project.js";
|
|
60
69
|
|
|
61
70
|
const execFileAsync = promisify(execFile);
|
|
62
71
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -135,6 +144,11 @@ const INTERACTIVE_COMMANDS = [
|
|
|
135
144
|
{ name: "/gateway unpair <user-id>", description: "Remove a paired Telegram user id" },
|
|
136
145
|
{ name: "/gateway stop", description: "Stop the tracked Telegram gateway process" },
|
|
137
146
|
{ name: "/channels", description: "Show messaging channel readiness" },
|
|
147
|
+
{ name: "/share-project", description: "Enable shared-project mode in the current cwd" },
|
|
148
|
+
{ name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
|
|
149
|
+
{ name: "/room", description: "Show shared room status for the current project" },
|
|
150
|
+
{ name: "/room claim", description: "Claim operator control for the shared room" },
|
|
151
|
+
{ name: "/room release", description: "Release operator control for the shared room" },
|
|
138
152
|
{ name: "/cwd", description: "Show current working directory" },
|
|
139
153
|
{ name: "/use <path>", description: "Switch the live session to a different working directory" },
|
|
140
154
|
{ name: "/desktop", description: "Switch the live session to ~/Desktop" },
|
|
@@ -253,6 +267,11 @@ Usage:
|
|
|
253
267
|
waterbrother gateway pairings [telegram]
|
|
254
268
|
waterbrother gateway pair [telegram] <user-id>
|
|
255
269
|
waterbrother gateway unpair [telegram] <user-id>
|
|
270
|
+
waterbrother project share
|
|
271
|
+
waterbrother project unshare
|
|
272
|
+
waterbrother room status
|
|
273
|
+
waterbrother room claim
|
|
274
|
+
waterbrother room release
|
|
256
275
|
waterbrother mcp list
|
|
257
276
|
waterbrother commit [--push]
|
|
258
277
|
waterbrother pr [--branch=<name>]
|
|
@@ -3697,6 +3716,80 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3697
3716
|
throw new Error("Usage: waterbrother gateway status|run <telegram>|stop [telegram]|pairings [telegram]|pair [telegram] <user-id>|unpair [telegram] <user-id>");
|
|
3698
3717
|
}
|
|
3699
3718
|
|
|
3719
|
+
function getLocalOperatorIdentity() {
|
|
3720
|
+
const userName = String(process.env.USER || process.env.USERNAME || "local").trim() || "local";
|
|
3721
|
+
return {
|
|
3722
|
+
id: `local:${userName}`,
|
|
3723
|
+
name: userName
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
async function runProjectCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
|
|
3728
|
+
const sub = String(positional[1] || "").trim().toLowerCase();
|
|
3729
|
+
if (sub === "share") {
|
|
3730
|
+
const operator = getLocalOperatorIdentity();
|
|
3731
|
+
const project = await enableSharedProject(cwd, { userId: operator.id, userName: operator.name, role: "owner" });
|
|
3732
|
+
const paths = getSharedProjectPaths(cwd);
|
|
3733
|
+
if (asJson) {
|
|
3734
|
+
printData({ ok: true, action: "share", project, paths }, true);
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
console.log(`Shared project enabled in ${cwd}`);
|
|
3738
|
+
console.log(`shared metadata: ${paths.sharedJson}`);
|
|
3739
|
+
console.log(`roundtable: ${paths.roundtable}`);
|
|
3740
|
+
return;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
if (sub === "unshare") {
|
|
3744
|
+
const project = await disableSharedProject(cwd);
|
|
3745
|
+
if (asJson) {
|
|
3746
|
+
printData({ ok: true, action: "unshare", project }, true);
|
|
3747
|
+
return;
|
|
3748
|
+
}
|
|
3749
|
+
console.log(project ? `Shared project disabled in ${cwd}` : "Project was not shared");
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
throw new Error("Usage: waterbrother project share|unshare");
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
|
|
3757
|
+
const sub = String(positional[1] || "status").trim().toLowerCase();
|
|
3758
|
+
if (sub === "status") {
|
|
3759
|
+
const project = await loadSharedProject(cwd);
|
|
3760
|
+
if (asJson) {
|
|
3761
|
+
printData(project || { enabled: false, cwd }, true);
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
console.log(formatSharedProjectStatus(project));
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
if (sub === "claim") {
|
|
3769
|
+
const operator = getLocalOperatorIdentity();
|
|
3770
|
+
const project = await claimSharedOperator(cwd, operator);
|
|
3771
|
+
if (asJson) {
|
|
3772
|
+
printData({ ok: true, action: "claim", project }, true);
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
console.log(`Room claimed by ${operator.name}`);
|
|
3776
|
+
return;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
if (sub === "release") {
|
|
3780
|
+
const operator = getLocalOperatorIdentity();
|
|
3781
|
+
const project = await releaseSharedOperator(cwd, operator.id);
|
|
3782
|
+
if (asJson) {
|
|
3783
|
+
printData({ ok: true, action: "release", project }, true);
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
console.log(`Room released by ${operator.name}`);
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
throw new Error("Usage: waterbrother room status|claim|release");
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3700
3793
|
async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
|
|
3701
3794
|
const sub = positional[1] || "list";
|
|
3702
3795
|
if (sub !== "list") {
|
|
@@ -7495,6 +7588,51 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
7495
7588
|
continue;
|
|
7496
7589
|
}
|
|
7497
7590
|
|
|
7591
|
+
if (line === "/share-project") {
|
|
7592
|
+
try {
|
|
7593
|
+
await runProjectCommand(["project", "share"], { cwd: context.cwd, asJson: false });
|
|
7594
|
+
} catch (error) {
|
|
7595
|
+
console.log(`share-project failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7596
|
+
}
|
|
7597
|
+
continue;
|
|
7598
|
+
}
|
|
7599
|
+
|
|
7600
|
+
if (line === "/unshare-project") {
|
|
7601
|
+
try {
|
|
7602
|
+
await runProjectCommand(["project", "unshare"], { cwd: context.cwd, asJson: false });
|
|
7603
|
+
} catch (error) {
|
|
7604
|
+
console.log(`unshare-project failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7605
|
+
}
|
|
7606
|
+
continue;
|
|
7607
|
+
}
|
|
7608
|
+
|
|
7609
|
+
if (line === "/room") {
|
|
7610
|
+
try {
|
|
7611
|
+
await runRoomCommand(["room", "status"], { cwd: context.cwd, asJson: false });
|
|
7612
|
+
} catch (error) {
|
|
7613
|
+
console.log(`room status failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7614
|
+
}
|
|
7615
|
+
continue;
|
|
7616
|
+
}
|
|
7617
|
+
|
|
7618
|
+
if (line === "/room claim") {
|
|
7619
|
+
try {
|
|
7620
|
+
await runRoomCommand(["room", "claim"], { cwd: context.cwd, asJson: false });
|
|
7621
|
+
} catch (error) {
|
|
7622
|
+
console.log(`room claim failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7623
|
+
}
|
|
7624
|
+
continue;
|
|
7625
|
+
}
|
|
7626
|
+
|
|
7627
|
+
if (line === "/room release") {
|
|
7628
|
+
try {
|
|
7629
|
+
await runRoomCommand(["room", "release"], { cwd: context.cwd, asJson: false });
|
|
7630
|
+
} catch (error) {
|
|
7631
|
+
console.log(`room release failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7632
|
+
}
|
|
7633
|
+
continue;
|
|
7634
|
+
}
|
|
7635
|
+
|
|
7498
7636
|
if (line === "/gateway pairings") {
|
|
7499
7637
|
const ttlMinutes = Number.isFinite(Number(context.runtime.channels?.telegram?.pairingExpiryMinutes))
|
|
7500
7638
|
? Math.max(1, Math.floor(Number(context.runtime.channels.telegram.pairingExpiryMinutes)))
|
|
@@ -9311,6 +9449,16 @@ export async function runCli(argv) {
|
|
|
9311
9449
|
return;
|
|
9312
9450
|
}
|
|
9313
9451
|
|
|
9452
|
+
if (command === "project") {
|
|
9453
|
+
await runProjectCommand(positional, { cwd: startupCwd, asJson });
|
|
9454
|
+
return;
|
|
9455
|
+
}
|
|
9456
|
+
|
|
9457
|
+
if (command === "room") {
|
|
9458
|
+
await runRoomCommand(positional, { cwd: startupCwd, asJson });
|
|
9459
|
+
return;
|
|
9460
|
+
}
|
|
9461
|
+
|
|
9314
9462
|
if (command === "gateway") {
|
|
9315
9463
|
await runGatewayCommand(positional, runtime, { cwd: startupCwd, asJson });
|
|
9316
9464
|
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 } 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,9 @@ 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: "claim", description: "Claim operator control for a shared project" },
|
|
30
|
+
{ command: "release", description: "Release operator control for a shared project" },
|
|
27
31
|
{ command: "sessions", description: "List recent remote sessions" },
|
|
28
32
|
{ command: "new", description: "Start a fresh remote session" },
|
|
29
33
|
{ command: "clear", description: "Clear current remote conversation" }
|
|
@@ -193,6 +197,9 @@ function buildRemoteHelp() {
|
|
|
193
197
|
"<code>/desktop</code> switch the linked session to <code>~/Desktop</code>",
|
|
194
198
|
"<code>/new-project <name></code> create a folder on Desktop and switch into it",
|
|
195
199
|
"<code>/runtime</code> show active provider/model/runtime state",
|
|
200
|
+
"<code>/room</code> show shared project room status",
|
|
201
|
+
"<code>/claim</code> claim operator control for a shared project",
|
|
202
|
+
"<code>/release</code> release operator control for a shared project",
|
|
196
203
|
"<code>/sessions</code> list recent linked remote sessions",
|
|
197
204
|
"<code>/resume <session-id></code> switch the linked remote session",
|
|
198
205
|
"<code>/new</code> start a fresh remote session",
|
|
@@ -257,6 +264,31 @@ function formatSessionListMarkup(currentSessionId, sessions = []) {
|
|
|
257
264
|
return lines.join("\n");
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
function formatTelegramRoomMarkup(project) {
|
|
268
|
+
if (!project?.enabled) {
|
|
269
|
+
return "<b>Shared room</b>\nThis project is not shared.";
|
|
270
|
+
}
|
|
271
|
+
const members = Array.isArray(project.members) ? project.members : [];
|
|
272
|
+
const active = project.activeOperator?.id
|
|
273
|
+
? `${project.activeOperator.name || project.activeOperator.id} (${project.activeOperator.id})`
|
|
274
|
+
: "none";
|
|
275
|
+
const roomLabel = project.room?.chatId
|
|
276
|
+
? `${project.room.provider || "telegram"} ${project.room.chatId}${project.room.title ? ` (${project.room.title})` : ""}`
|
|
277
|
+
: "not linked";
|
|
278
|
+
const memberLines = members.length
|
|
279
|
+
? members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i>`).join("\n")
|
|
280
|
+
: "• none";
|
|
281
|
+
return [
|
|
282
|
+
"<b>Shared room</b>",
|
|
283
|
+
`project: <code>${escapeTelegramHtml(project.projectName || "")}</code>`,
|
|
284
|
+
`mode: <code>${escapeTelegramHtml(project.mode || "single-operator")}</code>`,
|
|
285
|
+
`room: <code>${escapeTelegramHtml(roomLabel)}</code>`,
|
|
286
|
+
`active operator: <code>${escapeTelegramHtml(active)}</code>`,
|
|
287
|
+
"<b>Members</b>",
|
|
288
|
+
memberLines
|
|
289
|
+
].join("\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
260
292
|
function extractRetryDelayMs(error, attempt) {
|
|
261
293
|
const retryAfter = Number(error?.retryAfterSeconds);
|
|
262
294
|
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
@@ -565,6 +597,44 @@ class TelegramGateway {
|
|
|
565
597
|
return session.cwd;
|
|
566
598
|
}
|
|
567
599
|
|
|
600
|
+
async loadSharedProjectForSession(sessionId) {
|
|
601
|
+
const session = await loadSession(sessionId);
|
|
602
|
+
const project = await loadSharedProject(session.cwd || this.cwd);
|
|
603
|
+
return { session, project };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async bindSharedRoomForMessage(message, sessionId) {
|
|
607
|
+
const { session, project } = await this.loadSharedProjectForSession(sessionId);
|
|
608
|
+
if (!project?.enabled) {
|
|
609
|
+
return { session, project };
|
|
610
|
+
}
|
|
611
|
+
const next = await setSharedRoom(session.cwd || this.cwd, {
|
|
612
|
+
provider: "telegram",
|
|
613
|
+
chatId: String(message.chat.id),
|
|
614
|
+
title: String(message.chat.title || "").trim()
|
|
615
|
+
});
|
|
616
|
+
return { session, project: next };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async ensureSharedOperator(message, sessionId) {
|
|
620
|
+
const { session, project } = await this.loadSharedProjectForSession(sessionId);
|
|
621
|
+
if (!project?.enabled) return { ok: true, project: null, session };
|
|
622
|
+
const bound = await this.bindSharedRoomForMessage(message, sessionId);
|
|
623
|
+
const userId = String(message?.from?.id || "").trim();
|
|
624
|
+
if (!bound.project?.activeOperator?.id) {
|
|
625
|
+
return { ok: false, project: bound.project, session: bound.session, reason: "No active operator. Use /claim first." };
|
|
626
|
+
}
|
|
627
|
+
if (bound.project.activeOperator.id !== userId) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
project: bound.project,
|
|
631
|
+
session: bound.session,
|
|
632
|
+
reason: `Room is currently claimed by ${bound.project.activeOperator.name || bound.project.activeOperator.id}. Use /claim after they release it.`
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return { ok: true, project: bound.project, session: bound.session };
|
|
636
|
+
}
|
|
637
|
+
|
|
568
638
|
async getLiveBridgeHost() {
|
|
569
639
|
const bridge = await loadGatewayBridge("telegram");
|
|
570
640
|
const host = bridge.activeHost || {};
|
|
@@ -793,6 +863,45 @@ class TelegramGateway {
|
|
|
793
863
|
return;
|
|
794
864
|
}
|
|
795
865
|
|
|
866
|
+
if (text === "/room") {
|
|
867
|
+
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
868
|
+
await this.sendMessage(message.chat.id, project?.enabled ? formatTelegramRoomMarkup(project) : "<b>Shared room</b>\nThis project is not shared.", message.message_id);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (text === "/claim") {
|
|
873
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
874
|
+
if (!project?.enabled) {
|
|
875
|
+
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);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const claimed = await claimSharedOperator(session.cwd || this.cwd, {
|
|
880
|
+
id: userId,
|
|
881
|
+
name: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId
|
|
882
|
+
});
|
|
883
|
+
await this.sendMessage(message.chat.id, `Shared room claimed by <code>${escapeTelegramHtml(claimed.activeOperator?.name || claimed.activeOperator?.id || userId)}</code>`, message.message_id);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (text === "/release") {
|
|
891
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
892
|
+
if (!project?.enabled) {
|
|
893
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
|
|
898
|
+
await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
796
905
|
if (text === "/runtime") {
|
|
797
906
|
const status = await this.runRuntimeStatus();
|
|
798
907
|
await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
|
|
@@ -903,6 +1012,11 @@ class TelegramGateway {
|
|
|
903
1012
|
const stopTyping = await this.startTypingLoop(message.chat.id);
|
|
904
1013
|
let previewMessage = null;
|
|
905
1014
|
try {
|
|
1015
|
+
const operatorGate = await this.ensureSharedOperator(message, sessionId);
|
|
1016
|
+
if (!operatorGate.ok) {
|
|
1017
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(operatorGate.reason || "Shared room is not available."), message.message_id, { parseMode: "HTML" });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
906
1020
|
previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
|
|
907
1021
|
const content = (await this.runPromptViaBridge(message, sessionId, text))
|
|
908
1022
|
?? (await this.runPromptFallback(sessionId, text));
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
members,
|
|
41
|
+
activeOperator: activeOperator?.id ? activeOperator : null,
|
|
42
|
+
approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
|
|
43
|
+
createdAt: String(project.createdAt || new Date().toISOString()).trim(),
|
|
44
|
+
updatedAt: String(project.updatedAt || new Date().toISOString()).trim()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sharedFilePath(cwd) {
|
|
49
|
+
return path.join(cwd, SHARED_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function roundtablePath(cwd) {
|
|
53
|
+
return path.join(cwd, ROUNDTABLE_FILE);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureProjectStateDir(cwd) {
|
|
57
|
+
await fs.mkdir(path.join(cwd, ".waterbrother"), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function pathExists(target) {
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(target);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function writeJsonAtomically(targetPath, value) {
|
|
70
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
71
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
72
|
+
await fs.rename(tempPath, targetPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultRoundtableContent(project) {
|
|
76
|
+
const memberLines = project.members.length
|
|
77
|
+
? project.members.map((member) => `- ${member.name || member.id} (${member.role})`).join("\n")
|
|
78
|
+
: "- none yet";
|
|
79
|
+
const activeOperator = project.activeOperator?.id
|
|
80
|
+
? `${project.activeOperator.name || project.activeOperator.id}`
|
|
81
|
+
: "none";
|
|
82
|
+
return [
|
|
83
|
+
"# Roundtable",
|
|
84
|
+
"",
|
|
85
|
+
"## Project",
|
|
86
|
+
`- Name: ${project.projectName}`,
|
|
87
|
+
`- Mode: ${project.mode}`,
|
|
88
|
+
`- Approval policy: ${project.approvalPolicy}`,
|
|
89
|
+
project.room?.provider ? `- Room: ${project.room.provider} ${project.room.chatId || ""}`.trim() : "- Room: not linked",
|
|
90
|
+
"",
|
|
91
|
+
"## Members",
|
|
92
|
+
memberLines,
|
|
93
|
+
"",
|
|
94
|
+
"## Current Goal",
|
|
95
|
+
"-",
|
|
96
|
+
"",
|
|
97
|
+
"## Active Operator",
|
|
98
|
+
`- ${activeOperator}`,
|
|
99
|
+
"",
|
|
100
|
+
"## Decisions",
|
|
101
|
+
"-",
|
|
102
|
+
"",
|
|
103
|
+
"## Open Questions",
|
|
104
|
+
"-",
|
|
105
|
+
"",
|
|
106
|
+
"## Task Queue",
|
|
107
|
+
"- open: -",
|
|
108
|
+
"- active: -",
|
|
109
|
+
"- blocked: -",
|
|
110
|
+
"- done: -",
|
|
111
|
+
""
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function ensureRoundtable(cwd, project) {
|
|
116
|
+
const target = roundtablePath(cwd);
|
|
117
|
+
if (await pathExists(target)) return target;
|
|
118
|
+
await fs.writeFile(target, defaultRoundtableContent(project), "utf8");
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function appendRoundtableEvent(cwd, line) {
|
|
123
|
+
const target = roundtablePath(cwd);
|
|
124
|
+
if (!(await pathExists(target))) return null;
|
|
125
|
+
await fs.appendFile(target, `${String(line || "").trim()}\n`, "utf8");
|
|
126
|
+
return target;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function loadSharedProject(cwd) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = await fs.readFile(sharedFilePath(cwd), "utf8");
|
|
132
|
+
return normalizeSharedProject(JSON.parse(raw), cwd);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code === "ENOENT") return null;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function saveSharedProject(cwd, project) {
|
|
140
|
+
await ensureProjectStateDir(cwd);
|
|
141
|
+
const next = normalizeSharedProject({
|
|
142
|
+
...project,
|
|
143
|
+
updatedAt: new Date().toISOString()
|
|
144
|
+
}, cwd);
|
|
145
|
+
await writeJsonAtomically(sharedFilePath(cwd), next);
|
|
146
|
+
await ensureRoundtable(cwd, next);
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function enableSharedProject(cwd, options = {}) {
|
|
151
|
+
const existing = await loadSharedProject(cwd);
|
|
152
|
+
const userName = String(options.userName || process.env.USER || os.userInfo().username || "local").trim();
|
|
153
|
+
const userId = String(options.userId || `local:${userName}`).trim();
|
|
154
|
+
const initialRole = String(options.role || "owner").trim() || "owner";
|
|
155
|
+
const member = normalizeMember({ id: userId, name: userName, role: initialRole, paired: true });
|
|
156
|
+
const members = existing?.members?.length ? [...existing.members] : [];
|
|
157
|
+
if (!members.some((item) => item.id === member.id)) members.unshift(member);
|
|
158
|
+
const project = await saveSharedProject(cwd, {
|
|
159
|
+
...existing,
|
|
160
|
+
enabled: true,
|
|
161
|
+
projectName: existing?.projectName || path.basename(cwd),
|
|
162
|
+
members,
|
|
163
|
+
activeOperator: existing?.activeOperator || {
|
|
164
|
+
id: member.id,
|
|
165
|
+
name: member.name,
|
|
166
|
+
claimedAt: new Date().toISOString()
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing enabled by ${member.name || member.id}`);
|
|
170
|
+
return project;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function disableSharedProject(cwd) {
|
|
174
|
+
const existing = await loadSharedProject(cwd);
|
|
175
|
+
if (!existing) return null;
|
|
176
|
+
const next = await saveSharedProject(cwd, {
|
|
177
|
+
...existing,
|
|
178
|
+
enabled: false,
|
|
179
|
+
activeOperator: null
|
|
180
|
+
});
|
|
181
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing disabled`);
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function setSharedRoom(cwd, room = {}) {
|
|
186
|
+
const existing = await loadSharedProject(cwd);
|
|
187
|
+
if (!existing) throw new Error("Project is not shared. Run `waterbrother project share` first.");
|
|
188
|
+
const next = await saveSharedProject(cwd, {
|
|
189
|
+
...existing,
|
|
190
|
+
room: {
|
|
191
|
+
provider: String(room.provider || existing.room?.provider || "").trim(),
|
|
192
|
+
chatId: String(room.chatId || existing.room?.chatId || "").trim(),
|
|
193
|
+
title: String(room.title || existing.room?.title || "").trim()
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return next;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function claimSharedOperator(cwd, operator = {}) {
|
|
200
|
+
const existing = await loadSharedProject(cwd);
|
|
201
|
+
if (!existing?.enabled) throw new Error("Project is not shared.");
|
|
202
|
+
const nextOperator = {
|
|
203
|
+
id: String(operator.id || "").trim(),
|
|
204
|
+
name: String(operator.name || "").trim(),
|
|
205
|
+
claimedAt: new Date().toISOString()
|
|
206
|
+
};
|
|
207
|
+
if (!nextOperator.id) throw new Error("operator id is required");
|
|
208
|
+
if (existing.activeOperator?.id && existing.activeOperator.id !== nextOperator.id) {
|
|
209
|
+
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
210
|
+
}
|
|
211
|
+
const members = [...(existing.members || [])];
|
|
212
|
+
if (!members.some((item) => item.id === nextOperator.id)) {
|
|
213
|
+
members.push(normalizeMember({ id: nextOperator.id, name: nextOperator.name, role: "editor", paired: true }));
|
|
214
|
+
}
|
|
215
|
+
const next = await saveSharedProject(cwd, {
|
|
216
|
+
...existing,
|
|
217
|
+
members,
|
|
218
|
+
activeOperator: nextOperator
|
|
219
|
+
});
|
|
220
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator claimed by ${nextOperator.name || nextOperator.id}`);
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function releaseSharedOperator(cwd, operatorId = "") {
|
|
225
|
+
const existing = await loadSharedProject(cwd);
|
|
226
|
+
if (!existing?.enabled) throw new Error("Project is not shared.");
|
|
227
|
+
const normalizedId = String(operatorId || "").trim();
|
|
228
|
+
if (existing.activeOperator?.id && normalizedId && existing.activeOperator.id !== normalizedId) {
|
|
229
|
+
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
230
|
+
}
|
|
231
|
+
const next = await saveSharedProject(cwd, {
|
|
232
|
+
...existing,
|
|
233
|
+
activeOperator: null
|
|
234
|
+
});
|
|
235
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator released`);
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatSharedProjectStatus(project) {
|
|
240
|
+
if (!project) return "shared project: off";
|
|
241
|
+
return JSON.stringify({
|
|
242
|
+
enabled: project.enabled,
|
|
243
|
+
projectId: project.projectId,
|
|
244
|
+
projectName: project.projectName,
|
|
245
|
+
cwd: project.cwd,
|
|
246
|
+
room: project.room,
|
|
247
|
+
mode: project.mode,
|
|
248
|
+
approvalPolicy: project.approvalPolicy,
|
|
249
|
+
activeOperator: project.activeOperator,
|
|
250
|
+
members: project.members
|
|
251
|
+
}, null, 2);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function getSharedProjectPaths(cwd) {
|
|
255
|
+
return {
|
|
256
|
+
sharedJson: sharedFilePath(cwd),
|
|
257
|
+
roundtable: roundtablePath(cwd)
|
|
258
|
+
};
|
|
259
|
+
}
|