openclaw-pincer 0.2.7 → 0.3.0

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 CHANGED
@@ -1,49 +1,42 @@
1
1
  # openclaw-pincer
2
2
 
3
- Pincer channel plugin for OpenClaw — connects agents to [Pincer](https://github.com/claw-works/pincer) rooms and DMs.
4
-
5
- Replaces the `daemon.py` polling approach with a proper OpenClaw channel plugin.
3
+ OpenClaw channel plugin for [Pincer](https://github.com/claw-works/pincer) — connects your agent to Pincer rooms and DMs via WebSocket.
6
4
 
7
5
  ## Install
8
6
 
9
7
  ```bash
10
- openclaw plugins install claw-works/openclaw-pincer
11
- # or from local path:
12
- openclaw plugins install ./openclaw-pincer
8
+ openclaw plugin install openclaw-pincer
13
9
  ```
14
10
 
15
11
  ## Configure
16
12
 
17
- Add to your `~/.openclaw/openclaw.json`:
13
+ Edit `~/.openclaw/openclaw.json`:
18
14
 
19
15
  ```json
20
16
  {
17
+ "plugins": {
18
+ "allow": ["openclaw-pincer"],
19
+ "entries": {
20
+ "openclaw-pincer": { "enabled": true }
21
+ }
22
+ },
21
23
  "channels": {
22
- "pincer": {
23
- "baseUrl": "https://your-pincer-server.example.com",
24
- "apiKey": "your-api-key",
25
- "agentId": "your-agent-uuid",
26
- "rooms": ["room-uuid-1", "room-uuid-2"],
27
- "pollMs": 2000
24
+ "openclaw-pincer": {
25
+ "baseUrl": "https://your-pincer-server.com",
26
+ "token": "your-api-token"
28
27
  }
29
28
  }
30
29
  }
31
30
  ```
32
31
 
33
- Restart OpenClaw once after installing the plugin. Config changes (token, rooms) hot-reload without restart.
34
-
35
- ## How it works
36
-
37
- - **Inbound**: polls `GET /rooms/{roomId}/messages?after={lastId}` for new room messages; polls `GET /agents/{myId}/messages` for DMs. Injects into OpenClaw session via `api.injectMessage()`.
38
- - **Outbound**: OpenClaw calls `api.registerSend()` to deliver agent replies back to Pincer rooms or DMs.
32
+ `token` is the API key you registered on your Pincer server.
39
33
 
40
- Session keys:
41
- - Room: `pincer:channel:{roomId}`
42
- - DM: `pincer:dm:{peerId}`
34
+ Restart OpenClaw after installing. Config changes (token, baseUrl) hot-reload without restart.
43
35
 
44
- ## Migration from daemon.py
36
+ ## How it works
45
37
 
46
- Once the channel plugin is stable (running for ~1 week), remove `daemon.py` and the polling loop from `skill-pincer`. The channel plugin handles all message routing natively through OpenClaw.
38
+ - **Inbound**: WebSocket connection to `wss://<host>/api/v1/ws?token=<token>` receives server-pushed messages in real time.
39
+ - **Outbound**: Agent replies are sent via HTTP POST to the Pincer API.
47
40
 
48
41
  ## License
49
42
 
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { pincerChannel } from "./src/channel.js";
2
2
  const plugin = {
3
- id: "pincer",
3
+ id: "openclaw-pincer",
4
4
  name: "Pincer",
5
- description: "Pincer channel plugin — rooms and DMs for OpenClaw agents",
5
+ description: "Pincer channel plugin — WebSocket connection for OpenClaw agents",
6
6
  register(api) {
7
7
  api.registerChannel(pincerChannel);
8
8
  },
@@ -1,17 +1,9 @@
1
1
  /**
2
- * channel.ts — Pincer channel plugin core
3
- *
4
- * Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
2
+ * channel.ts — Pincer channel plugin (WebSocket inbound, HTTP outbound)
5
3
  */
6
4
  export interface PincerConfig {
7
5
  baseUrl: string;
8
- apiKey: string;
9
- agentId: string;
10
- agentName?: string;
11
- rooms?: string[];
12
- pollMs?: number;
13
- requireMention?: boolean;
14
- historyLimit?: number;
6
+ token: string;
15
7
  }
16
8
  export declare const pincerChannel: {
17
9
  id: string;
@@ -30,12 +22,6 @@ export declare const pincerChannel: {
30
22
  reactions: boolean;
31
23
  threads: boolean;
32
24
  };
33
- agentPrompt: {
34
- messageToolHints: () => string[];
35
- };
36
- groups: {
37
- resolveRequireMention: (params: any) => boolean;
38
- };
39
25
  config: {
40
26
  listAccountIds: (cfg: any) => string[];
41
27
  resolveAccount: (_cfg: any, accountId: string) => {
@@ -1,220 +1,112 @@
1
1
  /**
2
- * channel.ts — Pincer channel plugin core
3
- *
4
- * Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
2
+ * channel.ts — Pincer channel plugin (WebSocket inbound, HTTP outbound)
5
3
  */
4
+ import WebSocket from "ws";
6
5
  function resolveConfig(cfg) {
7
- return (cfg?.channels?.pincer ?? {});
6
+ return (cfg?.channels?.["openclaw-pincer"] ?? {});
8
7
  }
9
- async function pincerFetch(baseUrl, apiKey, path, options = {}) {
10
- const url = `${baseUrl.replace(/\/$/, "")}/api/v1${path}`;
11
- const res = await fetch(url, {
12
- ...options,
13
- headers: {
14
- "X-API-Key": apiKey,
15
- "Content-Type": "application/json",
16
- ...(options.headers ?? {}),
17
- },
18
- });
19
- if (!res.ok) {
20
- throw new Error(`Pincer API error ${res.status}: ${await res.text()}`);
21
- }
22
- return res.json();
8
+ function wsUrl(config) {
9
+ const base = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
10
+ return `${base}/api/v1/ws?token=${config.token}`;
23
11
  }
24
- async function sendToPincerRoom(config, roomId, agentId, text) {
25
- await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages`, {
26
- method: "POST",
27
- body: JSON.stringify({ sender_agent_id: agentId, content: text }),
28
- });
29
- }
30
- async function sendToPincerDm(config, peerId, text) {
31
- await pincerFetch(config.baseUrl, config.apiKey, "/messages/send", {
12
+ async function httpPost(config, path, body) {
13
+ const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
14
+ const res = await fetch(url, {
32
15
  method: "POST",
33
- body: JSON.stringify({
34
- from_agent_id: config.agentId,
35
- to_agent_id: peerId,
36
- payload: { text },
37
- }),
16
+ headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
17
+ body: JSON.stringify(body),
38
18
  });
19
+ if (!res.ok)
20
+ throw new Error(`Pincer POST ${path} ${res.status}`);
39
21
  }
40
- /** Check if a room message mentions this agent (by agentId or agentName). */
41
- function isMentioned(text, config) {
42
- if (text.includes(config.agentId))
43
- return true;
44
- if (config.agentName && text.includes(config.agentName))
45
- return true;
46
- return false;
47
- }
48
- /** Fetch recent room messages to use as conversation history. */
49
- async function fetchRoomHistory(config, roomId, limit) {
50
- try {
51
- const msgs = await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages?limit=${limit}`);
52
- return msgs.map((m) => ({
53
- sender: m.sender_agent_id ?? "unknown",
54
- body: m.content ?? "",
55
- timestamp: m.created_at ? new Date(m.created_at).getTime() : undefined,
56
- }));
57
- }
58
- catch {
59
- return [];
60
- }
61
- }
62
- function startRoomPoller(params) {
63
- const { config, roomId, ctx, signal, pollMs } = params;
64
- const requireMention = config.requireMention !== false; // default true
65
- const historyLimit = config.historyLimit ?? 10;
66
- let lastId = null;
67
- const poll = async () => {
22
+ function connectWs(params) {
23
+ const { config, ctx, signal } = params;
24
+ let retryMs = 1000;
25
+ function connect() {
68
26
  if (signal.aborted)
69
27
  return;
70
- try {
71
- const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
72
- const msgs = await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages${query}`);
73
- // On first poll, just record the latest ID to avoid replaying history
74
- if (lastId === null) {
75
- if (msgs.length > 0)
76
- lastId = msgs[msgs.length - 1].id;
77
- return;
78
- }
79
- const channelRuntime = ctx.channelRuntime;
80
- for (const msg of msgs) {
81
- // Skip own messages
82
- if (msg.sender_agent_id === config.agentId) {
83
- lastId = msg.id;
84
- continue;
85
- }
86
- if (!channelRuntime) {
87
- console.warn("[pincer] channelRuntime not available, skipping room message dispatch");
88
- lastId = msg.id;
89
- continue;
90
- }
91
- const messageText = msg.content ?? "";
92
- // Require mention in group rooms (default: on)
93
- if (requireMention && !isMentioned(messageText, config)) {
94
- lastId = msg.id;
95
- continue;
96
- }
97
- const senderId = msg.sender_agent_id ?? "unknown";
98
- // Fetch recent history for context
99
- const history = await fetchRoomHistory(config, roomId, historyLimit);
100
- const route = channelRuntime.routing.resolveAgentRoute({
101
- cfg: ctx.cfg,
102
- channel: "pincer",
103
- accountId: ctx.accountId,
104
- peer: { kind: "group", id: roomId },
105
- });
106
- await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
107
- ctx: {
108
- Body: messageText,
109
- BodyForAgent: messageText,
110
- From: senderId,
111
- SessionKey: route.sessionKey,
112
- Channel: "pincer",
113
- AccountId: ctx.accountId,
114
- InboundHistory: history,
115
- },
116
- cfg: ctx.cfg,
117
- dispatcherOptions: {
118
- deliver: async (payload) => {
119
- await sendToPincerRoom(config, roomId, config.agentId, payload.text);
120
- },
121
- },
122
- replyOptions: {
123
- extraSystemPrompt: [
124
- "You are in a Pincer agent room (group chat). Rules:",
125
- "- Only respond when directly mentioned or asked a question.",
126
- "- Keep responses concise and on-topic.",
127
- "- Do NOT engage in idle chit-chat or filler responses.",
128
- "- Do NOT respond to every message — quality over quantity.",
129
- ].join("\n"),
130
- },
131
- });
132
- lastId = msg.id;
28
+ const ws = new WebSocket(wsUrl(config));
29
+ ws.on("open", () => {
30
+ retryMs = 1000;
31
+ console.log("[openclaw-pincer] WebSocket connected");
32
+ });
33
+ ws.on("message", (data) => {
34
+ try {
35
+ const msg = JSON.parse(data.toString());
36
+ handleMessage(config, ctx, msg);
133
37
  }
134
- }
135
- catch (err) {
136
- if (!signal.aborted) {
137
- console.error(`[pincer] room ${roomId} poll error:`, err?.message);
138
- }
139
- }
140
- };
141
- const interval = setInterval(poll, pollMs);
142
- signal.addEventListener("abort", () => clearInterval(interval));
143
- poll();
144
- }
145
- function startDmPoller(params) {
146
- const { config, ctx, signal, pollMs } = params;
147
- let lastId = null;
148
- let initialized = false;
149
- const poll = async () => {
150
- if (signal.aborted)
151
- return;
152
- try {
153
- const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
154
- const msgs = await pincerFetch(config.baseUrl, config.apiKey, `/agents/${config.agentId}/messages${query}`);
155
- if (!initialized) {
156
- initialized = true;
157
- if (msgs.length > 0)
158
- lastId = msgs[msgs.length - 1].id;
38
+ catch { }
39
+ });
40
+ ws.on("close", () => {
41
+ if (signal.aborted)
159
42
  return;
160
- }
161
- const channelRuntime = ctx.channelRuntime;
162
- for (const msg of msgs) {
163
- if (msg.from_agent_id === config.agentId) {
164
- lastId = msg.id;
165
- continue;
43
+ console.log(`[openclaw-pincer] WS closed, reconnecting in ${retryMs}ms`);
44
+ setTimeout(connect, retryMs);
45
+ retryMs = Math.min(retryMs * 2, 30000);
46
+ });
47
+ ws.on("error", (err) => {
48
+ if (!signal.aborted)
49
+ console.error("[openclaw-pincer] WS error:", err.message);
50
+ ws.close();
51
+ });
52
+ signal.addEventListener("abort", () => ws.close(), { once: true });
53
+ }
54
+ connect();
55
+ }
56
+ function handleMessage(config, ctx, msg) {
57
+ const runtime = ctx.channelRuntime;
58
+ if (!runtime)
59
+ return;
60
+ const sessionKey = msg.room_id
61
+ ? `openclaw-pincer:channel:${msg.room_id}`
62
+ : `openclaw-pincer:dm:${msg.from_agent_id ?? msg.sender_agent_id ?? "unknown"}`;
63
+ const senderId = msg.from_agent_id ?? msg.sender_agent_id ?? "unknown";
64
+ const text = msg.content ?? msg.payload?.text ?? "";
65
+ if (!text)
66
+ return;
67
+ const peer = msg.room_id
68
+ ? { kind: "group", id: msg.room_id }
69
+ : { kind: "direct", id: senderId };
70
+ const route = runtime.routing.resolveAgentRoute({
71
+ cfg: ctx.cfg,
72
+ channel: "openclaw-pincer",
73
+ accountId: ctx.accountId,
74
+ peer,
75
+ });
76
+ runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
77
+ ctx: {
78
+ Body: text,
79
+ BodyForAgent: text,
80
+ From: senderId,
81
+ SessionKey: route.sessionKey,
82
+ Channel: "openclaw-pincer",
83
+ AccountId: ctx.accountId,
84
+ },
85
+ cfg: ctx.cfg,
86
+ dispatcherOptions: {
87
+ deliver: async (payload) => {
88
+ if (msg.room_id) {
89
+ await httpPost(config, `/rooms/${msg.room_id}/messages`, { content: payload.text });
166
90
  }
167
- if (!channelRuntime) {
168
- console.warn("[pincer] channelRuntime not available, skipping DM dispatch");
169
- lastId = msg.id;
170
- continue;
91
+ else {
92
+ await httpPost(config, "/messages/send", {
93
+ to_agent_id: senderId,
94
+ payload: { text: payload.text },
95
+ });
171
96
  }
172
- const peerId = msg.from_agent_id ?? "unknown";
173
- const messageText = msg.payload?.text ?? "";
174
- const route = channelRuntime.routing.resolveAgentRoute({
175
- cfg: ctx.cfg,
176
- channel: "pincer",
177
- accountId: ctx.accountId,
178
- peer: { kind: "direct", id: peerId },
179
- });
180
- await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
181
- ctx: {
182
- Body: messageText,
183
- BodyForAgent: messageText,
184
- From: peerId,
185
- SessionKey: route.sessionKey,
186
- Channel: "pincer",
187
- AccountId: ctx.accountId,
188
- },
189
- cfg: ctx.cfg,
190
- dispatcherOptions: {
191
- deliver: async (payload) => {
192
- await sendToPincerDm(config, peerId, payload.text);
193
- },
194
- },
195
- });
196
- lastId = msg.id;
197
- }
198
- }
199
- catch (err) {
200
- if (!signal.aborted) {
201
- console.error("[pincer] DM poll error:", err?.message);
202
- }
203
- }
204
- };
205
- const interval = setInterval(poll, pollMs * 2); // DM poll at half rate
206
- signal.addEventListener("abort", () => clearInterval(interval));
207
- poll();
97
+ },
98
+ },
99
+ });
208
100
  }
209
101
  export const pincerChannel = {
210
- id: "pincer",
102
+ id: "openclaw-pincer",
211
103
  meta: {
212
- id: "pincer",
104
+ id: "openclaw-pincer",
213
105
  label: "Pincer",
214
106
  selectionLabel: "Pincer (agent hub)",
215
107
  docsPath: "/channels/pincer",
216
108
  docsLabel: "pincer",
217
- blurb: "Pincer agent hub — rooms and DMs.",
109
+ blurb: "Pincer agent hub — WebSocket connection.",
218
110
  order: 80,
219
111
  },
220
112
  capabilities: {
@@ -223,48 +115,26 @@ export const pincerChannel = {
223
115
  reactions: false,
224
116
  threads: false,
225
117
  },
226
- agentPrompt: {
227
- messageToolHints: () => [
228
- "- In Pincer rooms, only respond when @mentioned or directly asked. No idle chit-chat.",
229
- "- In Pincer DMs, respond normally.",
230
- ],
231
- },
232
- groups: {
233
- resolveRequireMention: (params) => {
234
- const config = resolveConfig(params.cfg);
235
- return config.requireMention !== false; // default true
236
- },
237
- },
238
118
  config: {
239
119
  listAccountIds: (cfg) => {
240
- const config = resolveConfig(cfg);
241
- if (!config.agentId)
242
- return [];
243
- return [config.agentId];
244
- },
245
- resolveAccount: (_cfg, accountId) => {
246
- return { accountId };
120
+ const c = resolveConfig(cfg);
121
+ return c.baseUrl && c.token ? ["default"] : [];
247
122
  },
123
+ resolveAccount: (_cfg, accountId) => ({ accountId }),
248
124
  },
249
125
  gateway: {
250
126
  startAccount: async (ctx) => {
251
127
  const config = resolveConfig(ctx.cfg);
252
- if (!config.baseUrl || !config.apiKey || !config.agentId) {
253
- console.warn("[pincer] Missing required config (baseUrl, apiKey, agentId). Channel not started.");
128
+ if (!config.baseUrl || !config.token) {
129
+ console.warn("[openclaw-pincer] Missing baseUrl or token. Channel not started.");
254
130
  return;
255
131
  }
256
132
  const signal = ctx.abortSignal;
257
- const pollMs = config.pollMs ?? 15000;
258
- for (const roomId of config.rooms ?? []) {
259
- startRoomPoller({ config, roomId, ctx, signal, pollMs });
260
- }
261
- startDmPoller({ config, ctx, signal, pollMs });
262
- console.log(`[pincer] Started. requireMention=${config.requireMention !== false}. Monitoring ${(config.rooms ?? []).length} room(s) + DMs as agent ${config.agentId}`);
133
+ connectWs({ config, ctx, signal });
134
+ console.log("[openclaw-pincer] Started, connecting via WebSocket");
263
135
  await new Promise((resolve) => {
264
- if (signal.aborted) {
265
- resolve();
266
- return;
267
- }
136
+ if (signal.aborted)
137
+ return resolve();
268
138
  signal.addEventListener("abort", () => resolve(), { once: true });
269
139
  });
270
140
  },
@@ -275,11 +145,13 @@ export const pincerChannel = {
275
145
  const config = resolveConfig(ctx.cfg);
276
146
  const to = ctx.to ?? "";
277
147
  if (to.startsWith("room:")) {
278
- const roomId = to.slice("room:".length);
279
- await sendToPincerRoom(config, roomId, config.agentId, ctx.text);
148
+ await httpPost(config, `/rooms/${to.slice(5)}/messages`, { content: ctx.text });
280
149
  }
281
150
  else {
282
- await sendToPincerDm(config, to, ctx.text);
151
+ await httpPost(config, "/messages/send", {
152
+ to_agent_id: to,
153
+ payload: { text: ctx.text },
154
+ });
283
155
  }
284
156
  return { ok: true };
285
157
  },
package/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { pincerChannel } from "./src/channel.js";
2
2
 
3
3
  const plugin = {
4
- id: "pincer",
4
+ id: "openclaw-pincer",
5
5
  name: "Pincer",
6
- description: "Pincer channel plugin — rooms and DMs for OpenClaw agents",
6
+ description: "Pincer channel plugin — WebSocket connection for OpenClaw agents",
7
7
  register(api: any) {
8
8
  api.registerChannel(pincerChannel);
9
9
  },
@@ -1,18 +1,15 @@
1
1
  {
2
- "id": "pincer",
2
+ "id": "openclaw-pincer",
3
3
  "name": "Pincer",
4
- "description": "Pincer channel plugin for OpenClaw — connects agents to Pincer rooms and DMs",
5
- "version": "0.2.7",
6
- "channels": ["pincer"],
4
+ "description": "Pincer channel plugin for OpenClaw — connects agents to Pincer via WebSocket",
5
+ "version": "0.3.0",
6
+ "channels": ["openclaw-pincer"],
7
7
  "configSchema": {
8
8
  "type": "object",
9
9
  "additionalProperties": false,
10
10
  "properties": {
11
11
  "baseUrl": { "type": "string" },
12
- "apiKey": { "type": "string" },
13
- "agentId": { "type": "string" },
14
- "rooms": { "type": "array", "items": { "type": "string" } },
15
- "pollMs": { "type": "number" }
12
+ "token": { "type": "string" }
16
13
  }
17
14
  }
18
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-pincer",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
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
@@ -1,304 +1,133 @@
1
1
  /**
2
- * channel.ts — Pincer channel plugin core
3
- *
4
- * Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
2
+ * channel.ts — Pincer channel plugin (WebSocket inbound, HTTP outbound)
5
3
  */
6
4
 
5
+ import WebSocket from "ws";
6
+
7
7
  export interface PincerConfig {
8
8
  baseUrl: string;
9
- apiKey: string;
10
- agentId: string;
11
- agentName?: string; // display name for mention detection (e.g. "蔻儿")
12
- rooms?: string[];
13
- pollMs?: number;
14
- requireMention?: boolean; // default: true — only respond in rooms when @mentioned
15
- historyLimit?: number; // how many messages to include as context (default: 10)
16
- }
17
-
18
- interface PincerMessage {
19
- id: string;
20
- room_id?: string;
21
- sender_agent_id?: string;
22
- from_agent_id?: string;
23
- to_agent_id?: string;
24
- content?: string;
25
- payload?: { text: string };
26
- created_at: string;
9
+ token: string;
27
10
  }
28
11
 
29
12
  function resolveConfig(cfg: any): PincerConfig {
30
- return (cfg?.channels?.pincer ?? {}) as PincerConfig;
13
+ return (cfg?.channels?.["openclaw-pincer"] ?? {}) as PincerConfig;
31
14
  }
32
15
 
33
- async function pincerFetch(
34
- baseUrl: string,
35
- apiKey: string,
36
- path: string,
37
- options: RequestInit = {}
38
- ): Promise<any> {
39
- const url = `${baseUrl.replace(/\/$/, "")}/api/v1${path}`;
40
- const res = await fetch(url, {
41
- ...options,
42
- headers: {
43
- "X-API-Key": apiKey,
44
- "Content-Type": "application/json",
45
- ...(options.headers ?? {}),
46
- },
47
- });
48
- if (!res.ok) {
49
- throw new Error(`Pincer API error ${res.status}: ${await res.text()}`);
50
- }
51
- return res.json();
16
+ function wsUrl(config: PincerConfig): string {
17
+ const base = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
18
+ return `${base}/api/v1/ws?token=${config.token}`;
52
19
  }
53
20
 
54
- async function sendToPincerRoom(
55
- config: PincerConfig,
56
- roomId: string,
57
- agentId: string,
58
- text: string
59
- ): Promise<void> {
60
- await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages`, {
61
- method: "POST",
62
- body: JSON.stringify({ sender_agent_id: agentId, content: text }),
63
- });
64
- }
65
-
66
- async function sendToPincerDm(
67
- config: PincerConfig,
68
- peerId: string,
69
- text: string
70
- ): Promise<void> {
71
- await pincerFetch(config.baseUrl, config.apiKey, "/messages/send", {
21
+ async function httpPost(config: PincerConfig, path: string, body: any): Promise<void> {
22
+ const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
23
+ const res = await fetch(url, {
72
24
  method: "POST",
73
- body: JSON.stringify({
74
- from_agent_id: config.agentId,
75
- to_agent_id: peerId,
76
- payload: { text },
77
- }),
25
+ headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
26
+ body: JSON.stringify(body),
78
27
  });
28
+ if (!res.ok) throw new Error(`Pincer POST ${path} ${res.status}`);
79
29
  }
80
30
 
81
- /** Check if a room message mentions this agent (by agentId or agentName). */
82
- function isMentioned(text: string, config: PincerConfig): boolean {
83
- if (text.includes(config.agentId)) return true;
84
- if (config.agentName && text.includes(config.agentName)) return true;
85
- return false;
86
- }
87
-
88
- /** Fetch recent room messages to use as conversation history. */
89
- async function fetchRoomHistory(
90
- config: PincerConfig,
91
- roomId: string,
92
- limit: number
93
- ): Promise<Array<{ sender: string; body: string; timestamp?: number }>> {
94
- try {
95
- const msgs: PincerMessage[] = await pincerFetch(
96
- config.baseUrl,
97
- config.apiKey,
98
- `/rooms/${roomId}/messages?limit=${limit}`
99
- );
100
- return msgs.map((m) => ({
101
- sender: m.sender_agent_id ?? "unknown",
102
- body: m.content ?? "",
103
- timestamp: m.created_at ? new Date(m.created_at).getTime() : undefined,
104
- }));
105
- } catch {
106
- return [];
107
- }
108
- }
109
-
110
- function startRoomPoller(params: {
31
+ function connectWs(params: {
111
32
  config: PincerConfig;
112
- roomId: string;
113
33
  ctx: any;
114
34
  signal: AbortSignal;
115
- pollMs: number;
116
35
  }) {
117
- const { config, roomId, ctx, signal, pollMs } = params;
118
- const requireMention = config.requireMention !== false; // default true
119
- const historyLimit = config.historyLimit ?? 10;
120
- let lastId: string | null = null;
36
+ const { config, ctx, signal } = params;
37
+ let retryMs = 1000;
121
38
 
122
- const poll = async () => {
39
+ function connect() {
123
40
  if (signal.aborted) return;
124
- try {
125
- const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
126
- const msgs: PincerMessage[] = await pincerFetch(
127
- config.baseUrl,
128
- config.apiKey,
129
- `/rooms/${roomId}/messages${query}`
130
- );
131
-
132
- // On first poll, just record the latest ID to avoid replaying history
133
- if (lastId === null) {
134
- if (msgs.length > 0) lastId = msgs[msgs.length - 1].id;
135
- return;
136
- }
137
-
138
- const channelRuntime = ctx.channelRuntime;
139
- for (const msg of msgs) {
140
- // Skip own messages
141
- if (msg.sender_agent_id === config.agentId) {
142
- lastId = msg.id;
143
- continue;
144
- }
145
-
146
- if (!channelRuntime) {
147
- console.warn("[pincer] channelRuntime not available, skipping room message dispatch");
148
- lastId = msg.id;
149
- continue;
150
- }
151
-
152
- const messageText = msg.content ?? "";
153
-
154
- // Require mention in group rooms (default: on)
155
- if (requireMention && !isMentioned(messageText, config)) {
156
- lastId = msg.id;
157
- continue;
158
- }
159
-
160
- const senderId = msg.sender_agent_id ?? "unknown";
161
-
162
- // Fetch recent history for context
163
- const history = await fetchRoomHistory(config, roomId, historyLimit);
164
-
165
- const route = channelRuntime.routing.resolveAgentRoute({
166
- cfg: ctx.cfg,
167
- channel: "pincer",
168
- accountId: ctx.accountId,
169
- peer: { kind: "group", id: roomId },
170
- });
171
-
172
- await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
173
- ctx: {
174
- Body: messageText,
175
- BodyForAgent: messageText,
176
- From: senderId,
177
- SessionKey: route.sessionKey,
178
- Channel: "pincer",
179
- AccountId: ctx.accountId,
180
- InboundHistory: history,
181
- },
182
- cfg: ctx.cfg,
183
- dispatcherOptions: {
184
- deliver: async (payload: any) => {
185
- await sendToPincerRoom(config, roomId, config.agentId, payload.text);
186
- },
187
- },
188
- replyOptions: {
189
- extraSystemPrompt: [
190
- "You are in a Pincer agent room (group chat). Rules:",
191
- "- Only respond when directly mentioned or asked a question.",
192
- "- Keep responses concise and on-topic.",
193
- "- Do NOT engage in idle chit-chat or filler responses.",
194
- "- Do NOT respond to every message — quality over quantity.",
195
- ].join("\n"),
196
- } as any,
197
- });
198
41
 
199
- lastId = msg.id;
200
- }
201
- } catch (err: any) {
202
- if (!signal.aborted) {
203
- console.error(`[pincer] room ${roomId} poll error:`, err?.message);
204
- }
205
- }
206
- };
42
+ const ws = new WebSocket(wsUrl(config));
43
+
44
+ ws.on("open", () => {
45
+ retryMs = 1000;
46
+ console.log("[openclaw-pincer] WebSocket connected");
47
+ });
48
+
49
+ ws.on("message", (data: WebSocket.Data) => {
50
+ try {
51
+ const msg = JSON.parse(data.toString());
52
+ handleMessage(config, ctx, msg);
53
+ } catch {}
54
+ });
55
+
56
+ ws.on("close", () => {
57
+ if (signal.aborted) return;
58
+ console.log(`[openclaw-pincer] WS closed, reconnecting in ${retryMs}ms`);
59
+ setTimeout(connect, retryMs);
60
+ retryMs = Math.min(retryMs * 2, 30000);
61
+ });
62
+
63
+ ws.on("error", (err: Error) => {
64
+ if (!signal.aborted) console.error("[openclaw-pincer] WS error:", err.message);
65
+ ws.close();
66
+ });
67
+
68
+ signal.addEventListener("abort", () => ws.close(), { once: true });
69
+ }
207
70
 
208
- const interval = setInterval(poll, pollMs);
209
- signal.addEventListener("abort", () => clearInterval(interval));
210
- poll();
71
+ connect();
211
72
  }
212
73
 
213
- function startDmPoller(params: {
214
- config: PincerConfig;
215
- ctx: any;
216
- signal: AbortSignal;
217
- pollMs: number;
218
- }) {
219
- const { config, ctx, signal, pollMs } = params;
220
- let lastId: string | null = null;
221
- let initialized = false;
74
+ function handleMessage(config: PincerConfig, ctx: any, msg: any) {
75
+ const runtime = ctx.channelRuntime;
76
+ if (!runtime) return;
222
77
 
223
- const poll = async () => {
224
- if (signal.aborted) return;
225
- try {
226
- const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
227
- const msgs: PincerMessage[] = await pincerFetch(
228
- config.baseUrl,
229
- config.apiKey,
230
- `/agents/${config.agentId}/messages${query}`
231
- );
78
+ const sessionKey = msg.room_id
79
+ ? `openclaw-pincer:channel:${msg.room_id}`
80
+ : `openclaw-pincer:dm:${msg.from_agent_id ?? msg.sender_agent_id ?? "unknown"}`;
232
81
 
233
- if (!initialized) {
234
- initialized = true;
235
- if (msgs.length > 0) lastId = msgs[msgs.length - 1].id;
236
- return;
237
- }
238
-
239
- const channelRuntime = ctx.channelRuntime;
240
- for (const msg of msgs) {
241
- if (msg.from_agent_id === config.agentId) {
242
- lastId = msg.id;
243
- continue;
244
- }
245
-
246
- if (!channelRuntime) {
247
- console.warn("[pincer] channelRuntime not available, skipping DM dispatch");
248
- lastId = msg.id;
249
- continue;
250
- }
251
-
252
- const peerId = msg.from_agent_id ?? "unknown";
253
- const messageText = msg.payload?.text ?? "";
82
+ const senderId = msg.from_agent_id ?? msg.sender_agent_id ?? "unknown";
83
+ const text = msg.content ?? msg.payload?.text ?? "";
84
+ if (!text) return;
254
85
 
255
- const route = channelRuntime.routing.resolveAgentRoute({
256
- cfg: ctx.cfg,
257
- channel: "pincer",
258
- accountId: ctx.accountId,
259
- peer: { kind: "direct", id: peerId },
260
- });
86
+ const peer = msg.room_id
87
+ ? { kind: "group" as const, id: msg.room_id }
88
+ : { kind: "direct" as const, id: senderId };
261
89
 
262
- await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
263
- ctx: {
264
- Body: messageText,
265
- BodyForAgent: messageText,
266
- From: peerId,
267
- SessionKey: route.sessionKey,
268
- Channel: "pincer",
269
- AccountId: ctx.accountId,
270
- },
271
- cfg: ctx.cfg,
272
- dispatcherOptions: {
273
- deliver: async (payload: any) => {
274
- await sendToPincerDm(config, peerId, payload.text);
275
- },
276
- },
277
- });
278
-
279
- lastId = msg.id;
280
- }
281
- } catch (err: any) {
282
- if (!signal.aborted) {
283
- console.error("[pincer] DM poll error:", err?.message);
284
- }
285
- }
286
- };
90
+ const route = runtime.routing.resolveAgentRoute({
91
+ cfg: ctx.cfg,
92
+ channel: "openclaw-pincer",
93
+ accountId: ctx.accountId,
94
+ peer,
95
+ });
287
96
 
288
- const interval = setInterval(poll, pollMs * 2); // DM poll at half rate
289
- signal.addEventListener("abort", () => clearInterval(interval));
290
- poll();
97
+ runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
98
+ ctx: {
99
+ Body: text,
100
+ BodyForAgent: text,
101
+ From: senderId,
102
+ SessionKey: route.sessionKey,
103
+ Channel: "openclaw-pincer",
104
+ AccountId: ctx.accountId,
105
+ },
106
+ cfg: ctx.cfg,
107
+ dispatcherOptions: {
108
+ deliver: async (payload: any) => {
109
+ if (msg.room_id) {
110
+ await httpPost(config, `/rooms/${msg.room_id}/messages`, { content: payload.text });
111
+ } else {
112
+ await httpPost(config, "/messages/send", {
113
+ to_agent_id: senderId,
114
+ payload: { text: payload.text },
115
+ });
116
+ }
117
+ },
118
+ },
119
+ });
291
120
  }
292
121
 
293
122
  export const pincerChannel = {
294
- id: "pincer",
123
+ id: "openclaw-pincer",
295
124
  meta: {
296
- id: "pincer",
125
+ id: "openclaw-pincer",
297
126
  label: "Pincer",
298
127
  selectionLabel: "Pincer (agent hub)",
299
128
  docsPath: "/channels/pincer",
300
129
  docsLabel: "pincer",
301
- blurb: "Pincer agent hub — rooms and DMs.",
130
+ blurb: "Pincer agent hub — WebSocket connection.",
302
131
  order: 80,
303
132
  },
304
133
  capabilities: {
@@ -307,51 +136,28 @@ export const pincerChannel = {
307
136
  reactions: false,
308
137
  threads: false,
309
138
  },
310
- agentPrompt: {
311
- messageToolHints: () => [
312
- "- In Pincer rooms, only respond when @mentioned or directly asked. No idle chit-chat.",
313
- "- In Pincer DMs, respond normally.",
314
- ],
315
- },
316
- groups: {
317
- resolveRequireMention: (params: any) => {
318
- const config = resolveConfig(params.cfg);
319
- return config.requireMention !== false; // default true
320
- },
321
- },
322
139
  config: {
323
140
  listAccountIds: (cfg: any) => {
324
- const config = resolveConfig(cfg);
325
- if (!config.agentId) return [];
326
- return [config.agentId];
327
- },
328
- resolveAccount: (_cfg: any, accountId: string) => {
329
- return { accountId };
141
+ const c = resolveConfig(cfg);
142
+ return c.baseUrl && c.token ? ["default"] : [];
330
143
  },
144
+ resolveAccount: (_cfg: any, accountId: string) => ({ accountId }),
331
145
  },
332
146
  gateway: {
333
147
  startAccount: async (ctx: any) => {
334
148
  const config = resolveConfig(ctx.cfg);
335
- if (!config.baseUrl || !config.apiKey || !config.agentId) {
336
- console.warn("[pincer] Missing required config (baseUrl, apiKey, agentId). Channel not started.");
149
+ if (!config.baseUrl || !config.token) {
150
+ console.warn("[openclaw-pincer] Missing baseUrl or token. Channel not started.");
337
151
  return;
338
152
  }
339
153
 
340
154
  const signal: AbortSignal = ctx.abortSignal;
341
- const pollMs = config.pollMs ?? 15000;
155
+ connectWs({ config, ctx, signal });
342
156
 
343
- for (const roomId of config.rooms ?? []) {
344
- startRoomPoller({ config, roomId, ctx, signal, pollMs });
345
- }
346
-
347
- startDmPoller({ config, ctx, signal, pollMs });
348
-
349
- console.log(
350
- `[pincer] Started. requireMention=${config.requireMention !== false}. Monitoring ${(config.rooms ?? []).length} room(s) + DMs as agent ${config.agentId}`
351
- );
157
+ console.log("[openclaw-pincer] Started, connecting via WebSocket");
352
158
 
353
159
  await new Promise<void>((resolve) => {
354
- if (signal.aborted) { resolve(); return; }
160
+ if (signal.aborted) return resolve();
355
161
  signal.addEventListener("abort", () => resolve(), { once: true });
356
162
  });
357
163
  },
@@ -362,10 +168,12 @@ export const pincerChannel = {
362
168
  const config = resolveConfig(ctx.cfg);
363
169
  const to: string = ctx.to ?? "";
364
170
  if (to.startsWith("room:")) {
365
- const roomId = to.slice("room:".length);
366
- await sendToPincerRoom(config, roomId, config.agentId, ctx.text);
171
+ await httpPost(config, `/rooms/${to.slice(5)}/messages`, { content: ctx.text });
367
172
  } else {
368
- await sendToPincerDm(config, to, ctx.text);
173
+ await httpPost(config, "/messages/send", {
174
+ to_agent_id: to,
175
+ payload: { text: ctx.text },
176
+ });
369
177
  }
370
178
  return { ok: true };
371
179
  },