@tritard/waterbrother 0.16.139 → 0.16.141

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/discord.js +299 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.139",
3
+ "version": "0.16.141",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/discord.js CHANGED
@@ -7,18 +7,25 @@ import { loadGatewayBridge, loadGatewayState, saveGatewayBridge, saveGatewayStat
7
7
  import {
8
8
  acceptSharedInvite,
9
9
  approveSharedInvite,
10
+ assignSharedTask,
11
+ addSharedTask,
10
12
  claimSharedOperator,
13
+ claimSharedTask,
14
+ commentSharedTask,
11
15
  createSharedInvite,
12
16
  enableSharedProject,
17
+ getSharedTaskHistory,
13
18
  listSharedEvents,
14
19
  listSharedInvites,
15
20
  listSharedTasks,
16
21
  loadSharedProject,
22
+ moveSharedTask,
17
23
  rejectSharedInvite,
18
24
  releaseSharedOperator,
19
25
  removeSharedMember,
20
26
  setSharedRoom,
21
- setSharedRoomMode
27
+ setSharedRoomMode,
28
+ upsertSharedAgent
22
29
  } from "./shared-project.js";
23
30
 
24
31
  const DISCORD_API_BASE = "https://discord.com/api/v10";
@@ -159,8 +166,19 @@ function buildDiscordHelp() {
159
166
  "/project share enable Roundtable on the current project and bind this Discord channel",
160
167
  "/room show shared room status for the current project",
161
168
  "/people list recently seen Discord users in this channel",
169
+ "/terminals list live Waterbrother terminals for this project",
170
+ "/executor show the selected executor",
171
+ "/reviewer show the assigned reviewer",
172
+ "/verifier show the assigned verifier",
173
+ "/assign-role <terminal|this terminal> <executor|reviewer|verifier|standby> assign a room role",
162
174
  "/members list shared room members",
163
175
  "/tasks list shared room tasks",
176
+ "/task add <text> add a shared task",
177
+ "/task move <id> <open|active|blocked|done> move a shared task",
178
+ "/task assign <id> <member-id> assign a shared task",
179
+ "/task claim <id> claim a shared task for yourself",
180
+ "/task comment <id> <text> add a shared task comment",
181
+ "/task history <id> show shared task history and comments",
164
182
  "/events list recent shared room events",
165
183
  "/invites list pending shared room invites",
166
184
  "/invite <@user|user-id> [owner|editor|observer] create a shared room invite",
@@ -310,6 +328,77 @@ function formatDiscordPeople({ people = [], project = null } = {}) {
310
328
  ].join("\n");
311
329
  }
312
330
 
331
+ function getAgentOwnerDisplay(agent = {}, fallback = "") {
332
+ return String(agent?.ownerName || fallback || agent?.label || agent?.ownerId || agent?.id || "unknown").trim() || "unknown";
333
+ }
334
+
335
+ function getAgentTerminalDisplay(agent = {}, fallback = "") {
336
+ return String(agent?.label || fallback || agent?.ownerName || agent?.ownerId || agent?.id || "terminal").trim() || "terminal";
337
+ }
338
+
339
+ function formatBridgeHostLabel(host = {}) {
340
+ const owner = String(host?.ownerName || host?.ownerId || "").trim();
341
+ const label = String(host?.label || "").trim();
342
+ const sessionSuffix = String(host?.sessionId || "").trim().slice(-6);
343
+ const runtime = host?.provider && host?.model ? `${host.provider}/${host.model}` : "";
344
+ const primary = label || owner || (sessionSuffix ? `terminal ${sessionSuffix}` : "live terminal");
345
+ return [primary, owner && label && owner !== label ? `(${owner})` : "", runtime ? `[${runtime}]` : ""].filter(Boolean).join(" ").trim();
346
+ }
347
+
348
+ function chooseAgentByRole(project = {}, role = "") {
349
+ return (Array.isArray(project?.agents) ? project.agents : []).find((agent) => String(agent?.role || "").trim() === String(role || "").trim()) || null;
350
+ }
351
+
352
+ function formatDiscordAgentStatus(title, agent = null, options = {}) {
353
+ if (!agent) return `${title}\nNo ${String(title || "").toLowerCase()} is assigned yet.`;
354
+ return [
355
+ title,
356
+ `owner: ${getAgentOwnerDisplay(agent)}`,
357
+ `terminal: ${getAgentTerminalDisplay(agent)}`,
358
+ `role: ${agent.role || "standby"}`,
359
+ `runtime: ${agent.provider && agent.model ? `${agent.provider}/${agent.model}` : "unknown"}`,
360
+ `live: ${options.live ? "yes" : "no"}`
361
+ ].join("\n");
362
+ }
363
+
364
+ function formatDiscordLiveHosts(hosts = []) {
365
+ if (!hosts.length) return "Live terminals\n- none";
366
+ return [
367
+ "Live terminals",
368
+ ...hosts.map((host) => `- ${formatBridgeHostLabel(host)}${host?.surface ? ` (${host.surface})` : ""}`)
369
+ ].join("\n");
370
+ }
371
+
372
+ function formatDiscordTaskHistory(result = {}) {
373
+ const task = result?.task;
374
+ if (!task) return "No shared task found.";
375
+ const lines = [
376
+ `Task [${task.id}]`,
377
+ `${task.text}`,
378
+ `state: ${task.state}${task.assignedTo ? ` assigned: ${task.assignedTo}` : ""}`,
379
+ "",
380
+ "History"
381
+ ];
382
+ const history = Array.isArray(result.history) ? result.history : [];
383
+ const comments = Array.isArray(result.comments) ? result.comments : [];
384
+ if (history.length) {
385
+ for (const entry of history.slice(-8)) {
386
+ lines.push(`- ${entry.createdAt} ${entry.type} ${entry.text}`);
387
+ }
388
+ } else {
389
+ lines.push("- none");
390
+ }
391
+ lines.push("", "Comments");
392
+ if (comments.length) {
393
+ for (const comment of comments.slice(-8)) {
394
+ lines.push(`- ${comment.createdAt} ${comment.actorName || comment.actorId || "unknown"}: ${comment.text}`);
395
+ }
396
+ } else {
397
+ lines.push("- none");
398
+ }
399
+ return lines.join("\n");
400
+ }
401
+
313
402
  function parseDiscordInviteCommand(message = {}, text = "") {
314
403
  const value = String(text || "").trim();
315
404
  const match = value.match(/^\/?(?:room\s+)?invite\s+(.+)$/i);
@@ -467,6 +556,60 @@ function resolveDiscordInviteTarget(state, message = {}, rawTarget = "") {
467
556
  return direct;
468
557
  }
469
558
 
559
+ function resolveDiscordAgentTarget(project = {}, liveHosts = [], rawTarget = "") {
560
+ const value = String(rawTarget || "").trim().toLowerCase();
561
+ if (!value) return null;
562
+ if (/^(?:this|my)(?:\s+terminal)?$/.test(value)) {
563
+ const host = liveHosts[0] || null;
564
+ if (!host) return null;
565
+ return {
566
+ id: `agent:discord-bridge:${String(host.sessionId || host.pid || "current").trim()}`,
567
+ ownerId: String(host.ownerId || "").trim(),
568
+ ownerName: String(host.ownerName || "").trim(),
569
+ label: String(host.label || "").trim(),
570
+ surface: String(host.surface || "live-tui").trim(),
571
+ provider: String(host.provider || "").trim(),
572
+ model: String(host.model || "").trim(),
573
+ runtimeProfile: String(host.runtimeProfile || "").trim(),
574
+ sessionId: String(host.sessionId || "").trim(),
575
+ cwd: String(host.cwd || "").trim(),
576
+ role: "standby"
577
+ };
578
+ }
579
+ if (["executor", "reviewer", "verifier"].includes(value)) {
580
+ return chooseAgentByRole(project, value);
581
+ }
582
+ const candidates = [
583
+ ...(Array.isArray(project?.agents) ? project.agents : []),
584
+ ...liveHosts.map((host) => ({
585
+ id: `agent:discord-bridge:${String(host.sessionId || host.pid || "current").trim()}`,
586
+ ownerId: String(host.ownerId || "").trim(),
587
+ ownerName: String(host.ownerName || "").trim(),
588
+ label: String(host.label || "").trim(),
589
+ surface: String(host.surface || "live-tui").trim(),
590
+ provider: String(host.provider || "").trim(),
591
+ model: String(host.model || "").trim(),
592
+ runtimeProfile: String(host.runtimeProfile || "").trim(),
593
+ sessionId: String(host.sessionId || "").trim(),
594
+ cwd: String(host.cwd || "").trim(),
595
+ role: "standby"
596
+ }))
597
+ ];
598
+ const exact = candidates.find((agent) => {
599
+ const label = String(agent?.label || "").trim().toLowerCase();
600
+ const owner = String(agent?.ownerName || "").trim().toLowerCase();
601
+ const sessionId = String(agent?.sessionId || "").trim().toLowerCase();
602
+ return value === label || value === owner || value === sessionId;
603
+ });
604
+ if (exact) return exact;
605
+ const partial = candidates.filter((agent) => {
606
+ const label = String(agent?.label || "").trim().toLowerCase();
607
+ const owner = String(agent?.ownerName || "").trim().toLowerCase();
608
+ return (label && label.includes(value)) || (owner && owner.includes(value));
609
+ });
610
+ return partial.length === 1 ? partial[0] : null;
611
+ }
612
+
470
613
  async function loadDiscordGatewayState() {
471
614
  return loadGatewayState("discord");
472
615
  }
@@ -875,6 +1018,133 @@ async function handleDiscordControlCommand(runtime, state, message, rawText) {
875
1018
  "Use /room to inspect room state."
876
1019
  ].join("\n");
877
1020
  }
1021
+ if (normalized === "/terminals" || normalized === "terminals" || normalized === "/live" || normalized === "live" || normalizedRoomAlias === "terminals") {
1022
+ const sessionId = await ensurePeerSession(runtime, state, message);
1023
+ const session = await loadSession(sessionId).catch(() => null);
1024
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1025
+ return formatDiscordLiveHosts(liveHosts);
1026
+ }
1027
+ if (normalized === "/executor" || normalized === "executor" || normalizedRoomAlias === "executor") {
1028
+ const sessionId = await ensurePeerSession(runtime, state, message);
1029
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1030
+ if (!project?.enabled) return "This project is not shared.";
1031
+ const agent = chooseAgentByRole(project, "executor");
1032
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1033
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
1034
+ return formatDiscordAgentStatus("Executor", agent, { live });
1035
+ }
1036
+ if (normalized === "/reviewer" || normalized === "reviewer" || normalizedRoomAlias === "reviewer") {
1037
+ const sessionId = await ensurePeerSession(runtime, state, message);
1038
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1039
+ if (!project?.enabled) return "This project is not shared.";
1040
+ const agent = chooseAgentByRole(project, "reviewer");
1041
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1042
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
1043
+ return formatDiscordAgentStatus("Reviewer", agent, { live });
1044
+ }
1045
+ if (normalized === "/verifier" || normalized === "verifier" || normalizedRoomAlias === "verifier") {
1046
+ const sessionId = await ensurePeerSession(runtime, state, message);
1047
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1048
+ if (!project?.enabled) return "This project is not shared.";
1049
+ const agent = chooseAgentByRole(project, "verifier");
1050
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1051
+ const live = agent ? liveHosts.some((host) => String(host?.ownerId || "") === String(agent?.ownerId || "") || String(host?.sessionId || "") === String(agent?.sessionId || "")) : false;
1052
+ return formatDiscordAgentStatus("Verifier", agent, { live });
1053
+ }
1054
+ if (normalized.startsWith("/assign-role ") || normalizedRoomAlias.startsWith("assign-role ")) {
1055
+ const body = normalized.startsWith("/assign-role ") ? value.slice("/assign-role ".length).trim() : roomAlias.slice("assign-role ".length).trim();
1056
+ const match = body.match(/^(.+?)\s+(executor|reviewer|verifier|standby)\s*$/i);
1057
+ if (!match) return "Usage: /assign-role <terminal|this terminal> <executor|reviewer|verifier|standby>";
1058
+ const targetText = String(match[1] || "").trim();
1059
+ const nextRole = String(match[2] || "").trim().toLowerCase();
1060
+ const sessionId = await ensurePeerSession(runtime, state, message);
1061
+ const { project, session } = await bindSharedRoomForMessage(message, sessionId);
1062
+ if (!project?.enabled) return "This project is not shared.";
1063
+ const liveHosts = await getLiveBridgeHosts({ cwd: session?.cwd || "" });
1064
+ const targetAgent = resolveDiscordAgentTarget(project, liveHosts, targetText);
1065
+ if (!targetAgent) {
1066
+ return `No terminal found for ${targetText}. Use /terminals first.`;
1067
+ }
1068
+ const actor = describeDiscordUser(message);
1069
+ const nextProject = await upsertSharedAgent(session.cwd || process.cwd(), {
1070
+ ...targetAgent,
1071
+ role: nextRole
1072
+ }, {
1073
+ actorId: actor.userId,
1074
+ actorName: actor.displayName
1075
+ });
1076
+ return [
1077
+ "Room role updated",
1078
+ `owner: ${getAgentOwnerDisplay(targetAgent, actor.displayName || actor.userId)}`,
1079
+ `terminal: ${getAgentTerminalDisplay(targetAgent, "terminal")}`,
1080
+ `role: ${nextRole}`,
1081
+ `project: ${nextProject.projectName || "project"}`
1082
+ ].join("\n");
1083
+ }
1084
+ if (normalized.startsWith("/task add ") || normalizedRoomAlias.startsWith("task add ")) {
1085
+ const taskText = normalized.startsWith("/task add ") ? value.slice("/task add ".length).trim() : roomAlias.slice("task add ".length).trim();
1086
+ if (!taskText) return "Usage: /task add <text>";
1087
+ const sessionId = await ensurePeerSession(runtime, state, message);
1088
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1089
+ if (!project?.enabled) return "This project is not shared.";
1090
+ const actor = describeDiscordUser(message);
1091
+ const result = await addSharedTask(session.cwd || process.cwd(), taskText, { actorId: actor.userId, actorName: actor.displayName });
1092
+ return `Added shared task ${result.task.id}`;
1093
+ }
1094
+ if (normalized.startsWith("/task move ") || normalizedRoomAlias.startsWith("task move ")) {
1095
+ const rest = normalized.startsWith("/task move ") ? value.slice("/task move ".length).trim() : roomAlias.slice("task move ".length).trim();
1096
+ const [taskId, nextState] = rest.split(/\s+/, 2);
1097
+ if (!taskId || !nextState) return "Usage: /task move <id> <open|active|blocked|done>";
1098
+ const sessionId = await ensurePeerSession(runtime, state, message);
1099
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1100
+ if (!project?.enabled) return "This project is not shared.";
1101
+ const actor = describeDiscordUser(message);
1102
+ const result = await moveSharedTask(session.cwd || process.cwd(), taskId, nextState, { actorId: actor.userId, actorName: actor.displayName });
1103
+ return `Moved shared task ${result.task.id} to ${result.task.state}`;
1104
+ }
1105
+ if (normalized.startsWith("/task assign ") || normalizedRoomAlias.startsWith("task assign ")) {
1106
+ const rest = normalized.startsWith("/task assign ") ? value.slice("/task assign ".length).trim() : roomAlias.slice("task assign ".length).trim();
1107
+ const [taskId, memberId] = rest.split(/\s+/, 2);
1108
+ if (!taskId || !memberId) return "Usage: /task assign <id> <member-id>";
1109
+ const sessionId = await ensurePeerSession(runtime, state, message);
1110
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1111
+ if (!project?.enabled) return "This project is not shared.";
1112
+ const actor = describeDiscordUser(message);
1113
+ const result = await assignSharedTask(session.cwd || process.cwd(), taskId, memberId, { actorId: actor.userId, actorName: actor.displayName });
1114
+ return `Assigned shared task ${result.task.id} to ${memberId}`;
1115
+ }
1116
+ if (normalized.startsWith("/task claim ") || normalizedRoomAlias.startsWith("task claim ")) {
1117
+ const taskId = normalized.startsWith("/task claim ") ? value.slice("/task claim ".length).trim() : roomAlias.slice("task claim ".length).trim();
1118
+ if (!taskId) return "Usage: /task claim <id>";
1119
+ const sessionId = await ensurePeerSession(runtime, state, message);
1120
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1121
+ if (!project?.enabled) return "This project is not shared.";
1122
+ const actor = describeDiscordUser(message);
1123
+ const result = await claimSharedTask(session.cwd || process.cwd(), taskId, { actorId: actor.userId, actorName: actor.displayName });
1124
+ return `Claimed shared task ${result.task.id}`;
1125
+ }
1126
+ if (normalized.startsWith("/task comment ") || normalizedRoomAlias.startsWith("task comment ")) {
1127
+ const rest = normalized.startsWith("/task comment ") ? value.slice("/task comment ".length).trim() : roomAlias.slice("task comment ".length).trim();
1128
+ const [taskId, ...parts] = rest.split(/\s+/);
1129
+ const commentText = parts.join(" ").trim();
1130
+ if (!taskId || !commentText) return "Usage: /task comment <id> <text>";
1131
+ const sessionId = await ensurePeerSession(runtime, state, message);
1132
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1133
+ if (!project?.enabled) return "This project is not shared.";
1134
+ const actor = describeDiscordUser(message);
1135
+ const result = await commentSharedTask(session.cwd || process.cwd(), taskId, commentText, { actorId: actor.userId, actorName: actor.displayName });
1136
+ return `Commented on shared task ${result.task.id}`;
1137
+ }
1138
+ if (normalized.startsWith("/task history ") || normalizedRoomAlias.startsWith("task history ")) {
1139
+ const taskId = normalized.startsWith("/task history ") ? value.slice("/task history ".length).trim() : roomAlias.slice("task history ".length).trim();
1140
+ if (!taskId) return "Usage: /task history <id>";
1141
+ const sessionId = await ensurePeerSession(runtime, state, message);
1142
+ const { session, project } = await bindSharedRoomForMessage(message, sessionId);
1143
+ if (!project?.enabled) return "This project is not shared.";
1144
+ const actor = describeDiscordUser(message);
1145
+ const result = await getSharedTaskHistory(session.cwd || process.cwd(), taskId, { actorId: actor.userId, actorName: actor.displayName });
1146
+ return formatDiscordTaskHistory(result);
1147
+ }
878
1148
  if (normalized === "/people" || normalized === "people" || normalizedRoomAlias === "people") {
879
1149
  const sessionId = await ensurePeerSession(runtime, state, message);
880
1150
  const { project } = await bindSharedRoomForMessage(message, sessionId);
@@ -1096,6 +1366,34 @@ async function getLiveBridgeHost({ cwd = "" } = {}) {
1096
1366
  return matchingHosts[0] || null;
1097
1367
  }
1098
1368
 
1369
+ async function getLiveBridgeHosts({ cwd = "" } = {}) {
1370
+ const bridge = await loadGatewayBridge("discord");
1371
+ const hosts = Array.isArray(bridge.hosts) ? bridge.hosts : [];
1372
+ const nextHosts = [];
1373
+ let changed = false;
1374
+ for (const host of hosts) {
1375
+ const pid = Number(host?.pid || 0);
1376
+ if (!Number.isFinite(pid) || pid <= 0) {
1377
+ changed = true;
1378
+ continue;
1379
+ }
1380
+ try {
1381
+ process.kill(pid, 0);
1382
+ } catch {
1383
+ changed = true;
1384
+ continue;
1385
+ }
1386
+ nextHosts.push(host);
1387
+ }
1388
+ if (changed || nextHosts.length !== hosts.length) {
1389
+ bridge.hosts = nextHosts;
1390
+ await saveGatewayBridge("discord", bridge);
1391
+ }
1392
+ return cwd
1393
+ ? nextHosts.filter((host) => !host?.cwd || String(host.cwd) === String(cwd || ""))
1394
+ : nextHosts;
1395
+ }
1396
+
1099
1397
  async function runPromptViaBridge(runtime, message, promptText, options = {}) {
1100
1398
  const host = await getLiveBridgeHost();
1101
1399
  if (!host) {