bereach-openclaw 1.5.6 → 1.5.8

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.
@@ -102,4 +102,6 @@ export interface CacheStore {
102
102
  sessionMeta: SessionMeta | null;
103
103
  onboardingState: OnboardingState | null;
104
104
  recentEvents: RecentEvent[];
105
+ /** LLM circuit breaker status: "auth" | "billing" | "error" when tripped, null when OK */
106
+ llmStatus?: string | null;
105
107
  }
@@ -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.",
@@ -1302,6 +1307,11 @@ export const definitions: ToolDefinition[] = [
1302
1307
  },
1303
1308
  },
1304
1309
  },
1310
+ disabledTaskTypes: {
1311
+ type: "array",
1312
+ items: { type: "string" },
1313
+ description: "Task types to stop dispatching for this campaign (e.g. ['lead-gen-discovery']). Use when strategically pausing a task type (low conversion, enough leads). Set to [] to re-enable all.",
1314
+ },
1305
1315
  },
1306
1316
  },
1307
1317
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach-openclaw",
3
3
  "name": "BeReach",
4
- "version": "1.5.6",
4
+ "version": "1.5.8",
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.8",
4
4
  "description": "BeReach LinkedIn automation plugin for OpenClaw",
5
5
  "license": "AGPL-3.0",
6
6
  "exports": {
@@ -59,10 +59,27 @@ type TaskResult = {
59
59
  invitationsAccepted?: number;
60
60
  error?: string;
61
61
  reason?: string;
62
+ // Token usage tracking
63
+ tokenUsage?: {
64
+ inputTokens: number;
65
+ outputTokens: number;
66
+ cacheReadTokens?: number;
67
+ cacheWriteTokens?: number;
68
+ };
69
+ estimatedCostUsd?: number;
70
+ model?: string;
62
71
  };
63
72
 
64
73
  const MIN_POLL_MS = 5000;
65
74
 
75
+ /** Thrown when API returns 401/403 - credentials are invalid, retrying won't help */
76
+ export class AuthError extends Error {
77
+ constructor(public readonly statusCode: number, body: string) {
78
+ super(`Auth failed (HTTP ${statusCode}): ${body.slice(0, 200)}`);
79
+ this.name = "AuthError";
80
+ }
81
+ }
82
+
66
83
  // ---------------------------------------------------------------------------
67
84
  // TaskResult extraction from OpenClaw agent output
68
85
  // ---------------------------------------------------------------------------
@@ -123,6 +140,98 @@ function extractTaskResultFromWrapper(output: Record<string, unknown>): TaskResu
123
140
  return null;
124
141
  }
125
142
 
143
+ /**
144
+ * Extract token usage from the OpenClaw --json wrapper's meta field.
145
+ * Returns null if no usage data is available.
146
+ */
147
+ function extractTokenUsageFromWrapper(
148
+ output: Record<string, unknown>,
149
+ ): { usage: TaskResult["tokenUsage"]; model?: string } | null {
150
+ const result = output?.result as Record<string, unknown> | undefined;
151
+ const meta = (result?.meta ?? output?.meta ?? {}) as Record<string, unknown>;
152
+ // OpenClaw 2026.4+ puts token data in meta.agentMeta.lastCallUsage
153
+ const agentMeta = (meta.agentMeta ?? {}) as Record<string, unknown>;
154
+ const lastCall = (agentMeta.lastCallUsage ?? {}) as Record<string, number>;
155
+ const costData = (meta.cost ?? {}) as Record<string, number>;
156
+ const usageData = (meta.usage ?? {}) as Record<string, number>;
157
+
158
+ const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
159
+ const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
160
+
161
+ if (inputTokens === 0 && outputTokens === 0) return null;
162
+
163
+ const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
164
+ const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
165
+
166
+ return {
167
+ usage: {
168
+ inputTokens,
169
+ outputTokens,
170
+ ...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
171
+ ...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
172
+ },
173
+ model: agentMeta.model as string | undefined,
174
+ };
175
+ }
176
+
177
+ /** Model pricing per 1M tokens */
178
+ const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
179
+ "haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
180
+ "sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
181
+ "flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
182
+ "pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
183
+ };
184
+
185
+ function estimateTaskCost(
186
+ usage: NonNullable<TaskResult["tokenUsage"]>,
187
+ modelSlug?: string | null,
188
+ ): number {
189
+ const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
190
+ const p = MODEL_PRICING[key];
191
+ const cached = usage.cacheReadTokens ?? 0;
192
+ const uncached = Math.max(0, usage.inputTokens - cached);
193
+ return (uncached * p.input + cached * p.cacheRead + usage.outputTokens * p.output) / 1_000_000;
194
+ }
195
+
196
+ /** Enrich a TaskResult with token usage from the OpenClaw wrapper output */
197
+ function enrichResultWithTokens(
198
+ result: TaskResult,
199
+ output: Record<string, unknown>,
200
+ modelSlug?: string | null,
201
+ ): void {
202
+ if (result.tokenUsage) return; // already enriched (e.g. by lifecycle hook)
203
+ const extracted = extractTokenUsageFromWrapper(output);
204
+ if (!extracted?.usage) return;
205
+ const effectiveModel = extracted.model ?? modelSlug ?? undefined;
206
+ result.tokenUsage = extracted.usage;
207
+ result.model = effectiveModel;
208
+ result.estimatedCostUsd = estimateTaskCost(extracted.usage, effectiveModel);
209
+ console.log(
210
+ `[connector] Token usage: input=${extracted.usage.inputTokens} output=${extracted.usage.outputTokens}` +
211
+ ` cacheRead=${extracted.usage.cacheReadTokens ?? 0} cost=$${result.estimatedCostUsd.toFixed(4)}` +
212
+ ` model=${effectiveModel ?? "unknown"}`,
213
+ );
214
+ }
215
+
216
+ // LLM provider error detection — mirrors apps/web/src/lib/errors/llm-errors.ts
217
+ // but self-contained since plugin can't import from apps/web.
218
+ const LLM_ERROR_PATTERNS = [
219
+ /authentication_error/i, /invalid.*api.?key/i, /permission_error/i,
220
+ /401.*unauthorized/i, /access_denied.*api/i, /unauthorized.*invalid/i,
221
+ /out of (extra )?usage/i, /insufficient.quota/i, /exceeded.*current quota/i,
222
+ /RESOURCE_EXHAUSTED/i, /credit balance.*too low/i, /exceeded.*spending.*limit/i,
223
+ /billing.*hard.*limit/i, /payment.*required/i,
224
+ ];
225
+
226
+ /** Scan text for LLM provider errors (auth, billing). Returns the matched text or null. */
227
+ function detectLlmError(text: string): string | null {
228
+ for (const pattern of LLM_ERROR_PATTERNS) {
229
+ const match = text.match(pattern);
230
+ if (match) return match[0];
231
+ }
232
+ return null;
233
+ }
234
+
126
235
  /** Stderr patterns that are noise (module warnings, not actual errors) */
127
236
  const HARMLESS_STDERR_RE = [
128
237
  /Cannot find module '@aws-sdk\/client-bedrock'/,
@@ -167,6 +276,9 @@ async function httpPost(url: string, body: unknown, headers: Record<string, stri
167
276
  });
168
277
  if (!res.ok) {
169
278
  const text = await res.text().catch(() => "");
279
+ if (res.status === 401 || res.status === 403) {
280
+ throw new AuthError(res.status, text);
281
+ }
170
282
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
171
283
  }
172
284
  return res.json();
@@ -337,8 +449,11 @@ async function executeViaWebhook(
337
449
 
338
450
  // Webhook completed - lifecycle hook has already posted the result.
339
451
  // Return a success indicator so the connector doesn't double-report.
452
+ // Also extract token usage for the safety-net submission.
453
+ const webhookResult = (output.result as TaskResult) ?? { success: true };
454
+ enrichResultWithTokens(webhookResult, output, task.model);
340
455
  return {
341
- result: (output.result as TaskResult) ?? { success: true },
456
+ result: webhookResult,
342
457
  error: null,
343
458
  };
344
459
  } catch (err) {
@@ -419,20 +534,25 @@ async function executeViaExecFile(
419
534
  // B17 fix: extract it instead of returning the raw wrapper.
420
535
  const extracted = extractTaskResultFromWrapper(output);
421
536
  if (extracted) {
537
+ enrichResultWithTokens(extracted, output, task.model);
422
538
  return { result: extracted, error: null };
423
539
  }
424
540
 
425
541
  // Fallback: if output itself looks like a flat TaskResult
426
542
  if (output.success !== undefined || output.contactsProcessed !== undefined) {
427
- return { result: output as TaskResult, error: null };
543
+ const flat = output as unknown as TaskResult;
544
+ enrichResultWithTokens(flat, output, task.model);
545
+ return { result: flat, error: null };
428
546
  }
429
547
 
430
- // Last resort: lifecycle hook may have already posted the real result
431
- return { result: { success: true }, error: null };
548
+ // Last resort: lifecycle hook may have already posted the real result.
549
+ // Don't blindly claim success if we can't parse a result, something went wrong.
550
+ // The lifecycle hook will report the real result if it ran successfully.
551
+ return { result: { success: false, reason: "no_parseable_result" } as unknown as TaskResult, error: null };
432
552
  } catch {
433
- // Non-JSON output - treat as success if process exited cleanly
553
+ // Non-JSON output - can't determine success without structured data.
434
554
  return {
435
- result: { success: true },
555
+ result: { success: false, reason: "non_json_output" } as unknown as TaskResult,
436
556
  error: null,
437
557
  };
438
558
  }
@@ -455,11 +575,14 @@ async function executeViaExecFile(
455
575
  // B17 fix: extract TaskResult from OpenClaw wrapper (same as happy path)
456
576
  const extracted = extractTaskResultFromWrapper(output);
457
577
  if (extracted) {
578
+ enrichResultWithTokens(extracted, output, task.model);
458
579
  return { result: extracted, error: null };
459
580
  }
460
581
  // Accept flat { success: true } format
461
582
  if (output.success !== undefined || output.contactsProcessed !== undefined) {
462
- return { result: output as TaskResult, error: null };
583
+ const flat = output as unknown as TaskResult;
584
+ enrichResultWithTokens(flat, output, task.model);
585
+ return { result: flat, error: null };
463
586
  }
464
587
  } catch {
465
588
  // Not JSON
@@ -468,8 +591,20 @@ async function executeViaExecFile(
468
591
 
469
592
  // Filter known-harmless stderr noise (e.g. @aws-sdk/client-bedrock module warnings)
470
593
  const filtered = filterStderr(stderr);
471
- const message = filtered || execErr.message || String(err);
472
- return { result: null, error: message.slice(0, 500) };
594
+ // When execFile fails, err.message is "Command failed: openclaw agent ..." which is useless.
595
+ // Prefer stderr content, then stdout snippet, then exit code - anything more useful than the command string.
596
+ let errorMsg: string;
597
+ if (filtered) {
598
+ errorMsg = filtered;
599
+ } else if (execErr.message && !execErr.message.startsWith("Command failed:")) {
600
+ errorMsg = execErr.message;
601
+ } else {
602
+ // No stderr, message is just the command - build a useful error from what we have
603
+ const exitCode = execErr.code ?? "unknown";
604
+ const stdoutSnippet = stdout ? ` stdout=${stdout.slice(0, 200)}` : "";
605
+ errorMsg = `Agent process exited with code ${exitCode}${stdoutSnippet}`;
606
+ }
607
+ return { result: null, error: errorMsg.slice(0, 500) };
473
608
  }
474
609
  }
475
610
 
@@ -513,7 +648,7 @@ async function updateTaskStatus(
513
648
  }
514
649
 
515
650
  export type ConnectorStatus = {
516
- state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled";
651
+ state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled" | "dormant";
517
652
  uptime: number;
518
653
  lastHeartbeat: number;
519
654
  currentTaskId: string | undefined;
@@ -524,16 +659,26 @@ export type ConnectorStatus = {
524
659
 
525
660
  export async function runConnectorLoop(
526
661
  config: ConnectorConfig,
527
- options?: { signal?: AbortSignal },
662
+ options?: { signal?: AbortSignal; onHeartbeat?: () => void },
528
663
  ): Promise<void> {
529
664
  let pollInterval = config.pollIntervalMs;
530
665
  let currentTaskId: string | undefined;
531
666
  let consecutiveErrors = 0;
667
+ let consecutiveAuthErrors = 0;
532
668
  let totalTasksExecuted = 0;
533
669
  let webhookAvailable = false;
534
670
 
535
671
  const signal = options?.signal;
536
672
 
673
+ // Internal AbortController: links heartbeat + poll loops so either can abort both.
674
+ // When heartbeat detects auth failure → aborts internal → poll loop exits too.
675
+ // External signal (from ConnectorManager) is forwarded to internal.
676
+ const internalAbort = new AbortController();
677
+ const internalSignal = internalAbort.signal;
678
+ if (signal) {
679
+ signal.addEventListener("abort", () => internalAbort.abort(signal.reason), { once: true });
680
+ }
681
+
537
682
  console.log(`[connector] Starting connector loop`);
538
683
  console.log(`[connector] API: ${config.apiUrl}`);
539
684
  console.log(`[connector] Auth: ${config.apiKey ? "API key (Bearer)" : "connector token (legacy)"}`);
@@ -548,34 +693,105 @@ export async function runConnectorLoop(
548
693
  console.log(`[connector] OpenClaw: ${config.openclawUrl}`);
549
694
  }
550
695
 
696
+ // Proactive LLM key validation on startup: detect bad API keys before wasting tasks.
697
+ // Makes a lightweight check against the LLM provider. Non-fatal — just warns.
698
+ const llmModel = readEnv("OPENCLAW_DEFAULT_MODEL") || readEnv("BEREACH_TASK_MODEL");
699
+ if (llmModel) {
700
+ const isAnthropic = llmModel.includes("anthropic") || llmModel.includes("claude");
701
+ const isGemini = llmModel.includes("gemini") || llmModel.includes("google");
702
+ const anthropicKey = readEnv("ANTHROPIC_API_KEY");
703
+ const geminiKey = readEnv("GEMINI_API_KEY") || readEnv("GOOGLE_API_KEY");
704
+
705
+ if (isAnthropic && anthropicKey) {
706
+ try {
707
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
708
+ method: "POST",
709
+ headers: {
710
+ "x-api-key": anthropicKey,
711
+ "anthropic-version": "2023-06-01",
712
+ "content-type": "application/json",
713
+ },
714
+ body: JSON.stringify({ model: "claude-haiku-4-5-20241022", max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
715
+ signal: AbortSignal.timeout(10000),
716
+ });
717
+ if (res.status === 401 || res.status === 403) {
718
+ const body = await res.text().catch(() => "");
719
+ console.error(`[connector] ANTHROPIC_API_KEY is INVALID (HTTP ${res.status}): ${body.slice(0, 200)}`);
720
+ console.error("[connector] Tasks will fail until the API key is updated. Reporting to BeReach API...");
721
+ // Report the error to BeReach API so the circuit breaker can trip
722
+ try {
723
+ await fetch(`${config.apiUrl}/connectors/heartbeat`, {
724
+ method: "POST",
725
+ headers: buildHeaders(config),
726
+ body: JSON.stringify({ llmKeyError: { provider: "anthropic", status: res.status, error: body.slice(0, 200) } }),
727
+ signal: AbortSignal.timeout(5000),
728
+ });
729
+ } catch { /* best effort */ }
730
+ } else {
731
+ console.log(`[connector] Anthropic API key validated (HTTP ${res.status})`);
732
+ }
733
+ } catch (err) {
734
+ console.warn(`[connector] Anthropic key validation failed: ${err instanceof Error ? err.message : String(err)}`);
735
+ }
736
+ } else if (isGemini && geminiKey) {
737
+ try {
738
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${geminiKey}`, {
739
+ signal: AbortSignal.timeout(10000),
740
+ });
741
+ if (res.status === 401 || res.status === 403) {
742
+ console.error(`[connector] GEMINI_API_KEY is INVALID (HTTP ${res.status})`);
743
+ } else {
744
+ console.log(`[connector] Gemini API key validated (HTTP ${res.status})`);
745
+ }
746
+ } catch (err) {
747
+ console.warn(`[connector] Gemini key validation failed: ${err instanceof Error ? err.message : String(err)}`);
748
+ }
749
+ }
750
+ }
751
+
551
752
  // Heartbeat runs independently - does NOT touch pollInterval or consecutiveErrors.
552
753
  // Otherwise it would neutralize exponential backoff during error recovery.
754
+ // Uses internalAbort so auth failures kill both heartbeat AND poll loops.
553
755
  let consecutiveHeartbeatFailures = 0;
756
+ let consecutiveHeartbeatAuthErrors = 0;
554
757
  const heartbeatLoop = async () => {
555
- while (!signal?.aborted) {
758
+ let heartbeatIntervalMs = 30_000;
759
+ while (!internalSignal.aborted) {
556
760
  try {
557
761
  await heartbeat(config, currentTaskId);
558
762
  consecutiveHeartbeatFailures = 0;
763
+ consecutiveHeartbeatAuthErrors = 0;
764
+ heartbeatIntervalMs = 30_000;
765
+ options?.onHeartbeat?.();
559
766
  } catch (err) {
560
767
  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");
768
+ if (err instanceof AuthError) {
769
+ consecutiveHeartbeatAuthErrors++;
770
+ console.error(`[connector] Heartbeat auth failed (${consecutiveHeartbeatAuthErrors}/3): ${err.message}`);
771
+ if (consecutiveHeartbeatAuthErrors >= 3) {
772
+ console.error("[connector] FATAL: 3 consecutive auth failures in heartbeat, API key is invalid");
773
+ internalAbort.abort(err);
774
+ return;
775
+ }
776
+ } else {
777
+ consecutiveHeartbeatAuthErrors = 0;
778
+ console.error(`[connector] Heartbeat failed (${consecutiveHeartbeatFailures}):`, err instanceof Error ? err.message : String(err));
779
+ if (consecutiveHeartbeatFailures === 5) {
780
+ console.warn("[connector] WARNING: 5 consecutive heartbeat failures, API may be unreachable");
781
+ }
782
+ // Exponential backoff: 30s → 60s → 120s → max 5min (never die for transient errors)
783
+ heartbeatIntervalMs = Math.min(heartbeatIntervalMs * 2, 5 * 60 * 1000);
569
784
  }
570
785
  }
571
786
  await new Promise((r) => {
572
- const timer = setTimeout(r, 30_000);
573
- signal?.addEventListener("abort", () => { clearTimeout(timer); r(undefined); }, { once: true });
787
+ const timer = setTimeout(r, heartbeatIntervalMs);
788
+ internalSignal.addEventListener("abort", () => { clearTimeout(timer); r(undefined); }, { once: true });
574
789
  });
575
790
  }
576
791
  };
577
792
  heartbeatLoop().catch((err) => {
578
793
  console.error("[connector] Heartbeat loop crashed:", err instanceof Error ? err.message : String(err));
794
+ if (!internalSignal.aborted) internalAbort.abort(err);
579
795
  });
580
796
 
581
797
  // Task execution lock - prevents concurrent task execution.
@@ -583,11 +799,11 @@ export async function runConnectorLoop(
583
799
  let isExecuting = false;
584
800
  let idlePollCount = 0;
585
801
 
586
- while (!signal?.aborted) {
802
+ while (!internalSignal.aborted) {
587
803
  try {
588
804
  // Skip polling while a task is running - prevents claiming concurrent tasks
589
805
  if (isExecuting) {
590
- await sleep(pollInterval, signal);
806
+ await sleep(pollInterval, internalSignal);
591
807
  continue;
592
808
  }
593
809
 
@@ -664,6 +880,17 @@ export async function runConnectorLoop(
664
880
  console.warn(`[connector] DIAGNOSTIC: Task ${task.id} failed in <10s with 0 tool calls — likely context injection failure or model error`);
665
881
  }
666
882
 
883
+ // Scan output for LLM provider errors (auth/billing) that the gateway
884
+ // may not have flagged via meta.stopReason. If found, force-fail the task
885
+ // so the circuit breaker can detect it on the API side.
886
+ const allText = [error, result?.error, (result as any)?.reason].filter(Boolean).join(" ");
887
+ const llmError = detectLlmError(allText);
888
+ if (llmError && result && result.success !== false) {
889
+ console.warn(`[connector] LLM provider error detected in task ${task.id}: ${llmError}`);
890
+ result.success = false;
891
+ if (!result.error) result.error = `LLM provider error: ${llmError}`;
892
+ }
893
+
667
894
  // Always submit from connector as safety net. In webhook mode the lifecycle
668
895
  // hook usually reports first, but if it fails silently (network, crash) the
669
896
  // result is lost and the task stays "running" forever. The result endpoint
@@ -691,10 +918,22 @@ export async function runConnectorLoop(
691
918
  isExecuting = false;
692
919
  currentTaskId = undefined;
693
920
  (globalThis as any).__bereachCurrentTaskId = undefined;
694
- console.error(
695
- `[connector] Poll error (${consecutiveErrors}):`,
696
- err,
697
- );
921
+
922
+ if (err instanceof AuthError) {
923
+ consecutiveAuthErrors++;
924
+ console.error(`[connector] Poll auth failed (${consecutiveAuthErrors}/3): ${err.message}`);
925
+ if (consecutiveAuthErrors >= 3) {
926
+ console.error("[connector] FATAL: 3 consecutive auth failures in poll loop, API key is invalid");
927
+ internalAbort.abort(err);
928
+ break;
929
+ }
930
+ } else {
931
+ consecutiveAuthErrors = 0;
932
+ console.error(
933
+ `[connector] Poll error (${consecutiveErrors}):`,
934
+ err instanceof Error ? err.message : String(err),
935
+ );
936
+ }
698
937
 
699
938
  pollInterval = Math.min(
700
939
  config.pollIntervalMs * Math.pow(2, consecutiveErrors),
@@ -711,10 +950,17 @@ export async function runConnectorLoop(
711
950
  }
712
951
  }
713
952
 
714
- await sleep(pollInterval, signal);
953
+ await sleep(pollInterval, internalSignal);
715
954
  }
716
955
 
717
956
  console.log("[connector] Loop stopped (abort signal received)");
957
+
958
+ // If internal abort fired (auth failure) but external didn't (not a graceful shutdown),
959
+ // throw so ConnectorManager.handleCrash() triggers with the auth error.
960
+ if (internalSignal.aborted && !signal?.aborted) {
961
+ const reason = internalSignal.reason;
962
+ throw reason instanceof Error ? reason : new Error("Connector loop aborted internally");
963
+ }
718
964
  }
719
965
 
720
966
  /** 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
@@ -176,6 +176,7 @@ export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
176
176
  sessionMeta: snapshot.sessionMeta ?? null,
177
177
  onboardingState: snapshot.onboardingState ?? null,
178
178
  recentEvents: snapshot.recentEvents ?? [],
179
+ llmStatus: snapshot.llmStatus ?? null,
179
180
  };
180
181
 
181
182
  // Fallback: if snapshot didn't include limits, fetch from dedicated endpoint
@@ -584,6 +584,21 @@ function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?: string
584
584
 
585
585
  // ── ACTIVE MODE: data-only context for onboarded users ──
586
586
 
587
+ // LLM provider error warning — show prominently so the agent can inform the user
588
+ if (data.llmStatus) {
589
+ lines.push("### AI Provider Error");
590
+ if (data.llmStatus === "auth") {
591
+ lines.push("Your AI provider credentials are INVALID. Automated campaigns are paused.");
592
+ lines.push("Tell the user to update their API key in their provider's dashboard.");
593
+ } else if (data.llmStatus === "billing") {
594
+ lines.push("Your AI provider credits are EXHAUSTED. Automated campaigns are paused.");
595
+ lines.push("Tell the user to add credits or upgrade their plan with their AI provider.");
596
+ } else {
597
+ lines.push("There is an issue with the AI provider. Automated campaigns may be paused.");
598
+ }
599
+ lines.push("");
600
+ }
601
+
587
602
  // Account
588
603
  if (data.activeAccount) {
589
604
  const a = data.activeAccount;
@@ -318,9 +318,15 @@ function createDmGuard() {
318
318
  async function guardScheduledDm(toolName: string, params: Record<string, unknown>, key: string, state: SessionState) {
319
319
  if (toolName !== "bereach_scheduled_message_create") return null;
320
320
 
321
- // DM history check
321
+ // DM history check — skip in task mode when prefetch already loaded conversation
322
+ // data server-side. The agent is instructed to skip tool calls when prefetch is
323
+ // present, so dmHistoryChecked will be empty. This is safe because:
324
+ // 1. Prefetch runs the same dedup check server-side before injecting data
325
+ // 2. Drafts (default status) are user-reviewed before sending
326
+ const isDraft = !params.status || params.status === "draft";
327
+ const isTaskMode = !!state.currentTaskMode;
322
328
  const dmId = extractDmIdentifier(params);
323
- if (dmId && !state.dmHistoryChecked.has(dmId)) {
329
+ if (dmId && !state.dmHistoryChecked.has(dmId) && !(isTaskMode && isDraft)) {
324
330
  log(`scheduled DM dedup BLOCKED: history not checked for ${dmId}`);
325
331
  return {
326
332
  blocked: true,
@@ -65,6 +65,82 @@ function parseStructuredResult(text: string): Record<string, unknown> | null {
65
65
  /** Exported for tests. */
66
66
  export { parseStructuredResult as _parseStructuredResult };
67
67
 
68
+ // ---------------------------------------------------------------------------
69
+ // Token usage extraction & cost estimation
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Model pricing per 1M tokens (hardcoded — plugin can't import from apps/web) */
73
+ const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
74
+ "haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
75
+ "sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
76
+ "flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
77
+ "pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
78
+ };
79
+
80
+ function estimateTaskCost(
81
+ inputTokens: number,
82
+ outputTokens: number,
83
+ cacheReadTokens: number,
84
+ modelSlug?: string,
85
+ ): number {
86
+ const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
87
+ const p = MODEL_PRICING[key];
88
+ const uncached = Math.max(0, inputTokens - cacheReadTokens);
89
+ return (uncached * p.input + cacheReadTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
90
+ }
91
+
92
+ /**
93
+ * Enrich a task result with token usage and cost estimation from OpenClaw's
94
+ * endCtx.meta. The meta object may contain:
95
+ * - meta.cost: { inputTokens, outputTokens } (preferred)
96
+ * - meta.usage: { input, output, cacheRead, cacheWrite } (alternative)
97
+ */
98
+ function enrichWithTokenUsage(
99
+ taskResult: Record<string, unknown>,
100
+ endCtx: unknown,
101
+ taskMode: TaskModeInfo,
102
+ ): void {
103
+ try {
104
+ const meta = (endCtx as any)?.meta ?? (endCtx as any)?.result?.meta ?? {};
105
+ // OpenClaw provides token data in multiple possible locations:
106
+ // - meta.agentMeta.lastCallUsage (OpenClaw 2026.4+)
107
+ // - meta.cost (older versions / test runner)
108
+ // - meta.usage (alternative format)
109
+ const agentMeta = meta.agentMeta ?? {};
110
+ const lastCall = agentMeta.lastCallUsage ?? {};
111
+ const costData = meta.cost ?? {};
112
+ const usageData = meta.usage ?? {};
113
+
114
+ const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
115
+ const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
116
+
117
+ // No token data available — skip enrichment
118
+ if (inputTokens === 0 && outputTokens === 0) {
119
+ log(`token usage: no data in endCtx.meta for ${taskMode.taskId} (keys: ${Object.keys(meta).join(",")})`);
120
+ return;
121
+ }
122
+
123
+ const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
124
+ const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
125
+
126
+ taskResult.tokenUsage = {
127
+ inputTokens,
128
+ outputTokens,
129
+ ...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
130
+ ...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
131
+ };
132
+
133
+ // Model slug from agentMeta (most reliable), env, or task mode
134
+ const modelSlug = agentMeta.model ?? readEnv("BEREACH_TASK_MODEL") ?? readEnv("OPENCLAW_MODEL") ?? undefined;
135
+ taskResult.model = modelSlug;
136
+ taskResult.estimatedCostUsd = estimateTaskCost(inputTokens, outputTokens, cacheRead, modelSlug);
137
+
138
+ log(`token usage for ${taskMode.taskId}: input=${inputTokens} output=${outputTokens} cacheRead=${cacheRead} cacheWrite=${cacheWrite} cost=$${(taskResult.estimatedCostUsd as number).toFixed(4)} model=${modelSlug ?? "unknown"}`);
139
+ } catch (err) {
140
+ log(`token usage extraction failed: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ }
143
+
68
144
  // ---------------------------------------------------------------------------
69
145
  // Hook registration
70
146
  // ---------------------------------------------------------------------------
@@ -156,6 +232,9 @@ export function registerLifecycleHook(
156
232
  const shouldSubmit = result || !isExecFileMode;
157
233
  const taskResult = result ?? fallbackResult;
158
234
 
235
+ // Enrich with token usage from OpenClaw meta (if available)
236
+ enrichWithTokenUsage(taskResult, endCtx, taskMode);
237
+
159
238
  // Retry with backoff — task results are critical, losing them means
160
239
  // the task stays "running" forever and the workflow chain stalls.
161
240
  let posted = false;
package/src/index.ts CHANGED
@@ -87,10 +87,10 @@ async function autoDetectModels(apiKey: string): Promise<void> {
87
87
  const currentCreative = data.aiModels?.creative;
88
88
 
89
89
  // Only auto-switch if still on Anthropic defaults (respect manual choices)
90
- if (
90
+ const isAnthropicDefault =
91
91
  currentFast === "anthropic/claude-haiku-4-5" &&
92
- currentCreative === "anthropic/claude-sonnet-4-6"
93
- ) {
92
+ (currentCreative === "anthropic/claude-haiku-4-5" || currentCreative === "anthropic/claude-sonnet-4-6");
93
+ if (isAnthropicDefault) {
94
94
  const patchRes = await fetch(`${API_BASE}/me/settings`, {
95
95
  method: "PATCH",
96
96
  headers: {
@@ -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) {