bereach-openclaw 1.5.6 → 1.5.7

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.
@@ -1289,6 +1289,11 @@ export const definitions: ToolDefinition[] = [
1289
1289
  dailyActionLimit: { type: "integer", minimum: 1, description: "Max actions per day." },
1290
1290
  dailyTarget: { type: "integer", minimum: 1, description: "Daily target goal (e.g. 50 leads/day). Set null to remove." },
1291
1291
  totalTarget: { type: "integer", minimum: 1, description: "Total campaign goal (e.g. 500 leads total). Auto-completes when reached. Set null to remove." },
1292
+ handoverMode: {
1293
+ type: "string",
1294
+ enum: ["on_reply", "on_goal", "manual"],
1295
+ description: "When agent hands over conversation to human. on_reply (default): stop autonomous outreach when contact replies. on_goal: keep replying until meeting booked or converted. manual: never auto-stop.",
1296
+ },
1292
1297
  taskOverrides: {
1293
1298
  type: "object",
1294
1299
  description: "Per-task-type config overrides. Keys are task types (e.g. 'lead-gen-visit', 'lead-gen-qualify'). Values are config objects with: maxRunsPerDay, minIntervalMinutes, defaultBatchSize, maxCreditsPerRun.",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach-openclaw",
3
3
  "name": "BeReach",
4
- "version": "1.5.6",
4
+ "version": "1.5.7",
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.6",
3
+ "version": "1.5.7",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "exports": {
@@ -63,6 +63,14 @@ type TaskResult = {
63
63
 
64
64
  const MIN_POLL_MS = 5000;
65
65
 
66
+ /** Thrown when API returns 401/403 - credentials are invalid, retrying won't help */
67
+ export class AuthError extends Error {
68
+ constructor(public readonly statusCode: number, body: string) {
69
+ super(`Auth failed (HTTP ${statusCode}): ${body.slice(0, 200)}`);
70
+ this.name = "AuthError";
71
+ }
72
+ }
73
+
66
74
  // ---------------------------------------------------------------------------
67
75
  // TaskResult extraction from OpenClaw agent output
68
76
  // ---------------------------------------------------------------------------
@@ -167,6 +175,9 @@ async function httpPost(url: string, body: unknown, headers: Record<string, stri
167
175
  });
168
176
  if (!res.ok) {
169
177
  const text = await res.text().catch(() => "");
178
+ if (res.status === 401 || res.status === 403) {
179
+ throw new AuthError(res.status, text);
180
+ }
170
181
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
171
182
  }
172
183
  return res.json();
@@ -513,7 +524,7 @@ async function updateTaskStatus(
513
524
  }
514
525
 
515
526
  export type ConnectorStatus = {
516
- state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled";
527
+ state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled" | "dormant";
517
528
  uptime: number;
518
529
  lastHeartbeat: number;
519
530
  currentTaskId: string | undefined;
@@ -524,16 +535,26 @@ export type ConnectorStatus = {
524
535
 
525
536
  export async function runConnectorLoop(
526
537
  config: ConnectorConfig,
527
- options?: { signal?: AbortSignal },
538
+ options?: { signal?: AbortSignal; onHeartbeat?: () => void },
528
539
  ): Promise<void> {
529
540
  let pollInterval = config.pollIntervalMs;
530
541
  let currentTaskId: string | undefined;
531
542
  let consecutiveErrors = 0;
543
+ let consecutiveAuthErrors = 0;
532
544
  let totalTasksExecuted = 0;
533
545
  let webhookAvailable = false;
534
546
 
535
547
  const signal = options?.signal;
536
548
 
549
+ // Internal AbortController: links heartbeat + poll loops so either can abort both.
550
+ // When heartbeat detects auth failure → aborts internal → poll loop exits too.
551
+ // External signal (from ConnectorManager) is forwarded to internal.
552
+ const internalAbort = new AbortController();
553
+ const internalSignal = internalAbort.signal;
554
+ if (signal) {
555
+ signal.addEventListener("abort", () => internalAbort.abort(signal.reason), { once: true });
556
+ }
557
+
537
558
  console.log(`[connector] Starting connector loop`);
538
559
  console.log(`[connector] API: ${config.apiUrl}`);
539
560
  console.log(`[connector] Auth: ${config.apiKey ? "API key (Bearer)" : "connector token (legacy)"}`);
@@ -550,32 +571,47 @@ export async function runConnectorLoop(
550
571
 
551
572
  // Heartbeat runs independently - does NOT touch pollInterval or consecutiveErrors.
552
573
  // Otherwise it would neutralize exponential backoff during error recovery.
574
+ // Uses internalAbort so auth failures kill both heartbeat AND poll loops.
553
575
  let consecutiveHeartbeatFailures = 0;
576
+ let consecutiveHeartbeatAuthErrors = 0;
554
577
  const heartbeatLoop = async () => {
555
- while (!signal?.aborted) {
578
+ let heartbeatIntervalMs = 30_000;
579
+ while (!internalSignal.aborted) {
556
580
  try {
557
581
  await heartbeat(config, currentTaskId);
558
582
  consecutiveHeartbeatFailures = 0;
583
+ consecutiveHeartbeatAuthErrors = 0;
584
+ heartbeatIntervalMs = 30_000;
585
+ options?.onHeartbeat?.();
559
586
  } catch (err) {
560
587
  consecutiveHeartbeatFailures++;
561
- console.error(`[connector] Heartbeat failed (${consecutiveHeartbeatFailures}):`, err);
562
- if (consecutiveHeartbeatFailures === 5) {
563
- console.warn("[connector] WARNING: 5 consecutive heartbeat failures, API may be unreachable");
564
- }
565
- if (consecutiveHeartbeatFailures >= 10) {
566
- console.error("[connector] FATAL: 10 heartbeat failures, aborting connector for restart");
567
- // Throw to exit the heartbeat loop - ConnectorManager will restart us
568
- throw new Error("Too many consecutive heartbeat failures");
588
+ if (err instanceof AuthError) {
589
+ consecutiveHeartbeatAuthErrors++;
590
+ console.error(`[connector] Heartbeat auth failed (${consecutiveHeartbeatAuthErrors}/3): ${err.message}`);
591
+ if (consecutiveHeartbeatAuthErrors >= 3) {
592
+ console.error("[connector] FATAL: 3 consecutive auth failures in heartbeat, API key is invalid");
593
+ internalAbort.abort(err);
594
+ return;
595
+ }
596
+ } else {
597
+ consecutiveHeartbeatAuthErrors = 0;
598
+ console.error(`[connector] Heartbeat failed (${consecutiveHeartbeatFailures}):`, err instanceof Error ? err.message : String(err));
599
+ if (consecutiveHeartbeatFailures === 5) {
600
+ console.warn("[connector] WARNING: 5 consecutive heartbeat failures, API may be unreachable");
601
+ }
602
+ // Exponential backoff: 30s → 60s → 120s → max 5min (never die for transient errors)
603
+ heartbeatIntervalMs = Math.min(heartbeatIntervalMs * 2, 5 * 60 * 1000);
569
604
  }
570
605
  }
571
606
  await new Promise((r) => {
572
- const timer = setTimeout(r, 30_000);
573
- signal?.addEventListener("abort", () => { clearTimeout(timer); r(undefined); }, { once: true });
607
+ const timer = setTimeout(r, heartbeatIntervalMs);
608
+ internalSignal.addEventListener("abort", () => { clearTimeout(timer); r(undefined); }, { once: true });
574
609
  });
575
610
  }
576
611
  };
577
612
  heartbeatLoop().catch((err) => {
578
613
  console.error("[connector] Heartbeat loop crashed:", err instanceof Error ? err.message : String(err));
614
+ if (!internalSignal.aborted) internalAbort.abort(err);
579
615
  });
580
616
 
581
617
  // Task execution lock - prevents concurrent task execution.
@@ -583,11 +619,11 @@ export async function runConnectorLoop(
583
619
  let isExecuting = false;
584
620
  let idlePollCount = 0;
585
621
 
586
- while (!signal?.aborted) {
622
+ while (!internalSignal.aborted) {
587
623
  try {
588
624
  // Skip polling while a task is running - prevents claiming concurrent tasks
589
625
  if (isExecuting) {
590
- await sleep(pollInterval, signal);
626
+ await sleep(pollInterval, internalSignal);
591
627
  continue;
592
628
  }
593
629
 
@@ -691,10 +727,22 @@ export async function runConnectorLoop(
691
727
  isExecuting = false;
692
728
  currentTaskId = undefined;
693
729
  (globalThis as any).__bereachCurrentTaskId = undefined;
694
- console.error(
695
- `[connector] Poll error (${consecutiveErrors}):`,
696
- err,
697
- );
730
+
731
+ if (err instanceof AuthError) {
732
+ consecutiveAuthErrors++;
733
+ console.error(`[connector] Poll auth failed (${consecutiveAuthErrors}/3): ${err.message}`);
734
+ if (consecutiveAuthErrors >= 3) {
735
+ console.error("[connector] FATAL: 3 consecutive auth failures in poll loop, API key is invalid");
736
+ internalAbort.abort(err);
737
+ break;
738
+ }
739
+ } else {
740
+ consecutiveAuthErrors = 0;
741
+ console.error(
742
+ `[connector] Poll error (${consecutiveErrors}):`,
743
+ err instanceof Error ? err.message : String(err),
744
+ );
745
+ }
698
746
 
699
747
  pollInterval = Math.min(
700
748
  config.pollIntervalMs * Math.pow(2, consecutiveErrors),
@@ -711,10 +759,17 @@ export async function runConnectorLoop(
711
759
  }
712
760
  }
713
761
 
714
- await sleep(pollInterval, signal);
762
+ await sleep(pollInterval, internalSignal);
715
763
  }
716
764
 
717
765
  console.log("[connector] Loop stopped (abort signal received)");
766
+
767
+ // If internal abort fired (auth failure) but external didn't (not a graceful shutdown),
768
+ // throw so ConnectorManager.handleCrash() triggers with the auth error.
769
+ if (internalSignal.aborted && !signal?.aborted) {
770
+ const reason = internalSignal.reason;
771
+ throw reason instanceof Error ? reason : new Error("Connector loop aborted internally");
772
+ }
718
773
  }
719
774
 
720
775
  /** Re-probe webhook availability. Called by ConnectorManager on retry. */
@@ -4,39 +4,75 @@
4
4
  * Auto-starts the polling loop from register() when an API key is present.
5
5
  * Handles crash recovery with exponential backoff, graceful shutdown on
6
6
  * SIGTERM/SIGINT, and prevents duplicate instances.
7
+ *
8
+ * Key resilience features:
9
+ * - AuthError detection: distinguishes auth failures from transient errors
10
+ * - Dormant mode: after persistent auth failures, enters low-power probe mode
11
+ * instead of burning restarts. Probes every 15 min and auto-recovers.
12
+ * - Config resolver: re-resolves API key on each restart/probe
13
+ * - Health check: detects zombie state (running but heartbeat dead)
7
14
  */
8
15
 
9
- import { runConnectorLoop, isWebhookAvailable, type ConnectorConfig, type ConnectorStatus } from "../commands/connector.js";
16
+ import { runConnectorLoop, isWebhookAvailable, AuthError, type ConnectorConfig, type ConnectorStatus } from "../commands/connector.js";
10
17
 
11
18
  const MAX_RESTARTS = 10;
12
19
  const INITIAL_RESTART_DELAY_MS = 1_000;
13
20
  const MAX_RESTART_DELAY_MS = 30_000;
21
+ const DORMANT_PROBE_INTERVAL_MS = 15 * 60 * 1000; // 15 min
14
22
 
15
- export type ConnectorManagerState = "stopped" | "starting" | "running" | "stopping" | "error" | "disabled";
23
+ export type ConnectorManagerState = "stopped" | "starting" | "running" | "stopping" | "error" | "disabled" | "dormant";
16
24
 
17
25
  class ConnectorManager {
18
26
  private state: ConnectorManagerState = "stopped";
19
27
  private abortController: AbortController | null = null;
20
28
  private config: ConnectorConfig | null = null;
29
+ private resolveConfig: (() => ConnectorConfig) | null = null;
21
30
  private restartCount = 0;
22
31
  private startedAt = 0;
23
32
  private lastHeartbeatAt = 0;
24
33
  private totalTasksExecuted = 0;
25
34
  private consecutiveErrors = 0;
35
+ private consecutiveAuthCrashes = 0;
26
36
  private executionMode: "webhook" | "execFile" = "execFile";
27
37
  private loopPromise: Promise<void> | null = null;
28
38
  private signalHandlersInstalled = false;
39
+ private stabilityTimer: ReturnType<typeof setTimeout> | null = null;
40
+ private dormantTimer: ReturnType<typeof setTimeout> | null = null;
29
41
 
30
42
  /**
31
- * Start the connector. Idempotent - if already running, logs and returns.
43
+ * Start the connector. Accepts either a static config or a resolver function
44
+ * that re-resolves config (including API key) on each restart.
45
+ *
46
+ * Idempotent with smart recovery:
47
+ * - If dormant: wakes up and attempts restart (user may have changed key)
48
+ * - If running but unhealthy: forces restart
49
+ * - If running and healthy: skips
32
50
  */
33
- async start(config: ConnectorConfig): Promise<void> {
34
- if (this.state === "running" || this.state === "starting") {
35
- console.log("[bereach:connector] skip duplicate start (already running)");
36
- return;
51
+ async start(configOrResolver: ConnectorConfig | (() => ConnectorConfig)): Promise<void> {
52
+ // Store resolver for future re-resolution
53
+ if (typeof configOrResolver === "function") {
54
+ this.resolveConfig = configOrResolver;
55
+ this.config = configOrResolver();
56
+ } else {
57
+ this.resolveConfig = null;
58
+ this.config = configOrResolver;
59
+ }
60
+
61
+ if (this.state === "dormant") {
62
+ // User may have changed their key - wake up and try
63
+ console.log("[bereach:connector] waking from dormant mode (register() called)");
64
+ if (this.dormantTimer) { clearTimeout(this.dormantTimer); this.dormantTimer = null; }
65
+ this.consecutiveAuthCrashes = 0;
66
+ // Fall through to normal start
67
+ } else if (this.state === "running" || this.state === "starting") {
68
+ if (this.isHealthy()) {
69
+ console.log("[bereach:connector] skip duplicate start (already running and healthy)");
70
+ return;
71
+ }
72
+ console.warn("[bereach:connector] connector unhealthy, forcing restart");
73
+ await this.stop();
37
74
  }
38
75
 
39
- this.config = config;
40
76
  this.state = "starting";
41
77
  this.restartCount = 0;
42
78
 
@@ -73,11 +109,11 @@ class ConnectorManager {
73
109
  process.on("SIGINT", () => shutdown("SIGINT"));
74
110
  }
75
111
 
76
- // Probe webhook availability with retry the gateway HTTP server may not be
112
+ // Probe webhook availability with retry - the gateway HTTP server may not be
77
113
  // listening yet when register() fires during plugin load.
78
114
  let webhookOk = false;
79
115
  for (let attempt = 1; attempt <= 5; attempt++) {
80
- webhookOk = await isWebhookAvailable(config);
116
+ webhookOk = await isWebhookAvailable(this.config);
81
117
  if (webhookOk) break;
82
118
  if (attempt < 5) {
83
119
  console.log(`[bereach:connector] webhook probe attempt ${attempt}/5 failed, retrying in 3s...`);
@@ -95,6 +131,9 @@ class ConnectorManager {
95
131
  async stop(): Promise<void> {
96
132
  if (this.state === "stopped" || this.state === "disabled") return;
97
133
 
134
+ // Clean up dormant timer if active
135
+ if (this.dormantTimer) { clearTimeout(this.dormantTimer); this.dormantTimer = null; }
136
+
98
137
  this.state = "stopping";
99
138
  console.log("[bereach:connector] stopping...");
100
139
 
@@ -128,10 +167,43 @@ class ConnectorManager {
128
167
  };
129
168
  }
130
169
 
131
- private stabilityTimer: ReturnType<typeof setTimeout> | null = null;
170
+ /**
171
+ * Check if the connector is actually healthy (not a zombie).
172
+ * Unhealthy = running state but no heartbeat in 5+ minutes,
173
+ * or running for 2+ minutes without ever getting a heartbeat.
174
+ */
175
+ private isHealthy(): boolean {
176
+ if (this.state !== "running") return false;
177
+ const now = Date.now();
178
+ const uptime = now - this.startedAt;
179
+ // Just started (< 2 min), give it time
180
+ if (uptime < 2 * 60 * 1000) return true;
181
+ // No heartbeat ever received after 2 min = zombie
182
+ if (!this.lastHeartbeatAt) return false;
183
+ // Last heartbeat > 5 min ago = zombie
184
+ return (now - this.lastHeartbeatAt) < 5 * 60 * 1000;
185
+ }
186
+
187
+ /**
188
+ * Re-resolve config from the resolver (picks up API key changes).
189
+ * Falls back to stored config if no resolver.
190
+ */
191
+ private freshConfig(): ConnectorConfig | null {
192
+ if (this.resolveConfig) {
193
+ try {
194
+ this.config = this.resolveConfig();
195
+ const masked = this.config.apiKey ? `...${this.config.apiKey.slice(-6)}` : "NOT SET";
196
+ console.log(`[bereach:connector] re-resolved config (API key: ${masked})`);
197
+ } catch (err) {
198
+ console.error("[bereach:connector] config resolver failed:", err instanceof Error ? err.message : String(err));
199
+ }
200
+ }
201
+ return this.config;
202
+ }
132
203
 
133
204
  private startLoop(): void {
134
- if (!this.config) return;
205
+ const config = this.freshConfig();
206
+ if (!config) return;
135
207
 
136
208
  this.abortController = new AbortController();
137
209
  this.startedAt = Date.now();
@@ -151,7 +223,10 @@ class ConnectorManager {
151
223
  }, 5 * 60 * 1000);
152
224
  this.stabilityTimer.unref();
153
225
 
154
- this.loopPromise = runConnectorLoop(this.config, { signal: this.abortController.signal })
226
+ this.loopPromise = runConnectorLoop(config, {
227
+ signal: this.abortController.signal,
228
+ onHeartbeat: () => { this.lastHeartbeatAt = Date.now(); },
229
+ })
155
230
  .then(() => {
156
231
  // Normal exit (abort signal)
157
232
  if (this.state !== "stopping") {
@@ -160,14 +235,29 @@ class ConnectorManager {
160
235
  })
161
236
  .catch((err) => {
162
237
  console.error(`[bereach:connector] loop crashed: ${err instanceof Error ? err.message : String(err)}`);
163
- this.handleCrash();
238
+ this.handleCrash(err);
164
239
  });
165
240
  }
166
241
 
167
- private handleCrash(): void {
242
+ private handleCrash(err?: unknown): void {
168
243
  this.restartCount++;
169
244
  this.consecutiveErrors++;
170
245
 
246
+ // Track consecutive auth-related crashes for dormant mode.
247
+ // 2 auth crashes = 2 full start→3-failures→crash cycles = ~6 total 401s.
248
+ const isAuthCrash = err instanceof AuthError ||
249
+ (err instanceof Error && err.message.includes("Auth failed"));
250
+ if (isAuthCrash) {
251
+ this.consecutiveAuthCrashes++;
252
+ console.error(`[bereach:connector] auth crash ${this.consecutiveAuthCrashes}/2`);
253
+ if (this.consecutiveAuthCrashes >= 2) {
254
+ this.enterDormant();
255
+ return;
256
+ }
257
+ } else {
258
+ this.consecutiveAuthCrashes = 0;
259
+ }
260
+
171
261
  if (this.restartCount > MAX_RESTARTS) {
172
262
  // Don't give up permanently - enter cooldown then restart.
173
263
  // The user's VPS has no supervisor to restart us, so we must self-recover.
@@ -199,6 +289,69 @@ class ConnectorManager {
199
289
  this.startLoop();
200
290
  }, delay);
201
291
  }
292
+
293
+ /**
294
+ * Enter dormant mode - lightweight 15-min probe instead of full restart loop.
295
+ * Used when API key is confirmed invalid (persistent auth failures).
296
+ * Auto-recovers when the key becomes valid again.
297
+ */
298
+ private enterDormant(): void {
299
+ this.state = "dormant";
300
+ this.abortController = null;
301
+ this.loopPromise = null;
302
+ console.error(
303
+ "[bereach:connector] API key invalid - entering dormant mode. " +
304
+ "Will probe every 15 min and auto-recover when key is fixed.",
305
+ );
306
+ this.scheduleDormantProbe();
307
+ }
308
+
309
+ private scheduleDormantProbe(): void {
310
+ if (this.dormantTimer) clearTimeout(this.dormantTimer);
311
+ this.dormantTimer = setTimeout(() => this.runDormantProbe(), DORMANT_PROBE_INTERVAL_MS);
312
+ this.dormantTimer.unref();
313
+ }
314
+
315
+ private async runDormantProbe(): Promise<void> {
316
+ if (this.state !== "dormant") return;
317
+
318
+ // Re-resolve config to pick up any key changes
319
+ const config = this.freshConfig();
320
+ if (!config?.apiKey) {
321
+ console.log("[bereach:connector] dormant probe: no API key configured, will retry in 15 min");
322
+ this.scheduleDormantProbe();
323
+ return;
324
+ }
325
+
326
+ // Single lightweight heartbeat probe
327
+ try {
328
+ const res = await fetch(`${config.apiUrl}/connectors/heartbeat`, {
329
+ method: "POST",
330
+ headers: {
331
+ "Content-Type": "application/json",
332
+ "Authorization": `Bearer ${config.apiKey}`,
333
+ },
334
+ body: JSON.stringify({}),
335
+ signal: AbortSignal.timeout(10_000),
336
+ });
337
+ if (res.ok) {
338
+ console.log("[bereach:connector] dormant probe succeeded! Restarting connector.");
339
+ this.consecutiveAuthCrashes = 0;
340
+ this.restartCount = 0;
341
+ this.consecutiveErrors = 0;
342
+ this.startLoop();
343
+ return;
344
+ }
345
+ if (res.status === 401 || res.status === 403) {
346
+ console.log("[bereach:connector] dormant probe: still 401, will retry in 15 min");
347
+ } else {
348
+ console.log(`[bereach:connector] dormant probe: HTTP ${res.status}, will retry in 15 min`);
349
+ }
350
+ } catch (err) {
351
+ console.log(`[bereach:connector] dormant probe failed: ${err instanceof Error ? err.message : String(err)}`);
352
+ }
353
+ this.scheduleDormantProbe();
354
+ }
202
355
  }
203
356
 
204
357
  // Module-level singleton
package/src/index.ts CHANGED
@@ -232,14 +232,14 @@ export default function register(api: any) {
232
232
  const gatewayUrl = config.gatewayUrl || readEnv("OPENCLAW_GATEWAY_URL") || "http://localhost:18789";
233
233
  const pollMs = config.connectorPollIntervalMs ?? 15000;
234
234
 
235
- connectorManager.start({
236
- apiKey,
235
+ connectorManager.start(() => ({
236
+ apiKey: resolveApiKey(api),
237
237
  apiUrl: API_BASE,
238
238
  openclawUrl: readEnv("OPENCLAW_URL") || "http://localhost:3579",
239
239
  pollIntervalMs: Math.max(5000, pollMs),
240
240
  gatewayUrl,
241
241
  hooksToken,
242
- }).catch((err) => {
242
+ })).catch((err) => {
243
243
  log(`connector auto-start failed: ${err instanceof Error ? err.message : String(err)}`);
244
244
  });
245
245
  } else if (!connectorEnabled) {