bereach-openclaw 1.5.0-beta.2 → 1.5.0-beta.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach-openclaw",
3
3
  "name": "BeReach",
4
- "version": "1.5.0-beta.2",
4
+ "version": "1.5.0-beta.3",
5
5
  "description": "LinkedIn outreach automation — 75+ tools, hook-based enforcement, dynamic context",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bereach-openclaw",
3
- "version": "1.5.0-beta.2",
3
+ "version": "1.5.0-beta.3",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "exports": {
@@ -32,7 +32,10 @@
32
32
  "test:e2e:check-regression": "tsx __tests__/e2e/lib/token-tracker.ts --check-regression",
33
33
  "test:e2e:upgrade-setup": "cd __tests__/docker && docker compose -f docker-compose.upgrade-test.yml up -d --build",
34
34
  "test:e2e:upgrade-teardown": "cd __tests__/docker && docker compose -f docker-compose.upgrade-test.yml down",
35
- "test:e2e:upgrade": "cd __tests__/docker && ./upgrade-test.sh"
35
+ "test:e2e:upgrade": "cd __tests__/docker && ./upgrade-test.sh",
36
+ "test:e2e:vps-setup": "cd __tests__/docker && docker compose -f docker-compose.vps-test.yml up -d --build",
37
+ "test:e2e:vps-teardown": "cd __tests__/docker && docker compose -f docker-compose.vps-test.yml down",
38
+ "test:e2e:vps-smoke": "cd __tests__/docker && ./vps-smoke-test.sh"
36
39
  },
37
40
  "openclaw": {
38
41
  "extensions": [
@@ -61,6 +61,86 @@ type TaskResult = {
61
61
 
62
62
  const MIN_POLL_MS = 5000;
63
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // TaskResult extraction from OpenClaw agent output
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Extract the last JSON block from agent output text.
70
+ * Copied from lifecycle.ts — the connector must be self-contained
71
+ * since it can run as a standalone CLI without lifecycle hooks.
72
+ */
73
+ function parseStructuredResult(text: string): Record<string, unknown> | null {
74
+ // Try fenced code blocks first
75
+ const jsonBlocks = text.match(/```(?:json)?\s*([\s\S]*?)```/g);
76
+ if (jsonBlocks?.length) {
77
+ const lastBlock = jsonBlocks[jsonBlocks.length - 1];
78
+ const jsonStr = lastBlock.replace(/^```(?:json)?\s*/, "").replace(/```$/, "").trim();
79
+ try {
80
+ return JSON.parse(jsonStr);
81
+ } catch { /* fall through */ }
82
+ }
83
+
84
+ // Try raw JSON at end of text
85
+ const lastBrace = text.lastIndexOf("{");
86
+ if (lastBrace >= 0) {
87
+ try {
88
+ return JSON.parse(text.slice(lastBrace));
89
+ } catch { /* fall through */ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Extract a TaskResult from OpenClaw --json wrapper output.
97
+ * OpenClaw wraps agent output as: { result: { payloads: [{text: "..."}], meta: {...} } }
98
+ * or sometimes: { payloads: [{text: "..."}], meta: {...} }
99
+ * The actual TaskResult JSON is embedded in the last payload's text field.
100
+ */
101
+ function extractTaskResultFromWrapper(output: Record<string, unknown>): TaskResult | null {
102
+ const payloads = (
103
+ (output?.result as Record<string, unknown>)?.payloads ??
104
+ output?.payloads ??
105
+ []
106
+ ) as { text?: string }[];
107
+
108
+ if (payloads.length === 0) return null;
109
+
110
+ // Walk payloads backwards — TaskResult JSON is typically in the last one
111
+ for (let i = payloads.length - 1; i >= 0; i--) {
112
+ const text = payloads[i]?.text;
113
+ if (text) {
114
+ const parsed = parseStructuredResult(text);
115
+ if (parsed && ("success" in parsed || "contactsProcessed" in parsed)) {
116
+ return parsed as TaskResult;
117
+ }
118
+ }
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ /** Stderr patterns that are noise (module warnings, not actual errors) */
125
+ const HARMLESS_STDERR_RE = [
126
+ /Cannot find module '@aws-sdk\/client-bedrock'/,
127
+ /Cannot find module 'amazon-bedrock'/,
128
+ /Require stack:/,
129
+ /ExperimentalWarning/,
130
+ /DEP\d+/,
131
+ ];
132
+
133
+ function filterStderr(raw: string): string {
134
+ return raw
135
+ .split("\n")
136
+ .filter((line) => {
137
+ const t = line.trim();
138
+ return t && !HARMLESS_STDERR_RE.some((p) => p.test(t));
139
+ })
140
+ .join("\n")
141
+ .trim();
142
+ }
143
+
64
144
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
65
145
  return new Promise((resolve) => {
66
146
  const delay = Math.max(MIN_POLL_MS, ms);
@@ -96,7 +176,7 @@ async function pullTask(config: ConnectorConfig): Promise<PullResponse> {
96
176
  if (!config.apiKey && config.connectorToken) {
97
177
  body.connectorToken = config.connectorToken;
98
178
  }
99
- return httpPost(`${config.apiUrl}/api/tasks/pull`, body, headers) as Promise<PullResponse>;
179
+ return httpPost(`${config.apiUrl}/tasks/pull`, body, headers) as Promise<PullResponse>;
100
180
  }
101
181
 
102
182
  async function submitResult(
@@ -110,7 +190,7 @@ async function submitResult(
110
190
  if (!config.apiKey && config.connectorToken) {
111
191
  body.connectorToken = config.connectorToken;
112
192
  }
113
- await httpPost(`${config.apiUrl}/api/tasks/${taskId}/result`, body, headers);
193
+ await httpPost(`${config.apiUrl}/tasks/${taskId}/result`, body, headers);
114
194
  }
115
195
 
116
196
  async function heartbeat(
@@ -122,7 +202,7 @@ async function heartbeat(
122
202
  if (!config.apiKey && config.connectorToken) {
123
203
  body.connectorToken = config.connectorToken;
124
204
  }
125
- return httpPost(`${config.apiUrl}/api/connectors/heartbeat`, body, headers) as Promise<{ pollIntervalMs: number }>;
205
+ return httpPost(`${config.apiUrl}/connectors/heartbeat`, body, headers) as Promise<{ pollIntervalMs: number }>;
126
206
  }
127
207
 
128
208
  /**
@@ -161,6 +241,25 @@ async function isWebhookAvailable(config: ConnectorConfig): Promise<boolean> {
161
241
  }
162
242
  }
163
243
 
244
+ /**
245
+ * Probe webhook with retries — gateway may still be starting (B9 fix).
246
+ */
247
+ async function probeWebhookWithRetry(
248
+ config: ConnectorConfig,
249
+ attempts = 3,
250
+ delayMs = 2000,
251
+ ): Promise<boolean> {
252
+ for (let i = 1; i <= attempts; i++) {
253
+ const ok = await isWebhookAvailable(config);
254
+ if (ok) return true;
255
+ if (i < attempts) {
256
+ console.log(`[connector] Webhook probe ${i}/${attempts} failed, retrying in ${delayMs / 1000}s...`);
257
+ await new Promise((r) => setTimeout(r, delayMs));
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+
164
263
  /**
165
264
  * Execute a task via the gateway /hooks/agent webhook.
166
265
  * This is the preferred execution path - uses OpenClaw's native agent runner
@@ -251,17 +350,27 @@ async function executeViaExecFile(
251
350
  task: NonNullable<PullResponse["task"]>,
252
351
  ): Promise<{ result: TaskResult | null; error: string | null }> {
253
352
  try {
353
+ // Use task ID as session ID. The sessionKey ("hook:{userId}:{campaignId}:{type}")
354
+ // contains colons which the gateway rejects as invalid session IDs.
355
+ // Task metadata is passed via TASK_META in the message instead.
254
356
  const sessionId = `task-${task.id}`;
255
- const message = task.message || `Execute ${task.type} task`;
357
+ const maxCredits = (task.payload as Record<string, unknown>)?.maxCredits ?? 100;
358
+
359
+ // The `openclaw agent` CLI connects to the gateway process via WebSocket,
360
+ // so env vars set here do NOT reach the gateway's plugin hooks.
361
+ // Instead, inject [TASK_META: ...] into the message so the gateway's
362
+ // init_context hook can parse task metadata from the prompt text.
363
+ const taskMeta = `[TASK_META: taskType=${task.type} taskId=${task.id} campaignId=${task.campaignId ?? ""} maxCredits=${maxCredits}]`;
364
+ const baseMessage = task.message || `Execute ${task.type} task`;
365
+ const message = `${taskMeta}\n\n${baseMessage}`;
256
366
 
257
- // Pass task metadata via environment variables so the plugin's
258
- // detectTaskMode() picks them up without needing the hook: session prefix
367
+ // Keep env vars for --local mode (direct execution without gateway)
259
368
  const env: Record<string, string> = {
260
369
  ...process.env as Record<string, string>,
261
370
  BEREACH_TASK_ID: task.id,
262
371
  BEREACH_TASK_TYPE: task.type,
263
372
  BEREACH_TASK_CAMPAIGN_ID: task.campaignId ?? "",
264
- BEREACH_TASK_MAX_CREDITS: String((task.payload as Record<string, unknown>)?.maxCredits ?? 100),
373
+ BEREACH_TASK_MAX_CREDITS: String(maxCredits),
265
374
  };
266
375
 
267
376
  const args = [
@@ -297,12 +406,22 @@ async function executeViaExecFile(
297
406
  if (output.error) {
298
407
  return { result: null, error: output.error };
299
408
  }
300
- // The lifecycle hook posts the structured result to the API directly,
301
- // but we also parse it here as a fallback
302
- return {
303
- result: output.result ?? { success: true },
304
- error: null,
305
- };
409
+
410
+ // OpenClaw --json wraps agent output in { result: { payloads: [...], meta: {...} } }
411
+ // The actual TaskResult is embedded in the last payload's text.
412
+ // B17 fix: extract it instead of returning the raw wrapper.
413
+ const extracted = extractTaskResultFromWrapper(output);
414
+ if (extracted) {
415
+ return { result: extracted, error: null };
416
+ }
417
+
418
+ // Fallback: if output itself looks like a flat TaskResult
419
+ if (output.success !== undefined || output.contactsProcessed !== undefined) {
420
+ return { result: output as TaskResult, error: null };
421
+ }
422
+
423
+ // Last resort: lifecycle hook may have already posted the real result
424
+ return { result: { success: true }, error: null };
306
425
  } catch {
307
426
  // Non-JSON output - treat as success if process exited cleanly
308
427
  return {
@@ -323,15 +442,26 @@ async function executeViaExecFile(
323
442
  if (stdout) {
324
443
  try {
325
444
  const output = JSON.parse(stdout);
326
- if (output.result) {
327
- return { result: output.result, error: null };
445
+ if (output.error) {
446
+ return { result: null, error: String(output.error).slice(0, 500) };
447
+ }
448
+ // B17 fix: extract TaskResult from OpenClaw wrapper (same as happy path)
449
+ const extracted = extractTaskResultFromWrapper(output);
450
+ if (extracted) {
451
+ return { result: extracted, error: null };
452
+ }
453
+ // Accept flat { success: true } format
454
+ if (output.success !== undefined || output.contactsProcessed !== undefined) {
455
+ return { result: output as TaskResult, error: null };
328
456
  }
329
457
  } catch {
330
458
  // Not JSON
331
459
  }
332
460
  }
333
461
 
334
- const message = stderr || execErr.message || String(err);
462
+ // Filter known-harmless stderr noise (e.g. @aws-sdk/client-bedrock module warnings)
463
+ const filtered = filterStderr(stderr);
464
+ const message = filtered || execErr.message || String(err);
335
465
  return { result: null, error: message.slice(0, 500) };
336
466
  }
337
467
  }
@@ -361,7 +491,7 @@ async function updateTaskStatus(
361
491
  if (!config.apiKey && config.connectorToken) {
362
492
  body.connectorToken = config.connectorToken;
363
493
  }
364
- await httpPost(`${config.apiUrl}/api/tasks/${taskId}/result`, body, headers).catch((err) => {
494
+ await httpPost(`${config.apiUrl}/tasks/${taskId}/result`, body, headers).catch((err) => {
365
495
  console.error(`[connector] Task status update to "${status}" failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
366
496
  });
367
497
  }
@@ -392,8 +522,8 @@ export async function runConnectorLoop(
392
522
  console.log(`[connector] API: ${config.apiUrl}`);
393
523
  console.log(`[connector] Auth: ${config.apiKey ? "API key (Bearer)" : "connector token (legacy)"}`);
394
524
 
395
- // Probe webhook availability on startup
396
- webhookAvailable = await isWebhookAvailable(config);
525
+ // Probe webhook availability on startup (with retry for gateway startup race — B9 fix)
526
+ webhookAvailable = await probeWebhookWithRetry(config);
397
527
  const execMode = webhookAvailable ? "webhook" : "execFile (fallback)";
398
528
  console.log(`[connector] Execution mode: ${execMode}`);
399
529
  if (webhookAvailable) {
@@ -462,13 +592,15 @@ export async function runConnectorLoop(
462
592
  webhookAvailable,
463
593
  );
464
594
 
595
+ const taskStatus = error ? "failed" : (result?.success !== false ? "succeeded" : "failed");
465
596
  console.log(
466
- `[connector] Task ${response.task.id} ${result?.success ? "succeeded" : "failed"}`,
597
+ `[connector] Task ${response.task.id} ${taskStatus}${error ? `: ${error.slice(0, 100)}` : ""}`,
467
598
  );
468
599
 
469
600
  // With webhook execution, the lifecycle hook auto-reports results.
470
601
  // Submit from connector for execFile fallback, errors, or as safety net
471
602
  // when lifecycle hook may not have fired (agent crash).
603
+ // Don't submit if result looks successful — lifecycle hook already posted.
472
604
  if (!webhookAvailable || error) {
473
605
  await submitResult(config, response.task.id, result, error);
474
606
  }
@@ -497,6 +629,15 @@ export async function runConnectorLoop(
497
629
  config.pollIntervalMs * Math.pow(2, consecutiveErrors),
498
630
  5 * 60 * 1000,
499
631
  );
632
+
633
+ // Re-probe webhook if stuck in execFile mode with repeated errors (B9 fix)
634
+ if (!webhookAvailable && consecutiveErrors >= 3 && consecutiveErrors % 3 === 0) {
635
+ console.log(`[connector] ${consecutiveErrors} consecutive errors, re-probing webhook...`);
636
+ webhookAvailable = await isWebhookAvailable(config);
637
+ if (webhookAvailable) {
638
+ console.log(`[connector] Webhook now available, switching to webhook mode`);
639
+ }
640
+ }
500
641
  }
501
642
 
502
643
  await sleep(pollInterval, signal);
@@ -9,7 +9,7 @@ export function registerCommands(api: any, state?: SessionState, apiKey?: string
9
9
  name: "bereach-credits",
10
10
  description: "Show current BeReach credit balance",
11
11
  handler: async () => {
12
- const res = await apiFetch<{ credits: { current: number; limit: number | null; remaining: number | null; isUnlimited: boolean; percentage: number } }>("/api/me/credits", resolvedKey);
12
+ const res = await apiFetch<{ credits: { current: number; limit: number | null; remaining: number | null; isUnlimited: boolean; percentage: number } }>("/me/credits", resolvedKey);
13
13
  if (!res) return { text: "Failed to fetch credits." };
14
14
  const c = res.credits;
15
15
  return {
@@ -24,7 +24,7 @@ export function registerCommands(api: any, state?: SessionState, apiKey?: string
24
24
  name: "bereach-status",
25
25
  description: "Show LinkedIn rate limit summary",
26
26
  handler: async () => {
27
- const res = await apiFetch<any>("/api/rate-limits", resolvedKey);
27
+ const res = await apiFetch<any>("/rate-limits", resolvedKey);
28
28
  if (!res) return { text: "Failed to fetch limits." };
29
29
  const limits = res?.linkedIn?.limits ?? res?.limits ?? {};
30
30
  const lines = ["BeReach Rate Limits:"];
@@ -31,7 +31,7 @@ export function registerSetupCommand(api: any) {
31
31
 
32
32
  // Validate key by calling a lightweight endpoint
33
33
  try {
34
- const res = await fetch(`${API_BASE}/api/me/credits`, {
34
+ const res = await fetch(`${API_BASE}/me/credits`, {
35
35
  headers: { Authorization: `Bearer ${apiKey}` },
36
36
  signal: AbortSignal.timeout(5000),
37
37
  });
@@ -52,7 +52,7 @@ class ConnectorManager {
52
52
  if (taskId && this.config?.apiKey) {
53
53
  console.log(`[bereach:connector] reporting task ${taskId} as interrupted...`);
54
54
  try {
55
- await fetch(`${this.config.apiUrl}/api/tasks/${taskId}/result`, {
55
+ await fetch(`${this.config.apiUrl}/tasks/${taskId}/result`, {
56
56
  method: "POST",
57
57
  headers: {
58
58
  "Authorization": `Bearer ${this.config.apiKey}`,
@@ -37,7 +37,7 @@ async function gracefulShutdown(signal: string) {
37
37
  try {
38
38
  const headers: Record<string, string> = { "Content-Type": "application/json" };
39
39
  if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
40
- await fetch(`${config.apiUrl}/api/tasks/${taskId}/result`, {
40
+ await fetch(`${config.apiUrl}/tasks/${taskId}/result`, {
41
41
  method: "POST",
42
42
  headers,
43
43
  body: JSON.stringify({ result: null, error: `Connector terminated (${signal})` }),
@@ -219,7 +219,7 @@ export { apiFetchUtil as apiFetch };
219
219
  // ---------------------------------------------------------------------------
220
220
 
221
221
  export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
222
- const snapshot = await apiFetchUtil<any>("/api/agent/snapshot", apiKey);
222
+ const snapshot = await apiFetchUtil<any>("/agent/snapshot", apiKey);
223
223
 
224
224
  if (!snapshot) {
225
225
  log("fetchSnapshot: FAIL — snapshot endpoint returned null");
@@ -255,7 +255,7 @@ export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
255
255
  // Fallback: if snapshot didn't include limits, fetch from dedicated endpoint
256
256
  if (!store.limits) {
257
257
  log("fetchSnapshot: limits missing from snapshot, fetching from /api/me/limits");
258
- const limitsData = await apiFetchUtil<CachedLimits>("/api/me/limits", apiKey);
258
+ const limitsData = await apiFetchUtil<CachedLimits>("/me/limits", apiKey);
259
259
  if (limitsData) {
260
260
  store.limits = limitsData;
261
261
  log(`fetchSnapshot: limits fallback OK — multiplier=${limitsData.multiplier ?? 1} actions=${Object.keys(limitsData.limits ?? {}).length}`);
@@ -43,6 +43,7 @@ export function resetProfileState(state: SessionState) {
43
43
  state.profileInitDone = false;
44
44
  state.toneInferenceInjected = false;
45
45
  state.onboardingDirectiveInjected = false;
46
+ state.anthropicKeyWarningInjected = false;
46
47
  }
47
48
 
48
49
  // Version check cache
@@ -154,7 +155,7 @@ async function fetchSoulTemplate(apiKey?: string): Promise<string> {
154
155
  try {
155
156
  const headers: Record<string, string> = {};
156
157
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
157
- const res = await fetch(`${API_BASE}/api/onboarding/soul`, {
158
+ const res = await fetch(`${API_BASE}/onboarding/soul`, {
158
159
  headers,
159
160
  signal: AbortSignal.timeout(3000),
160
161
  });
@@ -194,7 +195,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
194
195
  profileUrl?: string;
195
196
  linkedinEmail?: string;
196
197
  profileData?: { location?: string; connectionsCount?: number };
197
- }>("/api/me/linkedin", apiKey);
198
+ }>("/me/linkedin", apiKey);
198
199
 
199
200
  if (!profile?.linkedinName) return;
200
201
 
@@ -211,7 +212,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
211
212
  return;
212
213
  }
213
214
 
214
- await sharedApiFetch("/api/context", apiKey, {
215
+ await sharedApiFetch("/context", apiKey, {
215
216
  method: "PUT",
216
217
  body: { type: "user-profile", content, scope: "user" },
217
218
  });
@@ -223,7 +224,7 @@ async function _autoInitProfileInner(data: CacheStore, apiKey: string): Promise<
223
224
  else data.contexts.push(entry);
224
225
 
225
226
  // Save refresh timestamp
226
- await sharedApiFetch("/api/agent-state/session-meta", apiKey, {
227
+ await sharedApiFetch("/agent-state/session-meta", apiKey, {
227
228
  method: "PATCH",
228
229
  body: { data: { lastProfileRefreshAt: new Date().toISOString() } },
229
230
  });
@@ -421,7 +422,7 @@ function formatOnboardingDirective(state: SessionState, data: CacheStore, apiKey
421
422
  cacheSet("onboardingState", completedState);
422
423
 
423
424
  if (apiKey) {
424
- sharedApiFetch("/api/agent-state/onboarding", apiKey, {
425
+ sharedApiFetch("/agent-state/onboarding", apiKey, {
425
426
  method: "PUT",
426
427
  body: {
427
428
  data: { completed: true, completedAt: new Date().toISOString(), autoCompleted: true },
@@ -333,7 +333,7 @@ async function guardScheduledDm(toolName: string, params: Record<string, unknown
333
333
 
334
334
  try {
335
335
  const res = await fetch(
336
- `${API_BASE}/api/contacts/${encodeURIComponent(contactId)}`,
336
+ `${API_BASE}/contacts/${encodeURIComponent(contactId)}`,
337
337
  {
338
338
  headers: { Authorization: `Bearer ${key}` },
339
339
  signal: AbortSignal.timeout(3000),
@@ -382,7 +382,7 @@ async function guardDoNotContact(toolName: string, params: Record<string, unknow
382
382
  let contactData: any = null;
383
383
  if (effectiveProfile) {
384
384
  const res = await fetch(
385
- `${API_BASE}/api/contacts/by-url?linkedinUrl=${encodeURIComponent(effectiveProfile)}`,
385
+ `${API_BASE}/contacts/by-url?linkedinUrl=${encodeURIComponent(effectiveProfile)}`,
386
386
  {
387
387
  headers: { Authorization: `Bearer ${key}` },
388
388
  signal: AbortSignal.timeout(3000),
@@ -395,7 +395,7 @@ async function guardDoNotContact(toolName: string, params: Record<string, unknow
395
395
  }
396
396
  } else if (contactId) {
397
397
  const res = await fetch(
398
- `${API_BASE}/api/contacts/${encodeURIComponent(contactId)}`,
398
+ `${API_BASE}/contacts/${encodeURIComponent(contactId)}`,
399
399
  {
400
400
  headers: { Authorization: `Bearer ${key}` },
401
401
  signal: AbortSignal.timeout(3000),
@@ -479,33 +479,36 @@ function guardBulkInChat(toolName: string, params: Record<string, unknown>, stat
479
479
  ?? (typeof params.postUrn === "string" ? params.postUrn : null);
480
480
  if (!contactId) { log(`bulk-in-chat: SKIP ${toolName} (no contactId, params=${JSON.stringify(Object.keys(params))})`); return null; }
481
481
 
482
- // Track this contact
482
+ // Check limit BEFORE adding to set — otherwise the blocked contact
483
+ // ends up in the set, inflating the count even though it was never processed.
484
+ const isNewContact = !state.targetedContacts.has(contactId);
485
+ if (isNewContact && state.targetedContacts.size >= MAX_CONTACTS_IN_CHAT) {
486
+ log(`bulk-in-chat BLOCKED ${toolName}: ${state.targetedContacts.size} unique contacts already targeted (max ${MAX_CONTACTS_IN_CHAT})`);
487
+ return {
488
+ blocked: true,
489
+ message: `BLOCKED — ${toolName} not executed. ${MAX_CONTACTS_IN_CHAT} contacts processed. Stop all tool calls. Propose campaign for remaining contacts. Do NOT say remaining were processed.`,
490
+ };
491
+ }
492
+
493
+ // Track after check passes — only contacts that actually get processed
483
494
  state.targetedContacts.add(contactId);
484
495
  log(`bulk-in-chat: ${toolName} target=${contactId} unique=${state.targetedContacts.size}/${MAX_CONTACTS_IN_CHAT}`);
485
-
486
- // Allow up to MAX_CONTACTS_IN_CHAT unique contacts
487
- if (state.targetedContacts.size <= MAX_CONTACTS_IN_CHAT) return null;
488
-
489
- log(`bulk-in-chat BLOCKED ${toolName}: ${state.targetedContacts.size} unique contacts targeted (max ${MAX_CONTACTS_IN_CHAT})`);
490
- const remaining = state.targetedContacts.size - MAX_CONTACTS_IN_CHAT;
491
- return {
492
- blocked: true,
493
- message: `BLOCKED — ${toolName} not executed. ${MAX_CONTACTS_IN_CHAT} contacts processed, ${remaining} remain. Stop all tool calls. Propose campaign for remaining contacts. Do NOT say remaining were processed.`,
494
- };
496
+ return null;
495
497
  }
496
498
 
497
499
  const MAX_POSTS_PER_SESSION = 3;
498
500
 
499
501
  function guardPostLimit(toolName: string, state: SessionState) {
500
502
  if (toolName !== "bereach_publish_post") return null;
501
- state.postsThisSession++;
502
- if (state.postsThisSession > MAX_POSTS_PER_SESSION) {
503
+ if (state.postsThisSession >= MAX_POSTS_PER_SESSION) {
503
504
  log(`post limit BLOCKED: ${state.postsThisSession}/${MAX_POSTS_PER_SESSION} this session`);
504
505
  return {
505
506
  blocked: true,
506
507
  message: `BLOCKED: Post limit reached (${MAX_POSTS_PER_SESSION} posts per session). Publishing too many posts in a short time can trigger LinkedIn restrictions.`,
507
508
  };
508
509
  }
510
+ // Increment AFTER check passes — blocked calls shouldn't inflate the counter
511
+ state.postsThisSession++;
509
512
  return null;
510
513
  }
511
514
 
@@ -558,6 +561,11 @@ export function registerEnforcementHook(
558
561
  // Per-session DM guard mutex (scoped to this hook registration's closure)
559
562
  const guardDmPacingAndDedup = createDmGuard();
560
563
 
564
+ // Scrape serialization lock — when OpenClaw batches parallel tool calls,
565
+ // each before_tool_call fires concurrently. Without serialization, all scrape
566
+ // calls hit the API simultaneously and trip the minInterval rate limit (B13 fix).
567
+ let scrapeLock: Promise<void> = Promise.resolve();
568
+
561
569
  // Reset per-turn counters at the start of each agent turn.
562
570
  api.on("before_prompt_build", () => {
563
571
  state.toolCallCount = 0;
@@ -648,8 +656,12 @@ export function registerEnforcementHook(
648
656
  if (toolName === "bereach_bulk_visit_profiles") {
649
657
  log(`pacing: SKIP for ${toolName} (server-side via Workflow)`);
650
658
  } else if (isRead) {
651
- log(`pacing: read delay ${readMin}-${readMax}s for ${toolName}`);
652
- await randomDelay(readMin, readMax);
659
+ // Serialize scrape-type reads through a promise chain so parallel batched
660
+ // calls don't all hit the API simultaneously (B13 fix)
661
+ const prev = scrapeLock;
662
+ scrapeLock = prev.then(() => randomDelay(readMin, readMax));
663
+ log(`pacing: read delay ${readMin}-${readMax}s for ${toolName} (serialized)`);
664
+ await scrapeLock;
653
665
  } else if (isWrite || PACED_FREE_TOOLS.has(toolName)) {
654
666
  log(`pacing: write delay ${writeMin}-${writeMax}s for ${toolName}`);
655
667
  await randomDelay(writeMin, writeMax);