@gholl-studio/pier-connector 0.2.21 → 0.2.23
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/package.json +1 -1
- package/src/index.js +584 -901
package/src/index.js
CHANGED
|
@@ -26,269 +26,529 @@ import path from 'path';
|
|
|
26
26
|
export default function register(api) {
|
|
27
27
|
const logger = api.logger;
|
|
28
28
|
|
|
29
|
-
// ── shared state
|
|
30
|
-
|
|
31
|
-
/** @type {import('@nats-io/transport-node').NatsConnection | null} */
|
|
32
|
-
let nc = null;
|
|
33
|
-
|
|
34
|
-
/** @type {import('@nats-io/transport-node').JetStreamContext | null} */
|
|
35
|
-
let js = null;
|
|
29
|
+
// ── shared state (Instances) ───────────────────────────────────
|
|
36
30
|
|
|
37
|
-
/** @type {any
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/** @type {import('@nats-io/transport-node').Subscription | null} */
|
|
41
|
-
let subscription = null;
|
|
42
|
-
|
|
43
|
-
/** Connection status for the /pier command */
|
|
44
|
-
let connectionStatus = 'disconnected';
|
|
45
|
-
const jobSubscriptions = new Map();
|
|
46
|
-
const jobStopTimeouts = new Map();
|
|
47
|
-
const activeNodeJobs = new Map();
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Anti-Interference Flag (BUG-34)
|
|
51
|
-
* When true, the robot will ignore new marketplace jobs.
|
|
52
|
-
*/
|
|
53
|
-
let isBusy = false;
|
|
31
|
+
/** @type {Map<string, any>} */
|
|
32
|
+
const instances = new Map();
|
|
54
33
|
|
|
55
|
-
|
|
56
|
-
let
|
|
57
|
-
let
|
|
58
|
-
let
|
|
59
|
-
let lastHeartbeatError = null;
|
|
34
|
+
/** Aggregated stats from all robots since start */
|
|
35
|
+
let totalJobsReceived = 0;
|
|
36
|
+
let totalJobsCompleted = 0;
|
|
37
|
+
let totalJobsFailed = 0;
|
|
60
38
|
|
|
61
39
|
// ── resolve plugin config ──────────────────────────────────────────
|
|
62
40
|
|
|
63
|
-
function
|
|
41
|
+
function resolveConfigs() {
|
|
64
42
|
const rawAccounts = api.config?.channels?.['pier']?.accounts || {};
|
|
65
|
-
const accountId = Object.keys(rawAccounts)[0] || 'default';
|
|
66
|
-
const firstAccount = rawAccounts[accountId] || {};
|
|
67
|
-
|
|
68
43
|
const legacyCfg = api.config?.plugins?.entries?.['pier-connector']?.config || {};
|
|
69
44
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
45
|
+
// If no accounts defined, fallback to 'default' using legacy/env config
|
|
46
|
+
if (Object.keys(rawAccounts).length === 0) {
|
|
47
|
+
return [{
|
|
48
|
+
accountId: 'default',
|
|
49
|
+
...mergedCfgFrom(legacyCfg, {})
|
|
50
|
+
}];
|
|
51
|
+
}
|
|
74
52
|
|
|
75
|
-
return {
|
|
76
|
-
accountId:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
secretKey: mergedCfg.secretKey || DEFAULTS.SECRET_KEY,
|
|
80
|
-
privateKey: mergedCfg.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
|
|
81
|
-
natsUrl: mergedCfg.natsUrl || DEFAULTS.NATS_URL,
|
|
82
|
-
subject: mergedCfg.subject || DEFAULTS.SUBJECT,
|
|
83
|
-
publishSubject: mergedCfg.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
|
|
84
|
-
queueGroup: mergedCfg.queueGroup || DEFAULTS.QUEUE_GROUP,
|
|
85
|
-
agentId: mergedCfg.agentId || DEFAULTS.AGENT_ID,
|
|
86
|
-
walletAddress: mergedCfg.walletAddress || DEFAULTS.WALLET_ADDRESS,
|
|
87
|
-
};
|
|
53
|
+
return Object.entries(rawAccounts).map(([id, account]) => ({
|
|
54
|
+
accountId: id,
|
|
55
|
+
...mergedCfgFrom(legacyCfg, account)
|
|
56
|
+
}));
|
|
88
57
|
}
|
|
89
58
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
59
|
+
function mergedCfgFrom(legacy, account) {
|
|
60
|
+
const merged = { ...legacy, ...account };
|
|
61
|
+
return {
|
|
62
|
+
pierApiUrl: merged.pierApiUrl || DEFAULTS.PIER_API_URL,
|
|
63
|
+
nodeId: merged.nodeId || DEFAULTS.NODE_ID,
|
|
64
|
+
secretKey: merged.secretKey || DEFAULTS.SECRET_KEY,
|
|
65
|
+
privateKey: merged.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
|
|
66
|
+
natsUrl: merged.natsUrl || DEFAULTS.NATS_URL,
|
|
67
|
+
subject: merged.subject || DEFAULTS.SUBJECT,
|
|
68
|
+
publishSubject: merged.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
|
|
69
|
+
queueGroup: merged.queueGroup || DEFAULTS.QUEUE_GROUP,
|
|
70
|
+
agentId: merged.agentId || DEFAULTS.AGENT_ID,
|
|
71
|
+
walletAddress: merged.walletAddress || DEFAULTS.WALLET_ADDRESS,
|
|
72
|
+
};
|
|
96
73
|
}
|
|
97
74
|
|
|
98
|
-
/** Heartbeat timer reference */
|
|
99
|
-
let heartbeatTimer = null;
|
|
100
|
-
|
|
101
|
-
// ── Lifecycle: Heartbeat ──────────────────────────────────────────
|
|
102
|
-
|
|
103
75
|
/**
|
|
104
|
-
*
|
|
76
|
+
* PierRobot class encapsulates the connection, heartbeat, and job handling
|
|
77
|
+
* for a single robot account.
|
|
105
78
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
79
|
+
class PierRobot {
|
|
80
|
+
constructor(config) {
|
|
81
|
+
this.config = config;
|
|
82
|
+
this.accountId = config.accountId;
|
|
83
|
+
this.nc = null;
|
|
84
|
+
this.js = null;
|
|
85
|
+
this.jsm = null;
|
|
86
|
+
this.subscription = null;
|
|
87
|
+
this.heartbeatTimer = null;
|
|
88
|
+
this.connectionStatus = 'disconnected';
|
|
89
|
+
this.jobSubscriptions = new Map();
|
|
90
|
+
this.jobStopTimeouts = new Map();
|
|
91
|
+
this.activeNodeJobs = new Map();
|
|
92
|
+
this.isBusy = false;
|
|
93
|
+
this.stats = { received: 0, completed: 0, failed: 0 };
|
|
94
|
+
this.lastHeartbeatError = null;
|
|
95
|
+
this.connectedAt = null;
|
|
96
|
+
}
|
|
108
97
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
98
|
+
async heartbeat() {
|
|
99
|
+
if (!this.config.nodeId || !this.config.secretKey) return null;
|
|
100
|
+
try {
|
|
101
|
+
const resp = await fetch(`${this.config.pierApiUrl}/nodes/heartbeat`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
node_id: this.config.nodeId,
|
|
106
|
+
secret_key: this.config.secretKey,
|
|
107
|
+
capabilities: ['translation', 'code-execution', 'reasoning', 'vision'],
|
|
108
|
+
description: `OpenClaw Node (${this.config.nodeId.substring(0, 8)}) [${this.accountId}]`,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
120
111
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
112
|
+
if (!resp.ok) {
|
|
113
|
+
const errText = await resp.text();
|
|
114
|
+
throw new Error(`Heartbeat failed (${resp.status}): ${errText}`);
|
|
115
|
+
}
|
|
125
116
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
117
|
+
const data = await resp.json();
|
|
118
|
+
this.lastHeartbeatError = null;
|
|
119
|
+
return data.nats_config || null;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
this.lastHeartbeatError = err.message;
|
|
122
|
+
logger.warn(`[pier-connector][${this.accountId}] Heartbeat failed: ${err.message}`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
133
125
|
}
|
|
134
|
-
}
|
|
135
126
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
'Content-Type': 'application/json',
|
|
147
|
-
'Authorization': `Bearer ${config.secretKey}`,
|
|
148
|
-
'X-Node-Id': config.nodeId // BUG-40: Explicit Node ID for backend
|
|
149
|
-
}
|
|
150
|
-
});
|
|
127
|
+
async claimJob(jobId) {
|
|
128
|
+
try {
|
|
129
|
+
const resp = await fetch(`${this.config.pierApiUrl}/jobs/${jobId}/claim`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
'Authorization': `Bearer ${this.config.secretKey}`,
|
|
134
|
+
'X-Node-Id': this.config.nodeId
|
|
135
|
+
}
|
|
136
|
+
});
|
|
151
137
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
138
|
+
if (!resp.ok) {
|
|
139
|
+
if (resp.status === 409) {
|
|
140
|
+
logger.warn(`[pier-connector][${this.accountId}] 🚫 Job ${jobId} already claimed.`);
|
|
141
|
+
return { ok: false, alreadyClaimed: true };
|
|
142
|
+
}
|
|
157
143
|
const errData = await resp.json().catch(() => ({}));
|
|
158
|
-
logger.error(`[pier-connector] ✖ Failed to claim job ${jobId}: ${errData.error || resp.statusText}`);
|
|
159
144
|
return { ok: false, error: errData.error || resp.statusText };
|
|
160
145
|
}
|
|
146
|
+
return { ok: true };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, error: err.message };
|
|
161
149
|
}
|
|
162
|
-
|
|
163
|
-
logger.info(`[pier-connector] ✅ Successfully claimed job ${jobId}`);
|
|
164
|
-
return { ok: true };
|
|
165
|
-
} catch (err) {
|
|
166
|
-
logger.error(`[pier-connector] ✖ Network error claiming job ${jobId}: ${err.message}`);
|
|
167
|
-
return false;
|
|
168
150
|
}
|
|
169
|
-
}
|
|
170
151
|
|
|
171
152
|
/**
|
|
172
153
|
* Routes an incoming message from Pier to the OpenClaw agent.
|
|
173
154
|
* Replaces the non-existent api.runtime.sendIncoming.
|
|
174
155
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
156
|
+
async receiveIncoming(inbound, jobId) {
|
|
157
|
+
if (!api.runtime?.channel?.reply) {
|
|
158
|
+
logger.error(`[pier-connector][${this.accountId}] SDK Error: api.runtime.channel.reply is not available.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
163
|
+
cfg: api.config,
|
|
164
|
+
channel: 'pier',
|
|
165
|
+
accountId: inbound.accountId,
|
|
166
|
+
peer: { kind: 'direct', id: jobId }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const dynamicSessionKey = `pier-job-${jobId}`;
|
|
170
|
+
const metadata = this.activeNodeJobs.get(jobId);
|
|
171
|
+
let injectedPrompt = "";
|
|
172
|
+
|
|
173
|
+
if (metadata && metadata.isTargeted === false) {
|
|
174
|
+
injectedPrompt = [
|
|
175
|
+
"=========================================",
|
|
176
|
+
"CRITICAL DIRECTIVE - OPEN BIDDING MARKETPLACE",
|
|
177
|
+
"=========================================",
|
|
178
|
+
"You are an autonomous AI worker on Pier. This job currently has NO assigned worker.",
|
|
179
|
+
"1. DO NOT directly solve the user's task or output the final answer right now.",
|
|
180
|
+
"2. You MUST USE the `pier_bid_task` tool to send a pitch/bid to the employer explaining why you are best equipped to solve this, and state your price.",
|
|
181
|
+
"3. Stop executing after submitting the bid.",
|
|
182
|
+
"4. You MUST use the Job ID provided below when calling the tool.",
|
|
183
|
+
`Job ID: ${jobId}`,
|
|
184
|
+
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
185
|
+
].join('\n');
|
|
186
|
+
} else {
|
|
187
|
+
injectedPrompt = [
|
|
188
|
+
"=========================================",
|
|
189
|
+
"CRITICAL DIRECTIVE - PIER TRADING NETWORK",
|
|
190
|
+
"=========================================",
|
|
191
|
+
"You are an autonomous AI worker on Pier.",
|
|
192
|
+
"1. You MUST USE the \`pier_finish_task\` tool to submit the final result when your work is complete.",
|
|
193
|
+
"2. If you need to negotiate or clarify requirements with the employer, use \`pier_chat\`.",
|
|
194
|
+
"3. Do not just wait in silence. Always officially finish the task if the employer's goal is met.",
|
|
195
|
+
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
|
|
200
|
+
Body: inbound.text,
|
|
201
|
+
BodyForAgent: inbound.text,
|
|
202
|
+
RawBody: inbound.text,
|
|
203
|
+
From: inbound.senderId,
|
|
204
|
+
To: `pier:${jobId}`,
|
|
205
|
+
SessionKey: dynamicSessionKey,
|
|
206
|
+
AccountId: route.accountId,
|
|
207
|
+
ChatType: 'direct',
|
|
208
|
+
SenderId: inbound.senderId,
|
|
209
|
+
Provider: 'pier',
|
|
210
|
+
Surface: 'pier',
|
|
211
|
+
OriginatingChannel: 'pier',
|
|
212
|
+
OriginatingTo: `pier:${jobId}`,
|
|
213
|
+
WasMentioned: true,
|
|
214
|
+
CommandAuthorized: true,
|
|
215
|
+
SystemPrompt: injectedPrompt,
|
|
216
|
+
MessageId: inbound.messageId || jobId,
|
|
217
|
+
Metadata: {
|
|
218
|
+
...metadata,
|
|
219
|
+
accountId: this.accountId,
|
|
220
|
+
pierJobId: jobId
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const { dispatcher, markDispatchIdle } = api.runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
225
|
+
cfg: api.config,
|
|
226
|
+
agentId: route.agentId,
|
|
227
|
+
deliver: async (payload) => {
|
|
228
|
+
const currentMeta = this.activeNodeJobs.get(jobId);
|
|
229
|
+
await pierChannel.outbound.sendText({
|
|
230
|
+
text: payload.text,
|
|
231
|
+
to: `pier:${jobId}`,
|
|
232
|
+
metadata: {
|
|
233
|
+
...currentMeta,
|
|
234
|
+
accountId: this.accountId,
|
|
235
|
+
pierJobId: jobId
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (api.runtime.channel.session?.recordSessionMetaFromInbound) {
|
|
242
|
+
try {
|
|
243
|
+
const storePath = api.runtime.channel.session.resolveStorePath(api.config, dynamicSessionKey);
|
|
244
|
+
await api.runtime.channel.session.recordSessionMetaFromInbound({
|
|
245
|
+
storePath, sessionKey: dynamicSessionKey, ctx: ctxPayload
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await api.runtime.channel.reply.dispatchReplyFromConfig({
|
|
252
|
+
ctx: ctxPayload, cfg: api.config, dispatcher
|
|
253
|
+
});
|
|
254
|
+
} finally {
|
|
255
|
+
markDispatchIdle();
|
|
256
|
+
}
|
|
179
257
|
}
|
|
258
|
+
async subscribeToJobMessages(jobId) {
|
|
259
|
+
if (!this.js) return;
|
|
260
|
+
|
|
261
|
+
const jsSubject = `jobs.job.${jobId}.msg`;
|
|
262
|
+
const streamName = 'PIER_JOBS';
|
|
263
|
+
const durableName = `pier_chat_${this.config.nodeId.replace(/[^a-zA-Z0-9]/g, '_')}_${jobId.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
let consumer;
|
|
267
|
+
try {
|
|
268
|
+
consumer = await this.jsm.consumers.get(streamName, durableName);
|
|
269
|
+
} catch {
|
|
270
|
+
await this.jsm.consumers.add(streamName, {
|
|
271
|
+
durable_name: durableName,
|
|
272
|
+
filter_subject: jsSubject,
|
|
273
|
+
deliver_policy: DeliverPolicy.New,
|
|
274
|
+
ack_policy: AckPolicy.Explicit,
|
|
275
|
+
ack_wait: 1000 * 60 * 60,
|
|
276
|
+
});
|
|
277
|
+
consumer = await this.jsm.consumers.get(streamName, durableName);
|
|
278
|
+
}
|
|
180
279
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
280
|
+
const iter = await consumer.consume();
|
|
281
|
+
this.jobSubscriptions.set(jobId, iter);
|
|
282
|
+
|
|
283
|
+
(async () => {
|
|
284
|
+
logger.info(`[pier-connector][${this.accountId}] 👂 Monitoring chat subject ${jsSubject}`);
|
|
285
|
+
const processedMessages = new Set();
|
|
286
|
+
|
|
287
|
+
for await (const msg of iter) {
|
|
288
|
+
try {
|
|
289
|
+
const rawMsg = new TextDecoder().decode(msg.data);
|
|
290
|
+
let msgPayload;
|
|
291
|
+
try {
|
|
292
|
+
msgPayload = JSON.parse(rawMsg);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
msg.ack();
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (msgPayload.id && processedMessages.has(msgPayload.id)) {
|
|
299
|
+
msg.ack();
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (msgPayload.sender_id === this.config.nodeId) {
|
|
304
|
+
msg.ack();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (msgPayload.type === 'receipt') {
|
|
309
|
+
msg.ack();
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (this.js && msgPayload.id && !msgPayload.type && msgPayload.sender_id !== this.config.nodeId) {
|
|
314
|
+
try {
|
|
315
|
+
const replySubject = `jobs.job.${jobId}.msg`;
|
|
316
|
+
await this.js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
|
|
317
|
+
type: 'receipt',
|
|
318
|
+
msg_id: msgPayload.id,
|
|
319
|
+
reader_id: this.config.nodeId
|
|
320
|
+
})));
|
|
321
|
+
} catch (err) {}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const content = msgPayload.content;
|
|
325
|
+
const senderCore = msgPayload.sender_id;
|
|
326
|
+
|
|
327
|
+
if (!content) {
|
|
328
|
+
msg.ack();
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
logger.info(`[pier-connector][${this.accountId}] 📥 Incoming chat: [${jobId}] sender=${senderCore} "${truncate(content, 40)}"`);
|
|
333
|
+
if (msgPayload.id) processedMessages.add(msgPayload.id);
|
|
334
|
+
|
|
335
|
+
msg.ack();
|
|
336
|
+
|
|
337
|
+
this.activeNodeJobs.set(jobId, {
|
|
338
|
+
pierJobId: jobId,
|
|
339
|
+
isRealtimeMsg: true
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
(async () => {
|
|
343
|
+
try {
|
|
344
|
+
await this.receiveIncoming({
|
|
345
|
+
accountId: this.accountId,
|
|
346
|
+
senderId: `pier:${senderCore}`,
|
|
347
|
+
text: content,
|
|
348
|
+
}, jobId);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
logger.error(`[pier-connector][${this.accountId}] Agent execution error: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
})();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
logger.error(`[pier-connector][${this.accountId}] Chat message processing error: ${err.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
})().catch(err => {
|
|
358
|
+
logger.error(`[pier-connector][${this.accountId}] Chat iteration died: ${err.message}`);
|
|
359
|
+
this.jobSubscriptions.delete(jobId);
|
|
360
|
+
});
|
|
361
|
+
} catch (err) {
|
|
362
|
+
logger.error(`[pier-connector][${this.accountId}] Failed to setup chat consumer: ${err.message}`);
|
|
188
363
|
}
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const dynamicSessionKey = `pier-job-${jobId}`;
|
|
192
|
-
const metadata = activeNodeJobs.get(jobId);
|
|
193
|
-
let injectedPrompt = "";
|
|
194
|
-
|
|
195
|
-
if (metadata && metadata.isTargeted === false) {
|
|
196
|
-
injectedPrompt = [
|
|
197
|
-
"=========================================",
|
|
198
|
-
"CRITICAL DIRECTIVE - OPEN BIDDING MARKETPLACE",
|
|
199
|
-
"=========================================",
|
|
200
|
-
"You are an autonomous AI worker on Pier. This job currently has NO assigned worker.",
|
|
201
|
-
"1. DO NOT directly solve the user's task or output the final answer right now.",
|
|
202
|
-
"2. You MUST USE the `pier_bid_task` tool to send a pitch/bid to the employer explaining why you are best equipped to solve this, and state your price.",
|
|
203
|
-
"3. Stop executing after submitting the bid.",
|
|
204
|
-
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
205
|
-
].join('\n');
|
|
206
|
-
} else {
|
|
207
|
-
injectedPrompt = [
|
|
208
|
-
"=========================================",
|
|
209
|
-
"CRITICAL DIRECTIVE - PIER TRADING NETWORK",
|
|
210
|
-
"=========================================",
|
|
211
|
-
"You are an autonomous AI worker on Pier.",
|
|
212
|
-
"1. You MUST USE the `pier_finish_task` tool to submit the final result when your work is complete.",
|
|
213
|
-
"2. If you need to negotiate or clarify requirements with the employer, use `pier_chat`.",
|
|
214
|
-
"3. Do not just wait in silence. Always officially finish the task if the employer's goal is met.",
|
|
215
|
-
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
216
|
-
].join('\n');
|
|
217
364
|
}
|
|
218
365
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
SenderId: inbound.senderId,
|
|
229
|
-
Provider: 'pier',
|
|
230
|
-
Surface: 'pier',
|
|
231
|
-
OriginatingChannel: 'pier',
|
|
232
|
-
OriginatingTo: `pier:${jobId}`,
|
|
233
|
-
WasMentioned: true,
|
|
234
|
-
CommandAuthorized: true,
|
|
235
|
-
SystemPrompt: injectedPrompt,
|
|
236
|
-
MessageId: inbound.messageId || jobId
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
let collectedResult = '';
|
|
240
|
-
|
|
241
|
-
// Create a dispatcher to handle the reply lifecycle (fixes sendFinalReply error)
|
|
242
|
-
const { dispatcher, markDispatchIdle } = api.runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
243
|
-
cfg: api.config,
|
|
244
|
-
agentId: route.agentId,
|
|
245
|
-
onIdle: () => {
|
|
246
|
-
logger.debug(`[pier-connector] Dispatcher idle for session ${dynamicSessionKey}`);
|
|
247
|
-
},
|
|
248
|
-
deliver: async (payload) => {
|
|
249
|
-
const metadata = activeNodeJobs.get(jobId);
|
|
250
|
-
const isRealtime = metadata?.isRealtimeMsg;
|
|
366
|
+
async handleMessage(msg) {
|
|
367
|
+
const rawData = new TextDecoder().decode(msg.data);
|
|
368
|
+
let payload;
|
|
369
|
+
try {
|
|
370
|
+
payload = JSON.parse(rawData);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
msg.ack();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
251
375
|
|
|
252
|
-
|
|
253
|
-
|
|
376
|
+
if (payload.type === 'wakeup') {
|
|
377
|
+
const { jobId } = payload;
|
|
378
|
+
if (jobId) {
|
|
379
|
+
logger.info(`[pier-connector][${this.accountId}] ⏰ Received wakeup signal for job ${jobId}`);
|
|
380
|
+
if (!this.jobSubscriptions.has(jobId)) {
|
|
381
|
+
this.activeNodeJobs.set(jobId, { pierJobId: jobId });
|
|
382
|
+
await this.subscribeToJobMessages(jobId);
|
|
383
|
+
}
|
|
384
|
+
msg.ack();
|
|
385
|
+
} else {
|
|
386
|
+
msg.ack();
|
|
254
387
|
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
255
390
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
391
|
+
if (this.isBusy) {
|
|
392
|
+
logger.debug(`[pier-connector][${this.accountId}] 🚫 Busy. Ignoring payload...`);
|
|
393
|
+
msg.nak();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (payload.assigned_node_id && payload.assigned_node_id !== this.config.nodeId) {
|
|
398
|
+
msg.nak();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const jobIdToClaim = payload.id;
|
|
403
|
+
const isTargeted = !!(payload.assigned_node_id || payload.targetNodeId || payload.TargetNodeID);
|
|
404
|
+
|
|
405
|
+
if (jobIdToClaim && isTargeted) {
|
|
406
|
+
const claimResult = await this.claimJob(jobIdToClaim);
|
|
407
|
+
if (!claimResult.ok) {
|
|
408
|
+
if (claimResult.alreadyClaimed) msg.ack();
|
|
409
|
+
else msg.nak();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this.isBusy = true;
|
|
415
|
+
msg.ack();
|
|
416
|
+
|
|
417
|
+
this.stats.received++;
|
|
418
|
+
totalJobsReceived++;
|
|
419
|
+
|
|
420
|
+
const parsed = parseJob(msg, logger);
|
|
421
|
+
if (!parsed.ok) {
|
|
422
|
+
this.stats.failed++;
|
|
423
|
+
totalJobsFailed++;
|
|
424
|
+
this.isBusy = false;
|
|
425
|
+
logger.error(`[pier-connector][${this.accountId}] Job parse error: ${parsed.error}`);
|
|
426
|
+
safeRespond(msg, createErrorPayload({
|
|
427
|
+
id: jobIdToClaim || 'unknown',
|
|
428
|
+
errorCode: 'PARSE_ERROR',
|
|
429
|
+
errorMessage: parsed.error,
|
|
430
|
+
workerId: this.config.nodeId,
|
|
431
|
+
walletAddress: this.config.walletAddress,
|
|
432
|
+
}));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const { job } = parsed;
|
|
437
|
+
const senderCore = job.meta?.sender ?? 'anonymous';
|
|
438
|
+
|
|
439
|
+
this.activeNodeJobs.set(job.id, {
|
|
440
|
+
pierJobId: job.id,
|
|
441
|
+
pierNatsMsg: msg,
|
|
442
|
+
pierStartTime: performance.now(),
|
|
443
|
+
pierMeta: job.meta,
|
|
444
|
+
isRealtimeMsg: false,
|
|
445
|
+
isTargeted: isTargeted
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
let finalText = job.task;
|
|
449
|
+
if (!isTargeted) {
|
|
450
|
+
finalText = `【PIER MARKETPLACE OPEN JOB】\nJob ID: ${job.id}\nTask: ${job.task}\n\n=== CRITICAL ===\nYou MUST use \`pier_bid_task\` to bid. Do not solve directly.`;
|
|
264
451
|
}
|
|
265
|
-
});
|
|
266
452
|
|
|
267
|
-
|
|
268
|
-
|
|
453
|
+
await this.subscribeToJobMessages(job.id);
|
|
454
|
+
await this.receiveIncoming({
|
|
455
|
+
accountId: this.accountId,
|
|
456
|
+
senderId: `pier:${senderCore}`,
|
|
457
|
+
text: finalText,
|
|
458
|
+
}, job.id);
|
|
459
|
+
|
|
460
|
+
this.isBusy = false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async setupMarketplaceConsumer(streamName, subjectName, durableName) {
|
|
269
464
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
465
|
+
let consumer;
|
|
466
|
+
try {
|
|
467
|
+
consumer = await this.js.consumers.get(streamName, durableName);
|
|
468
|
+
} catch {
|
|
469
|
+
await this.jsm.consumers.add(streamName, {
|
|
470
|
+
durable_name: durableName,
|
|
471
|
+
filter_subject: subjectName,
|
|
472
|
+
deliver_policy: DeliverPolicy.New,
|
|
473
|
+
ack_policy: AckPolicy.Explicit,
|
|
474
|
+
});
|
|
475
|
+
consumer = await this.js.consumers.get(streamName, durableName);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const iter = await consumer.consume();
|
|
479
|
+
this.subscription = iter;
|
|
480
|
+
|
|
481
|
+
(async () => {
|
|
482
|
+
for await (const msg of iter) {
|
|
483
|
+
await this.handleMessage(msg);
|
|
484
|
+
}
|
|
485
|
+
})().catch(err => {
|
|
486
|
+
logger.error(`[pier-connector][${this.accountId}] Consumer error: ${err.message}`);
|
|
275
487
|
});
|
|
276
488
|
} catch (err) {
|
|
277
|
-
logger.
|
|
489
|
+
logger.error(`[pier-connector][${this.accountId}] Setup failed: ${err.message}`);
|
|
490
|
+
throw err;
|
|
278
491
|
}
|
|
279
492
|
}
|
|
280
493
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
494
|
+
async autoRegister() {
|
|
495
|
+
const wallet = new ethers.Wallet(this.config.privateKey);
|
|
496
|
+
const address = wallet.address;
|
|
497
|
+
|
|
498
|
+
const challengeRes = await fetch(`${this.config.pierApiUrl}/auth/challenge?wallet_address=${address}`);
|
|
499
|
+
const { challenge } = await challengeRes.json();
|
|
500
|
+
const signature = await wallet.signMessage(challenge);
|
|
501
|
+
|
|
502
|
+
const loginRes = await fetch(`${this.config.pierApiUrl}/auth/login`, {
|
|
503
|
+
method: 'POST',
|
|
504
|
+
headers: { 'Content-Type': 'application/json' },
|
|
505
|
+
body: JSON.stringify({ wallet_address: address, challenge, signature })
|
|
506
|
+
});
|
|
507
|
+
const { api_key } = await loginRes.json();
|
|
508
|
+
|
|
509
|
+
const hostName = `${api?.getRuntimeInfo?.()?.hostname ?? 'Auto'}-${this.accountId}`;
|
|
510
|
+
const regRes = await fetch(`${this.config.pierApiUrl}/nodes/register`, {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${api_key}` },
|
|
513
|
+
body: JSON.stringify({ wallet_address: address, name: hostName })
|
|
288
514
|
});
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
515
|
+
const { id, secret_key } = await regRes.json();
|
|
516
|
+
|
|
517
|
+
this.config.nodeId = id;
|
|
518
|
+
this.config.secretKey = secret_key;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async start() {
|
|
522
|
+
this.connectionStatus = 'connecting';
|
|
523
|
+
try {
|
|
524
|
+
if (!this.config.nodeId && this.config.privateKey) await this.autoRegister();
|
|
525
|
+
const natsConfig = await this.heartbeat();
|
|
526
|
+
if (!natsConfig) throw new Error('No NATS config');
|
|
527
|
+
|
|
528
|
+
this.nc = await createNatsConnection(natsConfig.url || this.config.natsUrl, logger);
|
|
529
|
+
this.js = this.nc.jetstream();
|
|
530
|
+
this.jsm = await this.nc.jetstreamManager();
|
|
531
|
+
this.connectionStatus = 'connected';
|
|
532
|
+
this.connectedAt = new Date();
|
|
533
|
+
|
|
534
|
+
const streamName = 'PIER_JOBS';
|
|
535
|
+
const durableNameMarket = `pier_market_${this.config.nodeId.replace(/-/g, '_')}`;
|
|
536
|
+
const durableNameDirect = `pier_node_${this.config.nodeId.replace(/-/g, '_')}`;
|
|
537
|
+
|
|
538
|
+
await this.setupMarketplaceConsumer(streamName, this.config.subject, durableNameMarket);
|
|
539
|
+
await this.setupMarketplaceConsumer(streamName, `jobs.node.${this.config.nodeId}`, durableNameDirect);
|
|
540
|
+
|
|
541
|
+
this.heartbeatTimer = setInterval(() => this.heartbeat(), 60000);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
this.connectionStatus = 'error';
|
|
544
|
+
logger.error(`[pier-connector][${this.accountId}] Start failed: ${err.message}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async stop() {
|
|
549
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
550
|
+
if (this.nc) await drainConnection(this.nc, logger);
|
|
551
|
+
this.connectionStatus = 'disconnected';
|
|
292
552
|
}
|
|
293
553
|
}
|
|
294
554
|
|
|
@@ -347,72 +607,65 @@ export default function register(api) {
|
|
|
347
607
|
outbound: {
|
|
348
608
|
deliveryMode: 'direct',
|
|
349
609
|
|
|
350
|
-
/**
|
|
351
|
-
* Send a reply text back through the NATS response.
|
|
352
|
-
* This is called by OpenClaw's agent after processing a job.
|
|
353
|
-
*/
|
|
354
610
|
sendText: async (ctx) => {
|
|
355
611
|
const text = ctx.text;
|
|
356
612
|
let metadata = ctx.metadata;
|
|
357
|
-
|
|
358
|
-
|
|
613
|
+
const accountId = metadata?.accountId || 'default';
|
|
614
|
+
const robot = instances.get(accountId);
|
|
615
|
+
|
|
616
|
+
logger.info(`[pier-connector][${accountId}] 📤 Agent sending reply: "${truncate(text, 40)}" (To: ${ctx.to})`);
|
|
617
|
+
|
|
618
|
+
if (!robot) {
|
|
619
|
+
logger.error(`[pier-connector] ✖ No robot instance found for account ${accountId}`);
|
|
620
|
+
return { ok: false, error: 'Robot instance not found' };
|
|
621
|
+
}
|
|
359
622
|
|
|
360
623
|
if (!metadata && ctx.to) {
|
|
361
624
|
const toId = ctx.to.replace(/^pier:/, '');
|
|
362
|
-
metadata = activeNodeJobs.get(toId);
|
|
363
|
-
logger.debug(`[pier-connector] 📤 Resolved metadata for ${toId}: ${metadata ? 'Found' : 'Missing'}`);
|
|
625
|
+
metadata = robot.activeNodeJobs.get(toId);
|
|
364
626
|
}
|
|
365
627
|
|
|
366
628
|
const jobId = metadata?.pierJobId;
|
|
367
|
-
const msg = metadata?.pierNatsMsg;
|
|
368
|
-
const isRealtimeMsg = metadata?.isRealtimeMsg;
|
|
369
629
|
|
|
370
|
-
if (jobId && js) {
|
|
630
|
+
if (jobId && robot.js) {
|
|
371
631
|
try {
|
|
372
|
-
const config = getActiveConfig();
|
|
373
632
|
const replySubject = `jobs.job.${jobId}.msg`;
|
|
374
633
|
|
|
375
|
-
// Pre-hiring Radio Silence
|
|
376
|
-
// Only publish to the job channel if this node is hired (targeted)
|
|
377
|
-
// This prevents open market bidders from spamming the task chat with "Thinking..." messages.
|
|
634
|
+
// Pre-hiring Radio Silence
|
|
378
635
|
if (metadata?.isTargeted) {
|
|
379
636
|
const chatPayload = {
|
|
380
637
|
id: crypto.randomUUID ? crypto.randomUUID() : (Math.random().toString(36).substring(2)),
|
|
381
638
|
job_id: jobId,
|
|
382
|
-
sender_id: config.nodeId || 'anonymous',
|
|
639
|
+
sender_id: robot.config.nodeId || 'anonymous',
|
|
383
640
|
sender_type: 'node',
|
|
384
641
|
content: text,
|
|
385
642
|
created_at: new Date().toISOString(),
|
|
386
|
-
auth_token: config.secretKey
|
|
643
|
+
auth_token: robot.config.secretKey
|
|
387
644
|
};
|
|
388
645
|
|
|
389
|
-
await js.publish(replySubject, new TextEncoder().encode(JSON.stringify(chatPayload)));
|
|
390
|
-
logger.info(`[pier-connector] 💬 Agent reply published
|
|
646
|
+
await robot.js.publish(replySubject, new TextEncoder().encode(JSON.stringify(chatPayload)));
|
|
647
|
+
logger.info(`[pier-connector][${accountId}] 💬 Agent reply published to NATS for job ${jobId}`);
|
|
391
648
|
} else {
|
|
392
|
-
logger.debug(`[pier-connector] 🤫 Pre-hiring radio silence
|
|
649
|
+
logger.debug(`[pier-connector][${accountId}] 🤫 Pre-hiring radio silence for job ${jobId}`);
|
|
393
650
|
}
|
|
394
651
|
} catch (err) {
|
|
395
|
-
logger.error(`[pier-connector] Failed to publish
|
|
652
|
+
logger.error(`[pier-connector][${accountId}] Failed to publish reply: ${err.message}`);
|
|
396
653
|
}
|
|
397
654
|
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (jobStopTimeouts.has(jobId)) {
|
|
402
|
-
clearTimeout(jobStopTimeouts.get(jobId));
|
|
403
|
-
}
|
|
655
|
+
// Handle listener timeout
|
|
656
|
+
if (robot.jobSubscriptions.has(jobId)) {
|
|
657
|
+
if (robot.jobStopTimeouts.has(jobId)) clearTimeout(robot.jobStopTimeouts.get(jobId));
|
|
404
658
|
|
|
405
659
|
const timeout = setTimeout(() => {
|
|
406
|
-
const sub = jobSubscriptions.get(jobId);
|
|
660
|
+
const sub = robot.jobSubscriptions.get(jobId);
|
|
407
661
|
if (sub) {
|
|
408
|
-
logger.info(`[pier-connector] 🛑 Stopping listener for job ${jobId} (inactivity)`);
|
|
662
|
+
logger.info(`[pier-connector][${accountId}] 🛑 Stopping listener for job ${jobId} (inactivity)`);
|
|
409
663
|
sub.stop();
|
|
410
|
-
jobSubscriptions.delete(jobId);
|
|
411
|
-
jobStopTimeouts.delete(jobId);
|
|
664
|
+
robot.jobSubscriptions.delete(jobId);
|
|
665
|
+
robot.jobStopTimeouts.delete(jobId);
|
|
412
666
|
}
|
|
413
|
-
}, 3600000); // 1 hour
|
|
414
|
-
|
|
415
|
-
jobStopTimeouts.set(jobId, timeout);
|
|
667
|
+
}, 3600000); // 1 hour
|
|
668
|
+
robot.jobStopTimeouts.set(jobId, timeout);
|
|
416
669
|
}
|
|
417
670
|
}
|
|
418
671
|
|
|
@@ -427,14 +680,15 @@ export default function register(api) {
|
|
|
427
680
|
connected: false,
|
|
428
681
|
},
|
|
429
682
|
buildAccountSnapshot: ({ account }) => {
|
|
683
|
+
const robot = instances.get(account.accountId);
|
|
430
684
|
return {
|
|
431
685
|
accountId: account.accountId,
|
|
432
|
-
name: account.name
|
|
686
|
+
name: account.name || `Robot ${account.accountId}`,
|
|
433
687
|
enabled: account.enabled,
|
|
434
688
|
configured: Boolean(account.nodeId && account.secretKey) || Boolean(account.privateKey),
|
|
435
|
-
running: connectionStatus === 'connected' || connectionStatus === 'connecting',
|
|
436
|
-
connected: connectionStatus === 'connected' && !lastHeartbeatError,
|
|
437
|
-
lastError: lastHeartbeatError,
|
|
689
|
+
running: robot ? (robot.connectionStatus === 'connected' || robot.connectionStatus === 'connecting') : false,
|
|
690
|
+
connected: robot ? (robot.connectionStatus === 'connected' && !robot.lastHeartbeatError) : false,
|
|
691
|
+
lastError: robot?.lastHeartbeatError || null,
|
|
438
692
|
};
|
|
439
693
|
}
|
|
440
694
|
},
|
|
@@ -448,530 +702,31 @@ export default function register(api) {
|
|
|
448
702
|
id: 'pier-connector',
|
|
449
703
|
|
|
450
704
|
start: async () => {
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
logger.info('[pier-connector] 🚀 Starting background service …');
|
|
705
|
+
const configs = resolveConfigs();
|
|
706
|
+
logger.info(`[pier-connector] 🚀 Starting background service for ${configs.length} robots …`);
|
|
454
707
|
|
|
455
|
-
|
|
456
|
-
// 1. Mandatory credentials check or Auto Register
|
|
457
|
-
if (!config.nodeId || !config.secretKey) {
|
|
458
|
-
if (config.privateKey) {
|
|
459
|
-
logger.info('[pier-connector] 🛠 No Node ID found. Executing automatic registration via Private Key...');
|
|
460
|
-
try {
|
|
461
|
-
const wallet = new ethers.Wallet(config.privateKey);
|
|
462
|
-
const address = wallet.address;
|
|
463
|
-
logger.info(`[pier-connector] 🛠 Wallet Address: ${address}`);
|
|
464
|
-
|
|
465
|
-
const challengeRes = await fetch(`${config.pierApiUrl}/auth/challenge?wallet_address=${address}`);
|
|
466
|
-
if (!challengeRes.ok) throw new Error("Failed to get challenge");
|
|
467
|
-
const { challenge } = await challengeRes.json();
|
|
468
|
-
|
|
469
|
-
const signature = await wallet.signMessage(challenge);
|
|
470
|
-
|
|
471
|
-
const loginRes = await fetch(`${config.pierApiUrl}/auth/login`, {
|
|
472
|
-
method: 'POST',
|
|
473
|
-
headers: { 'Content-Type': 'application/json' },
|
|
474
|
-
body: JSON.stringify({ wallet_address: address, challenge, signature })
|
|
475
|
-
});
|
|
476
|
-
if (!loginRes.ok) throw new Error("Failed to login");
|
|
477
|
-
const { api_key } = await loginRes.json();
|
|
478
|
-
|
|
479
|
-
logger.info(`[pier-connector] 🛠 Authenticated. Registering node...`);
|
|
480
|
-
|
|
481
|
-
const hostName = api?.getRuntimeInfo?.()?.hostname ?? 'Auto-Node';
|
|
482
|
-
const regRes = await fetch(`${config.pierApiUrl}/nodes/register`, {
|
|
483
|
-
method: 'POST',
|
|
484
|
-
headers: {
|
|
485
|
-
'Content-Type': 'application/json',
|
|
486
|
-
'Authorization': `Bearer ${api_key}`
|
|
487
|
-
},
|
|
488
|
-
body: JSON.stringify({ wallet_address: address, name: hostName })
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
if (!regRes.ok) throw new Error("Failed to register node");
|
|
492
|
-
const { id, secret_key } = await regRes.json();
|
|
493
|
-
|
|
494
|
-
logger.info(`[pier-connector] ✔ Node registered automatically: ${id}`);
|
|
495
|
-
config.nodeId = id;
|
|
496
|
-
config.secretKey = secret_key;
|
|
497
|
-
if (!config.walletAddress) {
|
|
498
|
-
config.walletAddress = address;
|
|
499
|
-
}
|
|
500
|
-
} catch (err) {
|
|
501
|
-
logger.error(`[pier-connector] ✖ Auto-registration failed: ${err.message}`);
|
|
502
|
-
connectionStatus = 'error';
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
} else {
|
|
506
|
-
logger.warn('[pier-connector] ⚠️ Bot Node ID or Secret is missing.');
|
|
507
|
-
logger.warn('[pier-connector] Please run "openclaw pier setup" or provide a privateKey for auto-hosting.');
|
|
508
|
-
connectionStatus = 'error';
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
connectionStatus = 'connecting';
|
|
514
|
-
|
|
515
|
-
// 2. Initial Pulse & Config fetch
|
|
516
|
-
logger.info(`[pier-connector] 💓 Sending initial pulse to ${config.pierApiUrl}...`);
|
|
517
|
-
const natsUpdate = await heartbeatNode(config);
|
|
518
|
-
const activeNatsUrl = natsUpdate?.url || config.natsUrl;
|
|
519
|
-
|
|
520
|
-
logger.info(`[pier-connector] Node ID : ${config.nodeId}`);
|
|
521
|
-
logger.info(`[pier-connector] NATS URL : ${activeNatsUrl}`);
|
|
522
|
-
|
|
523
|
-
// 3. Start Heartbeat Timer
|
|
524
|
-
const runHeartbeat = async () => {
|
|
525
|
-
const currentConfig = getActiveConfig();
|
|
526
|
-
if (!currentConfig.nodeId) return;
|
|
527
|
-
await heartbeatNode(currentConfig);
|
|
528
|
-
};
|
|
529
|
-
heartbeatTimer = setInterval(runHeartbeat, 60000);
|
|
530
|
-
|
|
531
|
-
// 4. Connect to NATS
|
|
532
|
-
nc = await createNatsConnection(activeNatsUrl, logger);
|
|
533
|
-
js = jetstream(nc);
|
|
534
|
-
jsm = await jetstreamManager(nc);
|
|
535
|
-
connectionStatus = 'connected';
|
|
536
|
-
connectedAt = new Date();
|
|
537
|
-
|
|
538
|
-
// 5. Subscribe to Subjects
|
|
539
|
-
const publicSubject = config.subject;
|
|
540
|
-
const privateSubject = `jobs.node.${config.nodeId}`;
|
|
541
|
-
// 5a. Restore active job subscriptions (BUG-32)
|
|
708
|
+
for (const config of configs) {
|
|
542
709
|
try {
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const jobsResp = await fetch(`${activeConfig.apiBase}/jobs?assigned_node_id=${config.nodeId}&limit=50`);
|
|
547
|
-
if (jobsResp.ok) {
|
|
548
|
-
const jobsList = await jobsResp.json();
|
|
549
|
-
const activeJobs = (jobsList || []).filter(j => j.status === 'PENDING' || j.status === 'PROCESSING');
|
|
550
|
-
if (activeJobs.length > 0) {
|
|
551
|
-
logger.info(`[pier-connector] ♻️ Restoring ${activeJobs.length} active job subscriptions...`);
|
|
552
|
-
for (const job of activeJobs) {
|
|
553
|
-
if (!jobSubscriptions.has(job.id)) {
|
|
554
|
-
activeNodeJobs.set(job.id, { pierJobId: job.id });
|
|
555
|
-
await subscribeToJobMessages(job.id);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
} else {
|
|
559
|
-
logger.info('[pier-connector] No active jobs found to restore.');
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
710
|
+
const robot = new PierRobot(config);
|
|
711
|
+
instances.set(config.accountId, robot);
|
|
712
|
+
await robot.start();
|
|
563
713
|
} catch (err) {
|
|
564
|
-
logger.
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Public pool with broadcast via JetStream Durable Consumer
|
|
568
|
-
if (publicSubject) {
|
|
569
|
-
// For Bidding, every node needs to see the marketplace requests to evaluate them,
|
|
570
|
-
// so we do not use the shared queueGroup. We use a unique durable per node.
|
|
571
|
-
const durableName = `pier_market_${config.nodeId.replace(/-/g, '_')}`;
|
|
572
|
-
const streamName = 'PIER_JOBS';
|
|
573
|
-
logger.info(`[pier-connector] 👂 Listening to Marketplace (JetStream): ${publicSubject} (Durable: ${durableName})`);
|
|
574
|
-
|
|
575
|
-
(async () => {
|
|
576
|
-
try {
|
|
577
|
-
// SDK-5: Try to get existing consumer; if config changed, delete and recreate
|
|
578
|
-
let consumer;
|
|
579
|
-
try {
|
|
580
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
581
|
-
} catch {
|
|
582
|
-
// Consumer doesn't exist yet, create it
|
|
583
|
-
logger.info(`[pier-connector] Creating new Marketplace Consumer: ${durableName}`);
|
|
584
|
-
try {
|
|
585
|
-
await jsm.consumers.add(streamName, {
|
|
586
|
-
durable_name: durableName,
|
|
587
|
-
filter_subject: publicSubject,
|
|
588
|
-
deliver_policy: DeliverPolicy.New, // SDK-3: Only new messages
|
|
589
|
-
ack_policy: AckPolicy.Explicit,
|
|
590
|
-
// Removed deliver_group to ensure it's a broadcast to all independent nodes
|
|
591
|
-
});
|
|
592
|
-
} catch (addErr) {
|
|
593
|
-
// SDK-5: Config mismatch — delete old and recreate
|
|
594
|
-
logger.warn(`[pier-connector] Consumer config conflict, recreating: ${addErr.message}`);
|
|
595
|
-
try { await jsm.consumers.delete(streamName, durableName); } catch { /* ignore */ }
|
|
596
|
-
await jsm.consumers.add(streamName, {
|
|
597
|
-
durable_name: durableName,
|
|
598
|
-
filter_subject: publicSubject,
|
|
599
|
-
deliver_policy: DeliverPolicy.New,
|
|
600
|
-
ack_policy: AckPolicy.Explicit,
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const iter = await consumer.consume();
|
|
607
|
-
for await (const msg of iter) {
|
|
608
|
-
handleMessage(msg).catch(err => {
|
|
609
|
-
logger.error(`[pier-connector] Fatal handleMessage error: ${err.message}`);
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
} catch (err) {
|
|
613
|
-
logger.error(`[pier-connector] Marketplace JetStream error: ${err.message}`);
|
|
614
|
-
}
|
|
615
|
-
})();
|
|
616
|
-
} else {
|
|
617
|
-
logger.warn('[pier-connector] ⚠ No Marketplace subject defined. Skipping.');
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Private direct channel (JetStream Durable per Node)
|
|
621
|
-
if (privateSubject) {
|
|
622
|
-
const durableName = `pier_node_${config.nodeId.replace(/-/g, '_')}`;
|
|
623
|
-
const streamName = 'PIER_JOBS';
|
|
624
|
-
logger.info(`[pier-connector] 👂 Listening to Direct Messages (JetStream): ${privateSubject} (Durable: ${durableName})`);
|
|
625
|
-
|
|
626
|
-
(async () => {
|
|
627
|
-
try {
|
|
628
|
-
// SDK-5: Try to get existing consumer; if config changed, delete and recreate
|
|
629
|
-
let consumer;
|
|
630
|
-
try {
|
|
631
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
632
|
-
} catch {
|
|
633
|
-
logger.info(`[pier-connector] Creating new Direct Consumer: ${durableName}`);
|
|
634
|
-
try {
|
|
635
|
-
await jsm.consumers.add(streamName, {
|
|
636
|
-
durable_name: durableName,
|
|
637
|
-
filter_subject: privateSubject,
|
|
638
|
-
deliver_policy: DeliverPolicy.New, // SDK-4: Only new messages
|
|
639
|
-
ack_policy: AckPolicy.Explicit
|
|
640
|
-
});
|
|
641
|
-
} catch (addErr) {
|
|
642
|
-
logger.warn(`[pier-connector] Consumer config conflict, recreating: ${addErr.message}`);
|
|
643
|
-
try { await jsm.consumers.delete(streamName, durableName); } catch { /* ignore */ }
|
|
644
|
-
await jsm.consumers.add(streamName, {
|
|
645
|
-
durable_name: durableName,
|
|
646
|
-
filter_subject: privateSubject,
|
|
647
|
-
deliver_policy: DeliverPolicy.New,
|
|
648
|
-
ack_policy: AckPolicy.Explicit
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const iter = await consumer.consume();
|
|
655
|
-
for await (const msg of iter) {
|
|
656
|
-
handleMessage(msg).catch(err => {
|
|
657
|
-
logger.error(`[pier-connector] Fatal handleMessage error: ${err.message}`);
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
} catch (err) {
|
|
661
|
-
logger.error(`[pier-connector] Direct JetStream error: ${err.message}`);
|
|
662
|
-
}
|
|
663
|
-
})();
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Reusable job message subscriber
|
|
667
|
-
async function subscribeToJobMessages(jobId) {
|
|
668
|
-
if (jobSubscriptions.has(jobId)) {
|
|
669
|
-
// Already subscribed, just reset timeout
|
|
670
|
-
if (jobStopTimeouts.has(jobId)) {
|
|
671
|
-
clearTimeout(jobStopTimeouts.get(jobId));
|
|
672
|
-
jobStopTimeouts.delete(jobId);
|
|
673
|
-
}
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const msgSubject = `jobs.job.${jobId}.msg`;
|
|
678
|
-
const streamName = 'PIER_JOBS';
|
|
679
|
-
|
|
680
|
-
try {
|
|
681
|
-
// Use a unique durable name per node and job to avoid replaying acknowledged messages
|
|
682
|
-
const durableName = `pier_chat_${config.nodeId.replace(/[^a-zA-Z0-9]/g, '_')}_${jobId.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
683
|
-
|
|
684
|
-
let consumer;
|
|
685
|
-
try {
|
|
686
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
687
|
-
} catch (err) {
|
|
688
|
-
// Only add if it doesn't exist
|
|
689
|
-
await jsm.consumers.add(streamName, {
|
|
690
|
-
durable_name: durableName,
|
|
691
|
-
filter_subject: msgSubject,
|
|
692
|
-
deliver_policy: DeliverPolicy.New, // Reverted to New to stop message storms (BUG-27)
|
|
693
|
-
ack_policy: AckPolicy.Explicit, // Explicit ACK is crucial for deduplication
|
|
694
|
-
ack_wait: 1000 * 60 * 60, // 1 hour ACK wait to allow for long processing (BUG-30)
|
|
695
|
-
});
|
|
696
|
-
consumer = await js.consumers.get(streamName, durableName);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const iter = await consumer.consume();
|
|
700
|
-
jobSubscriptions.set(jobId, iter);
|
|
701
|
-
|
|
702
|
-
(async () => {
|
|
703
|
-
logger.info(`[pier-connector] 👂 Monitoring chat subject ${msgSubject} (Durable: ${durableName})`);
|
|
704
|
-
|
|
705
|
-
const processedMessages = new Set();
|
|
706
|
-
|
|
707
|
-
for await (const msg of iter) {
|
|
708
|
-
try {
|
|
709
|
-
const rawMsg = new TextDecoder().decode(msg.data);
|
|
710
|
-
let msgPayload;
|
|
711
|
-
try {
|
|
712
|
-
msgPayload = JSON.parse(rawMsg);
|
|
713
|
-
} catch (e) {
|
|
714
|
-
msg.ack();
|
|
715
|
-
continue;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Deduplication (prevents loops if NATS redelivers)
|
|
719
|
-
if (msgPayload.id && processedMessages.has(msgPayload.id)) {
|
|
720
|
-
msg.ack();
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Ignore my own messages
|
|
725
|
-
if (msgPayload.sender_id === config.nodeId) {
|
|
726
|
-
msg.ack();
|
|
727
|
-
continue;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (msgPayload.type === 'receipt') {
|
|
731
|
-
msg.ack();
|
|
732
|
-
continue;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Send read receipt back (ONLY for actual messages from users, BUG-31)
|
|
736
|
-
if (js && msgPayload.id && !msgPayload.type && msgPayload.sender_id !== config.nodeId) {
|
|
737
|
-
try {
|
|
738
|
-
const replySubject = `jobs.job.${jobId}.msg`;
|
|
739
|
-
await js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
|
|
740
|
-
type: 'receipt',
|
|
741
|
-
msg_id: msgPayload.id,
|
|
742
|
-
reader_id: config.nodeId
|
|
743
|
-
})));
|
|
744
|
-
} catch (err) {
|
|
745
|
-
// Silently ignore receipt failures
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
const content = msgPayload.content;
|
|
750
|
-
const senderCore = msgPayload.sender_id;
|
|
751
|
-
|
|
752
|
-
if (!content) {
|
|
753
|
-
msg.ack();
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
logger.info(`[pier-connector] 📥 Incoming chat: [${jobId}] sender=${senderCore} "${truncate(content, 40)}"`);
|
|
758
|
-
|
|
759
|
-
if (msgPayload.id) processedMessages.add(msgPayload.id);
|
|
760
|
-
|
|
761
|
-
// ACK IMMEDIATELY to prevent NATS 30s timeout (BUG-27/30)
|
|
762
|
-
msg.ack();
|
|
763
|
-
|
|
764
|
-
activeNodeJobs.set(jobId, {
|
|
765
|
-
pierJobId: jobId,
|
|
766
|
-
isRealtimeMsg: true
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
// Trigger agent (Asynchronously to NOT block the loop)
|
|
770
|
-
(async () => {
|
|
771
|
-
try {
|
|
772
|
-
logger.info(`[pier-connector] 🧠 Triggering agent for job ${jobId}...`);
|
|
773
|
-
await receiveIncoming({
|
|
774
|
-
accountId: config.accountId || 'default',
|
|
775
|
-
senderId: `pier:${senderCore}`,
|
|
776
|
-
text: content,
|
|
777
|
-
}, jobId);
|
|
778
|
-
} catch (err) {
|
|
779
|
-
logger.error(`[pier-connector] Agent execution error: ${err.message}`);
|
|
780
|
-
}
|
|
781
|
-
})();
|
|
782
|
-
} catch (err) {
|
|
783
|
-
logger.error(`[pier-connector] Chat message processing error for ${jobId}: ${err.message}`);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
})().catch(err => {
|
|
787
|
-
logger.error(`[pier-connector] Chat iteration died for ${jobId}: ${err.message}`);
|
|
788
|
-
jobSubscriptions.delete(jobId);
|
|
789
|
-
});
|
|
790
|
-
} catch (err) {
|
|
791
|
-
logger.error(`[pier-connector] Failed to setup chat consumer for ${jobId}: ${err.message}`);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Unified Message Handler
|
|
796
|
-
async function handleMessage(msg) {
|
|
797
|
-
const startTime = performance.now();
|
|
798
|
-
const rawData = new TextDecoder().decode(msg.data);
|
|
799
|
-
|
|
800
|
-
let payload;
|
|
801
|
-
try {
|
|
802
|
-
payload = JSON.parse(rawData);
|
|
803
|
-
} catch (e) {
|
|
804
|
-
logger.error(`[pier-connector] Failed to parse JSON: ${rawData.substring(0, 100)}`);
|
|
805
|
-
msg.ack();
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// WAKEUP SIGNAL HANDLING (BUG-26/29)
|
|
810
|
-
if (payload.type === 'wakeup') {
|
|
811
|
-
const { jobId } = payload;
|
|
812
|
-
if (jobId) {
|
|
813
|
-
logger.info(`[pier-connector] ⏰ Received wakeup signal for job ${jobId}`);
|
|
814
|
-
if (jobSubscriptions.has(jobId)) {
|
|
815
|
-
logger.info(`[pier-connector] 👂 Already monitoring job ${jobId}, skipping duplicate subscription.`);
|
|
816
|
-
} else {
|
|
817
|
-
// Important: Map the job ID so we know where to reply
|
|
818
|
-
activeNodeJobs.set(jobId, { pierJobId: jobId });
|
|
819
|
-
await subscribeToJobMessages(jobId);
|
|
820
|
-
}
|
|
821
|
-
msg.ack();
|
|
822
|
-
} else {
|
|
823
|
-
logger.warn('[pier-connector] ⚠️ Wakeup signal received but jobId is missing.');
|
|
824
|
-
msg.ack();
|
|
825
|
-
}
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// Anti-Interference: If busy, ignore new marketplace jobs (BUG-34)
|
|
830
|
-
if (isBusy) {
|
|
831
|
-
logger.debug(`[pier-connector] 🚫 Busy. Ignoring new job payload: ${rawData.substring(0, 50)}...`);
|
|
832
|
-
// NAK so others in the queue group can take it
|
|
833
|
-
msg.nak();
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// V1.1 FEATURE: Task Poisoning / Poaching Protection
|
|
838
|
-
if (payload.assigned_node_id && payload.assigned_node_id !== config.nodeId) {
|
|
839
|
-
// Not for us, nak it so others can take it
|
|
840
|
-
msg.nak();
|
|
841
|
-
return;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// For Marketplace jobs: if assigned_node_id IS NOT present, it's open!
|
|
845
|
-
// Do NOT claim it, let the agent evaluate and use pier_bid_task instead.
|
|
846
|
-
const jobIdToClaim = payload.id;
|
|
847
|
-
const isTargeted = !!(payload.assigned_node_id || payload.targetNodeId || payload.TargetNodeID);
|
|
848
|
-
|
|
849
|
-
if (jobIdToClaim && isTargeted) {
|
|
850
|
-
const claimResult = await claimJob(jobIdToClaim);
|
|
851
|
-
if (!claimResult.ok) {
|
|
852
|
-
if (claimResult.alreadyClaimed) {
|
|
853
|
-
// Already taken, ACK it so we don't keep getting redelivered
|
|
854
|
-
msg.ack();
|
|
855
|
-
} else {
|
|
856
|
-
// Real error, NAK it just in case someone else can try
|
|
857
|
-
msg.nak();
|
|
858
|
-
}
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// For an open bidding job, we don't lock the DB but we do set the node `isBusy` temporarily
|
|
864
|
-
// while it thinks whether to bid or not.
|
|
865
|
-
isBusy = true;
|
|
866
|
-
msg.ack();
|
|
867
|
-
|
|
868
|
-
jobsReceived++;
|
|
869
|
-
const parsed = parseJob(msg, logger);
|
|
870
|
-
if (!parsed.ok) {
|
|
871
|
-
jobsFailed++;
|
|
872
|
-
isBusy = false; // Reset busy state on parse error
|
|
873
|
-
logger.error(`[pier-connector] Job parse error: ${parsed.error}`);
|
|
874
|
-
safeRespond(msg, createErrorPayload({
|
|
875
|
-
id: jobIdToClaim || 'unknown',
|
|
876
|
-
errorCode: 'PARSE_ERROR',
|
|
877
|
-
errorMessage: parsed.error,
|
|
878
|
-
workerId: config.nodeId,
|
|
879
|
-
walletAddress: config.walletAddress,
|
|
880
|
-
}));
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const { job } = parsed;
|
|
885
|
-
logger.info(`[pier-connector] 📥 Received job ${job.id}: "${truncate(job.task, 60)}"`);
|
|
886
|
-
|
|
887
|
-
try {
|
|
888
|
-
const senderCore = job.meta?.sender ?? 'anonymous';
|
|
889
|
-
|
|
890
|
-
activeNodeJobs.set(job.id, {
|
|
891
|
-
pierJobId: job.id,
|
|
892
|
-
pierNatsMsg: msg,
|
|
893
|
-
pierStartTime: performance.now(),
|
|
894
|
-
pierMeta: job.meta,
|
|
895
|
-
isRealtimeMsg: false,
|
|
896
|
-
isTargeted: isTargeted
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
let finalText = job.task;
|
|
900
|
-
if (!isTargeted) {
|
|
901
|
-
finalText = `【PIER MARKETPLACE OPEN JOB】
|
|
902
|
-
Job ID: ${job.id}
|
|
903
|
-
Task description:
|
|
904
|
-
${job.task}
|
|
905
|
-
|
|
906
|
-
=== CRITICAL WARNING ===
|
|
907
|
-
You MUST NOT execute or solve this task directly! This is a public request for proposals.
|
|
908
|
-
You MUST reply by calling the \`pier_bid_task\` tool to submit a pitch explaining why you are best suited, and optionally your price.
|
|
909
|
-
You MUST use the Job ID provided above when calling the tool.
|
|
910
|
-
Any direct answer will be considered a failure to follow protocol.`;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const inbound = {
|
|
914
|
-
accountId: config.accountId || 'default',
|
|
915
|
-
senderId: `pier:${senderCore}`,
|
|
916
|
-
text: finalText,
|
|
917
|
-
};
|
|
918
|
-
|
|
919
|
-
// SUBSCRIBE to job-specific messages FIRST so we don't miss follow-ups
|
|
920
|
-
subscribeToJobMessages(job.id);
|
|
921
|
-
|
|
922
|
-
// Trigger agent run
|
|
923
|
-
await receiveIncoming(inbound, job.id);
|
|
924
|
-
|
|
925
|
-
// NOTE: isBusy is reset when the agent completes.
|
|
926
|
-
// Because receiveIncoming (via OpenClaw core) might be async or callback-based,
|
|
927
|
-
// we need to be careful. However, for standard MCP tools/agents,
|
|
928
|
-
// we'll assume isBusy should stay true until the job lifecycle is managed.
|
|
929
|
-
// For now, simple reset after agent returns or in error.
|
|
930
|
-
isBusy = false;
|
|
931
|
-
|
|
932
|
-
} catch (err) {
|
|
933
|
-
isBusy = false;
|
|
934
|
-
jobsFailed++;
|
|
935
|
-
safeRespond(msg, createErrorPayload({
|
|
936
|
-
id: (job && job.id) || 'unknown',
|
|
937
|
-
errorCode: 'EXECUTION_FAILED',
|
|
938
|
-
errorMessage: err.message,
|
|
939
|
-
workerId: config.nodeId,
|
|
940
|
-
walletAddress: config.walletAddress,
|
|
941
|
-
}));
|
|
942
|
-
}
|
|
714
|
+
logger.error(`[pier-connector] ✖ Failed to start robot ${config.accountId}: ${err.message}`);
|
|
943
715
|
}
|
|
944
|
-
|
|
945
|
-
// Monitor connection closure
|
|
946
|
-
nc.closed().then((err) => {
|
|
947
|
-
connectionStatus = 'disconnected';
|
|
948
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
949
|
-
if (err) {
|
|
950
|
-
logger.error(`[pier-connector] NATS connection closed with error: ${err.message}`);
|
|
951
|
-
} else {
|
|
952
|
-
logger.info('[pier-connector] NATS connection closed gracefully');
|
|
953
|
-
}
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
} catch (err) {
|
|
957
|
-
connectionStatus = 'error';
|
|
958
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
959
|
-
logger.error(`[pier-connector] ✖ Failed to start: ${err.message}`);
|
|
960
|
-
logger.error(err.stack);
|
|
961
716
|
}
|
|
962
717
|
},
|
|
963
718
|
|
|
964
719
|
stop: async () => {
|
|
965
|
-
logger.info('[pier-connector] 🛑 Stopping
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
720
|
+
logger.info('[pier-connector] 🛑 Stopping all robots …');
|
|
721
|
+
for (const [id, robot] of instances) {
|
|
722
|
+
try {
|
|
723
|
+
await robot.stop();
|
|
724
|
+
} catch (err) {
|
|
725
|
+
logger.error(`[pier-connector] ✖ Error stopping robot ${id}: ${err.message}`);
|
|
726
|
+
}
|
|
971
727
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
logger.info('[pier-connector] ✔ Background service stopped');
|
|
728
|
+
instances.clear();
|
|
729
|
+
logger.info('[pier-connector] ✔ All robots stopped');
|
|
975
730
|
},
|
|
976
731
|
});
|
|
977
732
|
|
|
@@ -980,100 +735,34 @@ Any direct answer will be considered a failure to follow protocol.`;
|
|
|
980
735
|
api.registerTool(
|
|
981
736
|
{
|
|
982
737
|
name: 'pier_publish',
|
|
983
|
-
description:
|
|
984
|
-
'Publish a task to the Pier job marketplace. Other OpenClaw instances ' +
|
|
985
|
-
'subscribed to the Pier platform will receive and process the task.',
|
|
738
|
+
description: 'Publish a task to the Pier job marketplace.',
|
|
986
739
|
parameters: {
|
|
987
740
|
type: 'object',
|
|
988
741
|
properties: {
|
|
989
|
-
task: {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
},
|
|
993
|
-
meta: {
|
|
994
|
-
type: 'object',
|
|
995
|
-
description: 'Optional metadata to attach to the task',
|
|
996
|
-
},
|
|
997
|
-
timeoutMs: {
|
|
998
|
-
type: 'number',
|
|
999
|
-
description: 'Timeout in milliseconds to wait for a result (default 60000)',
|
|
1000
|
-
},
|
|
1001
|
-
parentJobId: {
|
|
1002
|
-
type: 'string',
|
|
1003
|
-
description: 'Optional ID of the parent job triggering this sub-task (for delegation)',
|
|
1004
|
-
},
|
|
742
|
+
task: { type: 'string', description: 'The task description' },
|
|
743
|
+
accountId: { type: 'string', description: 'Account to use (default: "default")' },
|
|
744
|
+
meta: { type: 'object' },
|
|
745
|
+
timeoutMs: { type: 'number' },
|
|
1005
746
|
},
|
|
1006
747
|
required: ['task'],
|
|
1007
748
|
},
|
|
1008
749
|
|
|
1009
750
|
async execute(_id, params) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
type: 'text',
|
|
1015
|
-
text: JSON.stringify({
|
|
1016
|
-
ok: false,
|
|
1017
|
-
error: 'NATS not connected — cannot publish task',
|
|
1018
|
-
}),
|
|
1019
|
-
},
|
|
1020
|
-
],
|
|
1021
|
-
};
|
|
751
|
+
const accountId = params.accountId || 'default';
|
|
752
|
+
const robot = instances.get(accountId) || instances.values().next().value;
|
|
753
|
+
if (!robot || robot.connectionStatus !== 'connected') {
|
|
754
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Robot not connected' }) }] };
|
|
1022
755
|
}
|
|
1023
756
|
|
|
1024
|
-
const config = getActiveConfig();
|
|
1025
757
|
const timeout = params.timeoutMs || 60000;
|
|
1026
|
-
|
|
1027
|
-
const taskPayload = createRequestPayload({
|
|
1028
|
-
task: params.task,
|
|
1029
|
-
timeoutMs: timeout,
|
|
1030
|
-
meta: params.meta,
|
|
1031
|
-
parentJobId: params.parentJobId,
|
|
1032
|
-
});
|
|
758
|
+
const taskPayload = createRequestPayload({ task: params.task, timeoutMs: timeout, meta: params.meta });
|
|
1033
759
|
|
|
1034
760
|
try {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
);
|
|
1039
|
-
|
|
1040
|
-
const reply = await nc.request(
|
|
1041
|
-
config.publishSubject,
|
|
1042
|
-
new TextEncoder().encode(JSON.stringify(taskPayload)),
|
|
1043
|
-
{ timeout }
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
let resultData;
|
|
1047
|
-
try {
|
|
1048
|
-
resultData = JSON.parse(new TextDecoder().decode(reply.data));
|
|
1049
|
-
} catch (e) {
|
|
1050
|
-
resultData = { raw: new TextDecoder().decode(reply.data) };
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
logger.info(`[pier-connector] ✔ Received result for task ${taskPayload.id}`);
|
|
1054
|
-
|
|
1055
|
-
return {
|
|
1056
|
-
content: [
|
|
1057
|
-
{
|
|
1058
|
-
type: 'text',
|
|
1059
|
-
text: JSON.stringify({
|
|
1060
|
-
ok: true,
|
|
1061
|
-
taskId: taskPayload.id,
|
|
1062
|
-
result: resultData,
|
|
1063
|
-
}),
|
|
1064
|
-
},
|
|
1065
|
-
],
|
|
1066
|
-
};
|
|
761
|
+
const reply = await robot.nc.request(robot.config.publishSubject, new TextEncoder().encode(JSON.stringify(taskPayload)), { timeout });
|
|
762
|
+
const resultData = JSON.parse(new TextDecoder().decode(reply.data));
|
|
763
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, taskId: taskPayload.id, result: resultData }) }] };
|
|
1067
764
|
} catch (err) {
|
|
1068
|
-
|
|
1069
|
-
return {
|
|
1070
|
-
content: [
|
|
1071
|
-
{
|
|
1072
|
-
type: 'text',
|
|
1073
|
-
text: JSON.stringify({ ok: false, error: err.message }),
|
|
1074
|
-
},
|
|
1075
|
-
],
|
|
1076
|
-
};
|
|
765
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] };
|
|
1077
766
|
}
|
|
1078
767
|
},
|
|
1079
768
|
},
|
|
@@ -1083,42 +772,36 @@ Any direct answer will be considered a failure to follow protocol.`;
|
|
|
1083
772
|
api.registerTool(
|
|
1084
773
|
{
|
|
1085
774
|
name: 'pier_chat',
|
|
1086
|
-
description: 'Send a message to the employer regarding a specific job.
|
|
775
|
+
description: 'Send a message to the employer regarding a specific job.',
|
|
1087
776
|
parameters: {
|
|
1088
777
|
type: 'object',
|
|
1089
778
|
properties: {
|
|
1090
|
-
jobId: {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
},
|
|
1094
|
-
text: {
|
|
1095
|
-
type: 'string',
|
|
1096
|
-
description: 'The message content'
|
|
1097
|
-
}
|
|
779
|
+
jobId: { type: 'string' },
|
|
780
|
+
text: { type: 'string' },
|
|
781
|
+
accountId: { type: 'string' }
|
|
1098
782
|
},
|
|
1099
783
|
required: ['jobId', 'text']
|
|
1100
784
|
},
|
|
1101
785
|
async execute(_id, params) {
|
|
1102
|
-
|
|
1103
|
-
|
|
786
|
+
const accountId = params.accountId || 'default';
|
|
787
|
+
const robot = instances.get(accountId) || instances.values().next().value;
|
|
788
|
+
if (!robot || robot.connectionStatus !== 'connected') {
|
|
789
|
+
return { content: [{ type: 'text', text: 'Error: Robot not connected' }] };
|
|
1104
790
|
}
|
|
1105
791
|
|
|
1106
792
|
try {
|
|
1107
|
-
|
|
1108
|
-
const config = getActiveConfig();
|
|
1109
793
|
const subject = `jobs.job.${params.jobId}.msg`;
|
|
1110
794
|
const payload = {
|
|
1111
|
-
id:
|
|
795
|
+
id: crypto.randomUUID ? crypto.randomUUID() : (Math.random().toString(36).substring(2)),
|
|
1112
796
|
job_id: params.jobId,
|
|
1113
|
-
sender_id: config.nodeId,
|
|
797
|
+
sender_id: robot.config.nodeId,
|
|
1114
798
|
sender_type: 'node',
|
|
1115
799
|
content: params.text,
|
|
1116
800
|
created_at: new Date().toISOString(),
|
|
1117
|
-
auth_token: config.secretKey
|
|
801
|
+
auth_token: robot.config.secretKey
|
|
1118
802
|
};
|
|
1119
803
|
|
|
1120
|
-
await js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
|
|
1121
|
-
|
|
804
|
+
await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
|
|
1122
805
|
return { content: [{ type: 'text', text: 'Message sent' }] };
|
|
1123
806
|
} catch (err) {
|
|
1124
807
|
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
@@ -1138,39 +821,35 @@ Any direct answer will be considered a failure to follow protocol.`;
|
|
|
1138
821
|
type: 'object',
|
|
1139
822
|
properties: {
|
|
1140
823
|
jobId: { type: 'string', description: 'The ID of the job/chat session' },
|
|
824
|
+
accountId: { type: 'string' },
|
|
1141
825
|
...extraParams
|
|
1142
826
|
},
|
|
1143
827
|
required: ['jobId', ...Object.keys(extraParams)]
|
|
1144
828
|
},
|
|
1145
829
|
async execute(_id, params) {
|
|
1146
|
-
|
|
1147
|
-
|
|
830
|
+
const accountId = params.accountId || 'default';
|
|
831
|
+
const robot = instances.get(accountId) || instances.values().next().value;
|
|
832
|
+
if (!robot || robot.connectionStatus !== 'connected') {
|
|
833
|
+
return { content: [{ type: 'text', text: 'Error: Robot not connected' }] };
|
|
1148
834
|
}
|
|
835
|
+
|
|
1149
836
|
try {
|
|
1150
|
-
const config = getActiveConfig();
|
|
1151
837
|
const subject = `jobs.job.${params.jobId}.msg`;
|
|
1152
|
-
|
|
1153
|
-
const { jobId, ...payload } = params;
|
|
838
|
+
const { jobId, accountId: _, ...payload } = params;
|
|
1154
839
|
|
|
1155
|
-
// If acting as User (Employer), we need the user API key ideally, but for demo we just pass node ID or secret.
|
|
1156
|
-
// The Backend handles sender_type="node" vs "user".
|
|
1157
840
|
const msgData = {
|
|
1158
|
-
id:
|
|
841
|
+
id: crypto.randomUUID ? crypto.randomUUID() : (Math.random().toString(36).substring(2)),
|
|
1159
842
|
job_id: params.jobId,
|
|
1160
|
-
sender_id: userRole === 'user' ? 'user_' + config.nodeId : config.nodeId,
|
|
843
|
+
sender_id: userRole === 'user' ? 'user_' + robot.config.nodeId : robot.config.nodeId,
|
|
1161
844
|
sender_type: userRole,
|
|
1162
|
-
content: JSON.stringify({
|
|
1163
|
-
type: 'system_action',
|
|
1164
|
-
action,
|
|
1165
|
-
payload
|
|
1166
|
-
}),
|
|
845
|
+
content: JSON.stringify({ type: 'system_action', action, payload }),
|
|
1167
846
|
created_at: new Date().toISOString(),
|
|
1168
|
-
auth_token: config.secretKey,
|
|
847
|
+
auth_token: robot.config.secretKey,
|
|
1169
848
|
type: 'system_action',
|
|
1170
849
|
action: action
|
|
1171
850
|
};
|
|
1172
851
|
|
|
1173
|
-
await js.publish(subject, new TextEncoder().encode(JSON.stringify(msgData)));
|
|
852
|
+
await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(msgData)));
|
|
1174
853
|
return { content: [{ type: 'text', text: `${action} executed successfully` }] };
|
|
1175
854
|
} catch (err) {
|
|
1176
855
|
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
@@ -1201,25 +880,29 @@ Any direct answer will be considered a failure to follow protocol.`;
|
|
|
1201
880
|
name: 'pier',
|
|
1202
881
|
description: 'Show Pier connector status',
|
|
1203
882
|
handler: () => {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
`•
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
}
|
|
883
|
+
if (instances.size === 0) return { text: 'No Pier robots active.' };
|
|
884
|
+
|
|
885
|
+
const lines = ['**Pier Connector Status**'];
|
|
886
|
+
for (const [id, robot] of instances) {
|
|
887
|
+
const uptime = robot.connectedAt
|
|
888
|
+
? `${Math.round((Date.now() - robot.connectedAt.getTime()) / 1000)}s`
|
|
889
|
+
: 'N/A';
|
|
890
|
+
|
|
891
|
+
lines.push(`\n**Account: ${id}**`);
|
|
892
|
+
lines.push(`• Connection: ${robot.connectionStatus}`);
|
|
893
|
+
lines.push(`• Node ID: ${robot.config.nodeId || 'N/A'}`);
|
|
894
|
+
lines.push(`• Uptime: ${uptime}`);
|
|
895
|
+
lines.push(`• Jobs: ${robot.stats.received} received, ${robot.stats.completed} completed, ${robot.stats.failed} failed`);
|
|
896
|
+
if (robot.lastHeartbeatError) {
|
|
897
|
+
lines.push(`• \x1b[31mHeartbeat Error: ${robot.lastHeartbeatError}\x1b[0m`);
|
|
898
|
+
} else {
|
|
899
|
+
lines.push(`• Heartbeat: OK`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
lines.push(`\n**Global Stats:** ${totalJobsReceived} total jobs received`);
|
|
904
|
+
|
|
905
|
+
return { text: lines.join('\n') };
|
|
1223
906
|
},
|
|
1224
907
|
});
|
|
1225
908
|
|