openclaw-pincer 0.2.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 +50 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/src/channel.d.ts +45 -0
- package/dist/src/channel.js +222 -0
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +7 -0
- package/index.ts +12 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +36 -0
- package/src/channel.ts +297 -0
- package/src/runtime.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# openclaw-pincer
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
openclaw plugins install claw-works/openclaw-pincer
|
|
11
|
+
# or from local path:
|
|
12
|
+
openclaw plugins install ./openclaw-pincer
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
Add to your `~/.openclaw/openclaw.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"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
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
Session keys:
|
|
41
|
+
- Room: `pincer:channel:{roomId}`
|
|
42
|
+
- DM: `pincer:dm:{peerId}`
|
|
43
|
+
|
|
44
|
+
## Migration from daemon.py
|
|
45
|
+
|
|
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.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { pincerChannel } from "./src/channel.js";
|
|
2
|
+
const plugin = {
|
|
3
|
+
id: "pincer",
|
|
4
|
+
name: "Pincer",
|
|
5
|
+
description: "Pincer channel plugin — rooms and DMs for OpenClaw agents",
|
|
6
|
+
register(api) {
|
|
7
|
+
api.registerChannel(pincerChannel);
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
export default plugin;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.ts — Pincer channel plugin core
|
|
3
|
+
*
|
|
4
|
+
* Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
|
|
5
|
+
*/
|
|
6
|
+
export interface PincerConfig {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
agentId: string;
|
|
10
|
+
rooms?: string[];
|
|
11
|
+
pollMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const pincerChannel: {
|
|
14
|
+
id: string;
|
|
15
|
+
meta: {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
selectionLabel: string;
|
|
19
|
+
docsPath: string;
|
|
20
|
+
docsLabel: string;
|
|
21
|
+
blurb: string;
|
|
22
|
+
order: number;
|
|
23
|
+
};
|
|
24
|
+
capabilities: {
|
|
25
|
+
chatTypes: string[];
|
|
26
|
+
media: boolean;
|
|
27
|
+
reactions: boolean;
|
|
28
|
+
threads: boolean;
|
|
29
|
+
};
|
|
30
|
+
config: {
|
|
31
|
+
listAccountIds: (cfg: any) => string[];
|
|
32
|
+
resolveAccount: (_cfg: any, accountId: string) => {
|
|
33
|
+
accountId: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
gateway: {
|
|
37
|
+
startAccount: (ctx: any) => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
outbound: {
|
|
40
|
+
deliveryMode: string;
|
|
41
|
+
sendText: (ctx: any) => Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.ts — Pincer channel plugin core
|
|
3
|
+
*
|
|
4
|
+
* Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
|
|
5
|
+
*/
|
|
6
|
+
function resolveConfig(cfg) {
|
|
7
|
+
return (cfg?.channels?.pincer ?? {});
|
|
8
|
+
}
|
|
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();
|
|
23
|
+
}
|
|
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, `/agents/${config.agentId}/messages`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({ to_agent_id: peerId, payload: { text } }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function startRoomPoller(params) {
|
|
37
|
+
const { config, roomId, ctx, signal, pollMs } = params;
|
|
38
|
+
let lastId = null;
|
|
39
|
+
const poll = async () => {
|
|
40
|
+
if (signal.aborted)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
|
|
44
|
+
const msgs = await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages${query}`);
|
|
45
|
+
// On first poll, just record the latest ID to avoid replaying history
|
|
46
|
+
if (lastId === null) {
|
|
47
|
+
if (msgs.length > 0)
|
|
48
|
+
lastId = msgs[msgs.length - 1].id;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const channelRuntime = ctx.channelRuntime;
|
|
52
|
+
for (const msg of msgs) {
|
|
53
|
+
if (msg.sender_agent_id === config.agentId) {
|
|
54
|
+
lastId = msg.id;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!channelRuntime) {
|
|
58
|
+
console.warn("[pincer] channelRuntime not available, skipping room message dispatch");
|
|
59
|
+
lastId = msg.id;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const messageText = msg.content ?? "";
|
|
63
|
+
const senderId = msg.sender_agent_id ?? "unknown";
|
|
64
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
65
|
+
cfg: ctx.cfg,
|
|
66
|
+
channel: "pincer",
|
|
67
|
+
accountId: ctx.accountId,
|
|
68
|
+
peer: { kind: "group", id: roomId },
|
|
69
|
+
});
|
|
70
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
71
|
+
ctx: {
|
|
72
|
+
Body: messageText,
|
|
73
|
+
BodyForAgent: messageText,
|
|
74
|
+
From: senderId,
|
|
75
|
+
SessionKey: route.sessionKey,
|
|
76
|
+
Channel: "pincer",
|
|
77
|
+
AccountId: ctx.accountId,
|
|
78
|
+
},
|
|
79
|
+
cfg: ctx.cfg,
|
|
80
|
+
dispatcherOptions: {
|
|
81
|
+
deliver: async (payload) => {
|
|
82
|
+
await sendToPincerRoom(config, roomId, config.agentId, payload.text);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
lastId = msg.id;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (!signal.aborted) {
|
|
91
|
+
console.error(`[pincer] room ${roomId} poll error:`, err?.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const interval = setInterval(poll, pollMs);
|
|
96
|
+
signal.addEventListener("abort", () => clearInterval(interval));
|
|
97
|
+
poll();
|
|
98
|
+
}
|
|
99
|
+
function startDmPoller(params) {
|
|
100
|
+
const { config, ctx, signal, pollMs } = params;
|
|
101
|
+
let lastId = null;
|
|
102
|
+
let initialized = false;
|
|
103
|
+
const poll = async () => {
|
|
104
|
+
if (signal.aborted)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
|
|
108
|
+
const msgs = await pincerFetch(config.baseUrl, config.apiKey, `/agents/${config.agentId}/messages${query}`);
|
|
109
|
+
if (!initialized) {
|
|
110
|
+
initialized = true;
|
|
111
|
+
if (msgs.length > 0)
|
|
112
|
+
lastId = msgs[msgs.length - 1].id;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const channelRuntime = ctx.channelRuntime;
|
|
116
|
+
for (const msg of msgs) {
|
|
117
|
+
if (msg.from_agent_id === config.agentId) {
|
|
118
|
+
lastId = msg.id;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!channelRuntime) {
|
|
122
|
+
console.warn("[pincer] channelRuntime not available, skipping DM dispatch");
|
|
123
|
+
lastId = msg.id;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const peerId = msg.from_agent_id ?? "unknown";
|
|
127
|
+
const messageText = msg.payload?.text ?? "";
|
|
128
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
129
|
+
cfg: ctx.cfg,
|
|
130
|
+
channel: "pincer",
|
|
131
|
+
accountId: ctx.accountId,
|
|
132
|
+
peer: { kind: "direct", id: peerId },
|
|
133
|
+
});
|
|
134
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
135
|
+
ctx: {
|
|
136
|
+
Body: messageText,
|
|
137
|
+
BodyForAgent: messageText,
|
|
138
|
+
From: peerId,
|
|
139
|
+
SessionKey: route.sessionKey,
|
|
140
|
+
Channel: "pincer",
|
|
141
|
+
AccountId: ctx.accountId,
|
|
142
|
+
},
|
|
143
|
+
cfg: ctx.cfg,
|
|
144
|
+
dispatcherOptions: {
|
|
145
|
+
deliver: async (payload) => {
|
|
146
|
+
await sendToPincerDm(config, peerId, payload.text);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
lastId = msg.id;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (!signal.aborted) {
|
|
155
|
+
console.error("[pincer] DM poll error:", err?.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const interval = setInterval(poll, pollMs * 2); // DM poll at half rate
|
|
160
|
+
signal.addEventListener("abort", () => clearInterval(interval));
|
|
161
|
+
poll();
|
|
162
|
+
}
|
|
163
|
+
export const pincerChannel = {
|
|
164
|
+
id: "pincer",
|
|
165
|
+
meta: {
|
|
166
|
+
id: "pincer",
|
|
167
|
+
label: "Pincer",
|
|
168
|
+
selectionLabel: "Pincer (agent hub)",
|
|
169
|
+
docsPath: "/channels/pincer",
|
|
170
|
+
docsLabel: "pincer",
|
|
171
|
+
blurb: "Pincer agent hub — rooms and DMs.",
|
|
172
|
+
order: 80,
|
|
173
|
+
},
|
|
174
|
+
capabilities: {
|
|
175
|
+
chatTypes: ["direct", "group"],
|
|
176
|
+
media: false,
|
|
177
|
+
reactions: false,
|
|
178
|
+
threads: false,
|
|
179
|
+
},
|
|
180
|
+
config: {
|
|
181
|
+
listAccountIds: (cfg) => {
|
|
182
|
+
const config = resolveConfig(cfg);
|
|
183
|
+
if (!config.agentId)
|
|
184
|
+
return [];
|
|
185
|
+
return [config.agentId];
|
|
186
|
+
},
|
|
187
|
+
resolveAccount: (_cfg, accountId) => {
|
|
188
|
+
return { accountId };
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
gateway: {
|
|
192
|
+
startAccount: async (ctx) => {
|
|
193
|
+
const config = resolveConfig(ctx.cfg);
|
|
194
|
+
if (!config.baseUrl || !config.apiKey || !config.agentId) {
|
|
195
|
+
console.warn("[pincer] Missing required config (baseUrl, apiKey, agentId). Channel not started.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const signal = ctx.abortSignal;
|
|
199
|
+
const pollMs = config.pollMs ?? 2000;
|
|
200
|
+
for (const roomId of config.rooms ?? []) {
|
|
201
|
+
startRoomPoller({ config, roomId, ctx, signal, pollMs });
|
|
202
|
+
}
|
|
203
|
+
startDmPoller({ config, ctx, signal, pollMs });
|
|
204
|
+
console.log(`[pincer] Started. Monitoring ${(config.rooms ?? []).length} room(s) + DMs as agent ${config.agentId}`);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
outbound: {
|
|
208
|
+
deliveryMode: "direct",
|
|
209
|
+
sendText: async (ctx) => {
|
|
210
|
+
const config = resolveConfig(ctx.cfg);
|
|
211
|
+
const to = ctx.to ?? "";
|
|
212
|
+
if (to.startsWith("room:")) {
|
|
213
|
+
const roomId = to.slice("room:".length);
|
|
214
|
+
await sendToPincerRoom(config, roomId, config.agentId, ctx.text);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
await sendToPincerDm(config, to, ctx.text);
|
|
218
|
+
}
|
|
219
|
+
return { ok: true };
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { pincerChannel } from "./src/channel.js";
|
|
2
|
+
|
|
3
|
+
const plugin = {
|
|
4
|
+
id: "pincer",
|
|
5
|
+
name: "Pincer",
|
|
6
|
+
description: "Pincer channel plugin — rooms and DMs for OpenClaw agents",
|
|
7
|
+
register(api: any) {
|
|
8
|
+
api.registerChannel(pincerChannel);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default plugin;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "pincer",
|
|
3
|
+
"name": "Pincer",
|
|
4
|
+
"description": "Pincer channel plugin for OpenClaw — connects agents to Pincer rooms and DMs",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"channels": ["pincer"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {}
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-pincer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pincer channel plugin for OpenClaw",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"dev": "tsc --watch"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"openclaw": ">=2026.3.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"typescript": "^5.4.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"openclaw",
|
|
20
|
+
"pincer",
|
|
21
|
+
"channel",
|
|
22
|
+
"plugin",
|
|
23
|
+
"agent"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/claw-works/openclaw-pincer.git"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"index.ts",
|
|
34
|
+
"openclaw.plugin.json"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.ts — Pincer channel plugin core
|
|
3
|
+
*
|
|
4
|
+
* Implements the OpenClaw ChannelPlugin interface for Pincer rooms and DMs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface PincerConfig {
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
agentId: string;
|
|
11
|
+
rooms?: string[];
|
|
12
|
+
pollMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PincerMessage {
|
|
16
|
+
id: string;
|
|
17
|
+
room_id?: string;
|
|
18
|
+
sender_agent_id?: string;
|
|
19
|
+
from_agent_id?: string;
|
|
20
|
+
to_agent_id?: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
payload?: { text: string };
|
|
23
|
+
created_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveConfig(cfg: any): PincerConfig {
|
|
27
|
+
return (cfg?.channels?.pincer ?? {}) as PincerConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function pincerFetch(
|
|
31
|
+
baseUrl: string,
|
|
32
|
+
apiKey: string,
|
|
33
|
+
path: string,
|
|
34
|
+
options: RequestInit = {}
|
|
35
|
+
): Promise<any> {
|
|
36
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
...options,
|
|
39
|
+
headers: {
|
|
40
|
+
"X-API-Key": apiKey,
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
...(options.headers ?? {}),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
throw new Error(`Pincer API error ${res.status}: ${await res.text()}`);
|
|
47
|
+
}
|
|
48
|
+
return res.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function sendToPincerRoom(
|
|
52
|
+
config: PincerConfig,
|
|
53
|
+
roomId: string,
|
|
54
|
+
agentId: string,
|
|
55
|
+
text: string
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
await pincerFetch(config.baseUrl, config.apiKey, `/rooms/${roomId}/messages`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: JSON.stringify({ sender_agent_id: agentId, content: text }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function sendToPincerDm(
|
|
64
|
+
config: PincerConfig,
|
|
65
|
+
peerId: string,
|
|
66
|
+
text: string
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
await pincerFetch(config.baseUrl, config.apiKey, `/agents/${config.agentId}/messages`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: JSON.stringify({ to_agent_id: peerId, payload: { text } }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function startRoomPoller(params: {
|
|
75
|
+
config: PincerConfig;
|
|
76
|
+
roomId: string;
|
|
77
|
+
ctx: any;
|
|
78
|
+
signal: AbortSignal;
|
|
79
|
+
pollMs: number;
|
|
80
|
+
}) {
|
|
81
|
+
const { config, roomId, ctx, signal, pollMs } = params;
|
|
82
|
+
let lastId: string | null = null;
|
|
83
|
+
|
|
84
|
+
const poll = async () => {
|
|
85
|
+
if (signal.aborted) return;
|
|
86
|
+
try {
|
|
87
|
+
const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
|
|
88
|
+
const msgs: PincerMessage[] = await pincerFetch(
|
|
89
|
+
config.baseUrl,
|
|
90
|
+
config.apiKey,
|
|
91
|
+
`/rooms/${roomId}/messages${query}`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// On first poll, just record the latest ID to avoid replaying history
|
|
95
|
+
if (lastId === null) {
|
|
96
|
+
if (msgs.length > 0) lastId = msgs[msgs.length - 1].id;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const channelRuntime = ctx.channelRuntime;
|
|
101
|
+
for (const msg of msgs) {
|
|
102
|
+
if (msg.sender_agent_id === config.agentId) {
|
|
103
|
+
lastId = msg.id;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!channelRuntime) {
|
|
108
|
+
console.warn("[pincer] channelRuntime not available, skipping room message dispatch");
|
|
109
|
+
lastId = msg.id;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const messageText = msg.content ?? "";
|
|
114
|
+
const senderId = msg.sender_agent_id ?? "unknown";
|
|
115
|
+
|
|
116
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
117
|
+
cfg: ctx.cfg,
|
|
118
|
+
channel: "pincer",
|
|
119
|
+
accountId: ctx.accountId,
|
|
120
|
+
peer: { kind: "group", id: roomId },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
124
|
+
ctx: {
|
|
125
|
+
Body: messageText,
|
|
126
|
+
BodyForAgent: messageText,
|
|
127
|
+
From: senderId,
|
|
128
|
+
SessionKey: route.sessionKey,
|
|
129
|
+
Channel: "pincer",
|
|
130
|
+
AccountId: ctx.accountId,
|
|
131
|
+
},
|
|
132
|
+
cfg: ctx.cfg,
|
|
133
|
+
dispatcherOptions: {
|
|
134
|
+
deliver: async (payload: any) => {
|
|
135
|
+
await sendToPincerRoom(config, roomId, config.agentId, payload.text);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
lastId = msg.id;
|
|
141
|
+
}
|
|
142
|
+
} catch (err: any) {
|
|
143
|
+
if (!signal.aborted) {
|
|
144
|
+
console.error(`[pincer] room ${roomId} poll error:`, err?.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const interval = setInterval(poll, pollMs);
|
|
150
|
+
signal.addEventListener("abort", () => clearInterval(interval));
|
|
151
|
+
poll();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function startDmPoller(params: {
|
|
155
|
+
config: PincerConfig;
|
|
156
|
+
ctx: any;
|
|
157
|
+
signal: AbortSignal;
|
|
158
|
+
pollMs: number;
|
|
159
|
+
}) {
|
|
160
|
+
const { config, ctx, signal, pollMs } = params;
|
|
161
|
+
let lastId: string | null = null;
|
|
162
|
+
let initialized = false;
|
|
163
|
+
|
|
164
|
+
const poll = async () => {
|
|
165
|
+
if (signal.aborted) return;
|
|
166
|
+
try {
|
|
167
|
+
const query = lastId ? `?after=${lastId}&limit=50` : "?limit=1";
|
|
168
|
+
const msgs: PincerMessage[] = await pincerFetch(
|
|
169
|
+
config.baseUrl,
|
|
170
|
+
config.apiKey,
|
|
171
|
+
`/agents/${config.agentId}/messages${query}`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!initialized) {
|
|
175
|
+
initialized = true;
|
|
176
|
+
if (msgs.length > 0) lastId = msgs[msgs.length - 1].id;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const channelRuntime = ctx.channelRuntime;
|
|
181
|
+
for (const msg of msgs) {
|
|
182
|
+
if (msg.from_agent_id === config.agentId) {
|
|
183
|
+
lastId = msg.id;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!channelRuntime) {
|
|
188
|
+
console.warn("[pincer] channelRuntime not available, skipping DM dispatch");
|
|
189
|
+
lastId = msg.id;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const peerId = msg.from_agent_id ?? "unknown";
|
|
194
|
+
const messageText = msg.payload?.text ?? "";
|
|
195
|
+
|
|
196
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
197
|
+
cfg: ctx.cfg,
|
|
198
|
+
channel: "pincer",
|
|
199
|
+
accountId: ctx.accountId,
|
|
200
|
+
peer: { kind: "direct", id: peerId },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
204
|
+
ctx: {
|
|
205
|
+
Body: messageText,
|
|
206
|
+
BodyForAgent: messageText,
|
|
207
|
+
From: peerId,
|
|
208
|
+
SessionKey: route.sessionKey,
|
|
209
|
+
Channel: "pincer",
|
|
210
|
+
AccountId: ctx.accountId,
|
|
211
|
+
},
|
|
212
|
+
cfg: ctx.cfg,
|
|
213
|
+
dispatcherOptions: {
|
|
214
|
+
deliver: async (payload: any) => {
|
|
215
|
+
await sendToPincerDm(config, peerId, payload.text);
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
lastId = msg.id;
|
|
221
|
+
}
|
|
222
|
+
} catch (err: any) {
|
|
223
|
+
if (!signal.aborted) {
|
|
224
|
+
console.error("[pincer] DM poll error:", err?.message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const interval = setInterval(poll, pollMs * 2); // DM poll at half rate
|
|
230
|
+
signal.addEventListener("abort", () => clearInterval(interval));
|
|
231
|
+
poll();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const pincerChannel = {
|
|
235
|
+
id: "pincer",
|
|
236
|
+
meta: {
|
|
237
|
+
id: "pincer",
|
|
238
|
+
label: "Pincer",
|
|
239
|
+
selectionLabel: "Pincer (agent hub)",
|
|
240
|
+
docsPath: "/channels/pincer",
|
|
241
|
+
docsLabel: "pincer",
|
|
242
|
+
blurb: "Pincer agent hub — rooms and DMs.",
|
|
243
|
+
order: 80,
|
|
244
|
+
},
|
|
245
|
+
capabilities: {
|
|
246
|
+
chatTypes: ["direct", "group"],
|
|
247
|
+
media: false,
|
|
248
|
+
reactions: false,
|
|
249
|
+
threads: false,
|
|
250
|
+
},
|
|
251
|
+
config: {
|
|
252
|
+
listAccountIds: (cfg: any) => {
|
|
253
|
+
const config = resolveConfig(cfg);
|
|
254
|
+
if (!config.agentId) return [];
|
|
255
|
+
return [config.agentId];
|
|
256
|
+
},
|
|
257
|
+
resolveAccount: (_cfg: any, accountId: string) => {
|
|
258
|
+
return { accountId };
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
gateway: {
|
|
262
|
+
startAccount: async (ctx: any) => {
|
|
263
|
+
const config = resolveConfig(ctx.cfg);
|
|
264
|
+
if (!config.baseUrl || !config.apiKey || !config.agentId) {
|
|
265
|
+
console.warn("[pincer] Missing required config (baseUrl, apiKey, agentId). Channel not started.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const signal: AbortSignal = ctx.abortSignal;
|
|
270
|
+
const pollMs = config.pollMs ?? 2000;
|
|
271
|
+
|
|
272
|
+
for (const roomId of config.rooms ?? []) {
|
|
273
|
+
startRoomPoller({ config, roomId, ctx, signal, pollMs });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
startDmPoller({ config, ctx, signal, pollMs });
|
|
277
|
+
|
|
278
|
+
console.log(
|
|
279
|
+
`[pincer] Started. Monitoring ${(config.rooms ?? []).length} room(s) + DMs as agent ${config.agentId}`
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
outbound: {
|
|
284
|
+
deliveryMode: "direct",
|
|
285
|
+
sendText: async (ctx: any) => {
|
|
286
|
+
const config = resolveConfig(ctx.cfg);
|
|
287
|
+
const to: string = ctx.to ?? "";
|
|
288
|
+
if (to.startsWith("room:")) {
|
|
289
|
+
const roomId = to.slice("room:".length);
|
|
290
|
+
await sendToPincerRoom(config, roomId, config.agentId, ctx.text);
|
|
291
|
+
} else {
|
|
292
|
+
await sendToPincerDm(config, to, ctx.text);
|
|
293
|
+
}
|
|
294
|
+
return { ok: true };
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
};
|