@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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 | null} */
38
- let jsm = null;
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
- let jobsReceived = 0;
56
- let jobsCompleted = 0;
57
- let jobsFailed = 0;
58
- let connectedAt = null;
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 resolveConfig() {
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
- const mergedCfg = {
71
- ...legacyCfg,
72
- ...firstAccount
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: accountId,
77
- pierApiUrl: mergedCfg.pierApiUrl || DEFAULTS.PIER_API_URL,
78
- nodeId: mergedCfg.nodeId || DEFAULTS.NODE_ID,
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
- /** Runtime config caching to remember newly generated auto-credentials */
91
- let runtimeConfigCache = null;
92
-
93
- function getActiveConfig() {
94
- if (runtimeConfigCache) return runtimeConfigCache;
95
- return resolveConfig();
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
- * Sends a heartbeat to the Pier Backend to stay active in the marketplace.
76
+ * PierRobot class encapsulates the connection, heartbeat, and job handling
77
+ * for a single robot account.
105
78
  */
106
- async function heartbeatNode(config) {
107
- if (!config.nodeId || !config.secretKey) return null;
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
- try {
110
- const resp = await fetch(`${config.pierApiUrl}/nodes/heartbeat`, {
111
- method: 'POST',
112
- headers: { 'Content-Type': 'application/json' },
113
- body: JSON.stringify({
114
- node_id: config.nodeId,
115
- secret_key: config.secretKey,
116
- capabilities: ['translation', 'code-execution', 'reasoning', 'vision'],
117
- description: `OpenClaw Node (${config.nodeId.substring(0, 8)})`,
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
- if (!resp.ok) {
122
- const errText = await resp.text();
123
- throw new Error(`Heartbeat failed (${resp.status}): ${errText}`);
124
- }
112
+ if (!resp.ok) {
113
+ const errText = await resp.text();
114
+ throw new Error(`Heartbeat failed (${resp.status}): ${errText}`);
115
+ }
125
116
 
126
- const data = await resp.json();
127
- lastHeartbeatError = null;
128
- return data.nats_config || null;
129
- } catch (err) {
130
- lastHeartbeatError = err.message;
131
- logger.warn(`[pier-connector] Heartbeat failed: ${err.message}`);
132
- return null;
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
- * Atomically claims a job from the backend before starting execution.
138
- * Ensures "one-job-at-a-time" exclusivity.
139
- */
140
- async function claimJob(jobId) {
141
- const config = getActiveConfig();
142
- try {
143
- const resp = await fetch(`${config.pierApiUrl}/jobs/${jobId}/claim`, {
144
- method: 'POST',
145
- headers: {
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
- if (!resp.ok) {
153
- if (resp.status === 409) {
154
- logger.warn(`[pier-connector] 🚫 Job ${jobId} was already claimed by someone else.`);
155
- return { ok: false, alreadyClaimed: true };
156
- } else {
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
- async function receiveIncoming(inbound, jobId) {
176
- if (!api.runtime?.channel?.reply) {
177
- logger.error(`[pier-connector] SDK Error: api.runtime.channel.reply is not available.`);
178
- return;
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
- const route = api.runtime.channel.routing.resolveAgentRoute({
182
- cfg: api.config,
183
- channel: 'pier',
184
- accountId: inbound.accountId,
185
- peer: {
186
- kind: 'direct',
187
- id: jobId
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
- const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
220
- Body: inbound.text,
221
- BodyForAgent: inbound.text,
222
- RawBody: inbound.text,
223
- From: inbound.senderId,
224
- To: `pier:${jobId}`,
225
- SessionKey: dynamicSessionKey,
226
- AccountId: route.accountId,
227
- ChatType: 'direct',
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
- if (!isRealtime) {
253
- collectedResult += payload.text;
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
- // Route Agent's reply to outbound.sendText
257
- // For marketplace jobs, sendText will now just log (content is buffered in collectedResult)
258
- // For realtime chat, it will publish to the chat subject immediately.
259
- await pierChannel.outbound.sendText({
260
- text: payload.text,
261
- to: `pier:${jobId}`,
262
- metadata,
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
- // Record session meta
268
- if (api.runtime.channel.session?.recordSessionMetaFromInbound) {
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
- const storePath = api.runtime.channel.session.resolveStorePath(api.config, dynamicSessionKey);
271
- await api.runtime.channel.session.recordSessionMetaFromInbound({
272
- storePath,
273
- sessionKey: dynamicSessionKey,
274
- ctx: ctxPayload
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.debug(`[pier-connector] Failed to record session meta: ${err.message}`);
489
+ logger.error(`[pier-connector][${this.accountId}] Setup failed: ${err.message}`);
490
+ throw err;
278
491
  }
279
492
  }
280
493
 
281
- try {
282
- logger.info(`[pier-connector] 🧠 Triggering agent for session ${dynamicSessionKey}...`);
283
- // Dispatch reply — this will trigger outbound: sendText in pierChannel
284
- await api.runtime.channel.reply.dispatchReplyFromConfig({
285
- ctx: ctxPayload,
286
- cfg: api.config,
287
- dispatcher
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
- logger.info(`[pier-connector] 🧠 Agent dispatch completed for ${dynamicSessionKey}`);
290
- } finally {
291
- markDispatchIdle();
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
- logger.info(`[pier-connector] 📤 Agent sending reply: "${truncate(text, 40)}" (To: ${ctx.to})`);
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) { // Ensure we have a jobId and NATS JetStream is available
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 // Secure token for backend validation
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 directly to NATS chat for job ${jobId}`);
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: suppressed chat for job ${jobId}`);
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 realtime reply to NATS: ${err.message}`);
652
+ logger.error(`[pier-connector][${accountId}] Failed to publish reply: ${err.message}`);
396
653
  }
397
654
 
398
- // Delayed stop for job-specific message listener
399
- // This allows for follow-up chat messages even after a job is "completed"
400
- if (jobSubscriptions.has(jobId)) {
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 inactivity timeout
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 config = resolveConfig();
452
- runtimeConfigCache = config;
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
- try {
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 activeConfig = getActiveConfig();
544
- if (activeConfig.apiBase) {
545
- logger.info(`[pier-connector] 🔍 Checking for active jobs to restore for node ${config.nodeId}...`);
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.warn(`[pier-connector] ⚠️ Failed to restore active jobs: ${err.message}`);
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 background service …');
966
- connectionStatus = 'stopping';
967
-
968
- if (nc) {
969
- await drainConnection(nc, logger);
970
- nc = null;
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
- connectionStatus = 'disconnected';
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
- type: 'string',
991
- description: 'The task description or prompt to publish',
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
- if (!nc || connectionStatus !== 'connected') {
1011
- return {
1012
- content: [
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
- logger.info(
1036
- `[pier-connector] 📤 Publishing task to "${config.publishSubject}" and awaiting reply (timeout ${timeout}ms) — ` +
1037
- `id: ${taskPayload.id}, task: "${truncate(params.task, 60)}"`,
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
- logger.error(`[pier-connector] Failed to publish or await task: ${err.message}`);
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. Use this for clarification or progress updates.',
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
- type: 'string',
1092
- description: 'The ID of the job to send the message for'
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
- if (!nc || connectionStatus !== 'connected') {
1103
- return { content: [{ type: 'text', text: 'Error: NATS not connected' }] };
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: ethers.hexlify(ethers.randomBytes(16)),
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 // Secure token for backend validation
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
- if (!nc || connectionStatus !== 'connected') {
1147
- return { content: [{ type: 'text', text: 'Error: NATS not connected' }] };
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: ethers.hexlify(ethers.randomBytes(16)),
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, // auth handle in DB needs updating for user role simulation
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
- const config = getActiveConfig();
1205
- const uptime = connectedAt
1206
- ? `${Math.round((Date.now() - connectedAt.getTime()) / 1000)}s`
1207
- : 'N/A';
1208
-
1209
- return {
1210
- text: [
1211
- `**Pier Connector Status**`,
1212
- `• Connection: ${connectionStatus}`,
1213
- `• Node ID: ${config.nodeId || 'N/A'}`,
1214
- `• API URL: ${config.pierApiUrl}`,
1215
- `• NATS URL: ${config.natsUrl}`,
1216
- `• Subscribe: ${config.subject}`,
1217
- `• Publish: ${config.publishSubject}`,
1218
- `• Uptime: ${uptime}`,
1219
- `• Jobs: ${jobsReceived} received, ${jobsCompleted} completed, ${jobsFailed} failed`,
1220
- lastHeartbeatError ? `• \x1b[31mHeartbeat Error: ${lastHeartbeatError}\x1b[0m` : `• Heartbeat: OK`,
1221
- ].join('\n'),
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