openclaw-pincer 0.3.2 → 0.3.4

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.
@@ -31,6 +31,91 @@ async function httpPost(config, path, body) {
31
31
  if (!res.ok)
32
32
  throw new Error(`Pincer POST ${path} ${res.status}`);
33
33
  }
34
+ // Fetch project room_ids from /api/v1/projects
35
+ async function fetchProjectRooms(config) {
36
+ const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/projects`;
37
+ try {
38
+ const res = await fetch(url, {
39
+ headers: { "X-API-Key": config.token, "User-Agent": "openclaw-pincer/0.3" },
40
+ });
41
+ if (!res.ok)
42
+ return [];
43
+ const data = await res.json();
44
+ const projects = Array.isArray(data) ? data : [];
45
+ return projects.map((p) => p.room_id).filter(Boolean);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ // Subscribe to a single room WebSocket and dispatch messages
52
+ function connectRoomWs(params) {
53
+ const { config, ctx, roomId, signal } = params;
54
+ let retryMs = 2000;
55
+ function connect() {
56
+ if (signal.aborted)
57
+ return;
58
+ const wsBase = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
59
+ const ws = new WebSocket(`${wsBase}/api/v1/rooms/${roomId}/ws?api_key=${config.token}`);
60
+ ws.on("open", () => {
61
+ retryMs = 2000;
62
+ console.log(`[openclaw-pincer] Room WS connected: ${roomId}`);
63
+ });
64
+ ws.on("message", (data) => {
65
+ try {
66
+ const msg = JSON.parse(data.toString());
67
+ if (msg.type !== "room.message")
68
+ return;
69
+ const payload = msg.data ?? msg.payload ?? {};
70
+ const sender = payload.sender_agent_id ?? "unknown";
71
+ const content = payload.content ?? "";
72
+ if (sender === config.agentId || !content)
73
+ return;
74
+ // Mention-only: respond only when @agentName or @all
75
+ const agentName = config.agentName ?? "";
76
+ const isMentioned = agentName && content.includes(`@${agentName}`);
77
+ const isBroadcast = content.includes("@all") || content.includes("@所有人");
78
+ if (!isMentioned && !isBroadcast)
79
+ return;
80
+ const runtime = ctx.channelRuntime;
81
+ if (!runtime)
82
+ return;
83
+ dispatchToAgent(config, ctx, runtime, sender, content, roomId);
84
+ }
85
+ catch { /* ignore */ }
86
+ });
87
+ ws.on("close", () => {
88
+ if (signal.aborted)
89
+ return;
90
+ setTimeout(connect, retryMs);
91
+ retryMs = Math.min(retryMs * 1.5, 30000);
92
+ });
93
+ ws.on("error", () => ws.close());
94
+ signal.addEventListener("abort", () => ws.close(), { once: true });
95
+ }
96
+ connect();
97
+ }
98
+ // Subscribe to all project rooms, refresh every PROJECT_REFRESH_INTERVAL ms
99
+ function startProjectRoomSubscriptions(params) {
100
+ const { config, ctx, signal } = params;
101
+ const PROJECT_REFRESH_MS = 60_000;
102
+ const subscribedRooms = new Set();
103
+ async function refresh() {
104
+ if (signal.aborted)
105
+ return;
106
+ const rooms = await fetchProjectRooms(config);
107
+ for (const roomId of rooms) {
108
+ if (!subscribedRooms.has(roomId)) {
109
+ subscribedRooms.add(roomId);
110
+ connectRoomWs({ config, ctx, roomId, signal });
111
+ console.log(`[openclaw-pincer] Subscribed to project room: ${roomId}`);
112
+ }
113
+ }
114
+ }
115
+ refresh();
116
+ const timer = setInterval(refresh, PROJECT_REFRESH_MS);
117
+ signal.addEventListener("abort", () => clearInterval(timer), { once: true });
118
+ }
34
119
  function connectWs(params) {
35
120
  const { config, ctx, signal } = params;
36
121
  let retryMs = 1000;
@@ -162,6 +247,12 @@ function handleMessage(config, ctx, msg) {
162
247
  const roomId = data.room_id ?? msg.room_id ?? "";
163
248
  if (sender === config.agentId || !content)
164
249
  return;
250
+ // Mention-only: only respond when @agentName or @all (prevents echo loops)
251
+ const agentName = config.agentName ?? "";
252
+ const isMentioned = agentName && content.includes(`@${agentName}`);
253
+ const isBroadcast = content.includes("@all") || content.includes("@所有人");
254
+ if (!isMentioned && !isBroadcast)
255
+ return;
165
256
  console.log(`[openclaw-pincer] 💬 Room msg from ${sender.slice(0, 8)}: ${content.slice(0, 60)}`);
166
257
  dispatchToAgent(config, ctx, runtime, sender, content, roomId);
167
258
  return;
@@ -267,7 +358,10 @@ export const pincerChannel = {
267
358
  if (!config.agentName)
268
359
  config.agentName = "openclaw-agent";
269
360
  const signal = ctx.abortSignal;
361
+ // Connect to agent DM hub
270
362
  connectWs({ config, ctx, signal });
363
+ // Subscribe to all project rooms (and refresh every 60s for new projects)
364
+ startProjectRoomSubscriptions({ config, ctx, signal });
271
365
  console.log("[openclaw-pincer] Started, connecting via WebSocket");
272
366
  await new Promise((resolve) => {
273
367
  if (signal.aborted)
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-pincer",
3
3
  "name": "Pincer",
4
4
  "description": "Pincer channel plugin for OpenClaw — connects agents to Pincer via WebSocket",
5
- "version": "0.3.1",
5
+ "version": "0.3.3",
6
6
  "channels": ["openclaw-pincer"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-pincer",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Pincer channel plugin for OpenClaw",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/channel.ts CHANGED
@@ -43,6 +43,101 @@ async function httpPost(config: PincerConfig, path: string, body: any): Promise<
43
43
  if (!res.ok) throw new Error(`Pincer POST ${path} ${res.status}`);
44
44
  }
45
45
 
46
+ // Fetch project room_ids from /api/v1/projects
47
+ async function fetchProjectRooms(config: PincerConfig): Promise<string[]> {
48
+ const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/projects`;
49
+ try {
50
+ const res = await fetch(url, {
51
+ headers: { "X-API-Key": config.token, "User-Agent": "openclaw-pincer/0.3" },
52
+ });
53
+ if (!res.ok) return [];
54
+ const data = await res.json();
55
+ const projects = Array.isArray(data) ? data : [];
56
+ return projects.map((p: any) => p.room_id).filter(Boolean);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ // Subscribe to a single room WebSocket and dispatch messages
63
+ function connectRoomWs(params: {
64
+ config: PincerConfig;
65
+ ctx: any;
66
+ roomId: string;
67
+ signal: AbortSignal;
68
+ }): void {
69
+ const { config, ctx, roomId, signal } = params;
70
+ let retryMs = 2000;
71
+
72
+ function connect() {
73
+ if (signal.aborted) return;
74
+ const wsBase = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
75
+ const ws = new WebSocket(`${wsBase}/api/v1/rooms/${roomId}/ws?api_key=${config.token}`);
76
+
77
+ ws.on("open", () => {
78
+ retryMs = 2000;
79
+ console.log(`[openclaw-pincer] Room WS connected: ${roomId}`);
80
+ });
81
+
82
+ ws.on("message", (data: WebSocket.Data) => {
83
+ try {
84
+ const msg = JSON.parse(data.toString());
85
+ if (msg.type !== "room.message") return;
86
+ const payload = msg.data ?? msg.payload ?? {};
87
+ const sender = payload.sender_agent_id ?? "unknown";
88
+ const content = payload.content ?? "";
89
+ if (sender === config.agentId || !content) return;
90
+ // Mention-only: respond only when @agentName or @all
91
+ const agentName = config.agentName ?? "";
92
+ const isMentioned = agentName && content.includes(`@${agentName}`);
93
+ const isBroadcast = content.includes("@all") || content.includes("@所有人");
94
+ if (!isMentioned && !isBroadcast) return;
95
+ const runtime = ctx.channelRuntime;
96
+ if (!runtime) return;
97
+ dispatchToAgent(config, ctx, runtime, sender, content, roomId);
98
+ } catch { /* ignore */ }
99
+ });
100
+
101
+ ws.on("close", () => {
102
+ if (signal.aborted) return;
103
+ setTimeout(connect, retryMs);
104
+ retryMs = Math.min(retryMs * 1.5, 30000);
105
+ });
106
+
107
+ ws.on("error", () => ws.close());
108
+ signal.addEventListener("abort", () => ws.close(), { once: true });
109
+ }
110
+
111
+ connect();
112
+ }
113
+
114
+ // Subscribe to all project rooms, refresh every PROJECT_REFRESH_INTERVAL ms
115
+ function startProjectRoomSubscriptions(params: {
116
+ config: PincerConfig;
117
+ ctx: any;
118
+ signal: AbortSignal;
119
+ }): void {
120
+ const { config, ctx, signal } = params;
121
+ const PROJECT_REFRESH_MS = 60_000;
122
+ const subscribedRooms = new Set<string>();
123
+
124
+ async function refresh() {
125
+ if (signal.aborted) return;
126
+ const rooms = await fetchProjectRooms(config);
127
+ for (const roomId of rooms) {
128
+ if (!subscribedRooms.has(roomId)) {
129
+ subscribedRooms.add(roomId);
130
+ connectRoomWs({ config, ctx, roomId, signal });
131
+ console.log(`[openclaw-pincer] Subscribed to project room: ${roomId}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ refresh();
137
+ const timer = setInterval(refresh, PROJECT_REFRESH_MS);
138
+ signal.addEventListener("abort", () => clearInterval(timer), { once: true });
139
+ }
140
+
46
141
  function connectWs(params: {
47
142
  config: PincerConfig;
48
143
  ctx: any;
@@ -182,6 +277,11 @@ function handleMessage(config: PincerConfig, ctx: any, msg: any) {
182
277
  const content = data.content ?? "";
183
278
  const roomId = data.room_id ?? msg.room_id ?? "";
184
279
  if (sender === config.agentId || !content) return;
280
+ // Mention-only: only respond when @agentName or @all (prevents echo loops)
281
+ const agentName = config.agentName ?? "";
282
+ const isMentioned = agentName && content.includes(`@${agentName}`);
283
+ const isBroadcast = content.includes("@all") || content.includes("@所有人");
284
+ if (!isMentioned && !isBroadcast) return;
185
285
  console.log(`[openclaw-pincer] 💬 Room msg from ${sender.slice(0, 8)}: ${content.slice(0, 60)}`);
186
286
  dispatchToAgent(config, ctx, runtime, sender, content, roomId);
187
287
  return;
@@ -295,8 +395,13 @@ export const pincerChannel = {
295
395
  if (!config.agentName) config.agentName = "openclaw-agent";
296
396
 
297
397
  const signal: AbortSignal = ctx.abortSignal;
398
+
399
+ // Connect to agent DM hub
298
400
  connectWs({ config, ctx, signal });
299
401
 
402
+ // Subscribe to all project rooms (and refresh every 60s for new projects)
403
+ startProjectRoomSubscriptions({ config, ctx, signal });
404
+
300
405
  console.log("[openclaw-pincer] Started, connecting via WebSocket");
301
406
 
302
407
  await new Promise<void>((resolve) => {