@swarmclawai/swarmclaw 0.7.6 → 0.7.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.
Files changed (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -332,6 +332,7 @@ export interface ConnectorReconnectState {
332
332
  nextRetryAt: number
333
333
  backoffMs: number
334
334
  error: string
335
+ exhausted: boolean
335
336
  }
336
337
 
337
338
  const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
@@ -342,6 +343,55 @@ const RECONNECT_INITIAL_BACKOFF_MS = 1_000
342
343
  const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
343
344
  const RECONNECT_MAX_ATTEMPTS = 10
344
345
 
346
+ interface ConnectorReconnectPolicy {
347
+ initialBackoffMs?: number
348
+ maxBackoffMs?: number
349
+ maxAttempts?: number
350
+ }
351
+
352
+ export function createConnectorReconnectState(
353
+ init: Partial<ConnectorReconnectState> = {},
354
+ policy: ConnectorReconnectPolicy = {},
355
+ ): ConnectorReconnectState {
356
+ return {
357
+ attempts: init.attempts ?? 0,
358
+ lastAttemptAt: init.lastAttemptAt ?? 0,
359
+ nextRetryAt: init.nextRetryAt ?? 0,
360
+ backoffMs: init.backoffMs ?? policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS,
361
+ error: init.error ?? '',
362
+ exhausted: init.exhausted ?? false,
363
+ }
364
+ }
365
+
366
+ export function advanceConnectorReconnectState(
367
+ previous: ConnectorReconnectState,
368
+ error: string,
369
+ now = Date.now(),
370
+ policy: ConnectorReconnectPolicy = {},
371
+ ): ConnectorReconnectState {
372
+ const initialBackoffMs = policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS
373
+ const maxBackoffMs = policy.maxBackoffMs ?? RECONNECT_MAX_BACKOFF_MS
374
+ const maxAttempts = policy.maxAttempts ?? RECONNECT_MAX_ATTEMPTS
375
+ const attempts = previous.attempts + 1
376
+ const backoffMs = Math.min(maxBackoffMs, initialBackoffMs * (2 ** Math.max(0, attempts - 1)))
377
+ return {
378
+ attempts,
379
+ lastAttemptAt: now,
380
+ nextRetryAt: now + backoffMs,
381
+ backoffMs,
382
+ error,
383
+ exhausted: attempts >= maxAttempts,
384
+ }
385
+ }
386
+
387
+ export function clearReconnectState(connectorId: string): void {
388
+ reconnectState.delete(connectorId)
389
+ }
390
+
391
+ export function setReconnectState(connectorId: string, state: ConnectorReconnectState): void {
392
+ reconnectState.set(connectorId, state)
393
+ }
394
+
345
395
  /** Record a health event for a connector (persisted to connector_health collection) */
346
396
  function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType, message?: string): void {
347
397
  const id = genId()
@@ -2008,33 +2058,43 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2008
2058
  const connector = connectors[connectorId] as Connector | undefined
2009
2059
  if (!connector) throw new Error('Connector not found')
2010
2060
 
2011
- // Resolve bot token from credential
2012
- let botToken = ''
2013
- if (connector.credentialId) {
2014
- const creds = loadCredentials()
2015
- const cred = creds[connector.credentialId]
2016
- if (cred?.encryptedKey) {
2017
- try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
2018
- }
2019
- }
2020
- // Also check config for inline token (some platforms)
2021
- if (!botToken && connector.config.botToken) {
2022
- botToken = connector.config.botToken
2023
- }
2024
- if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
2025
- botToken = connector.config.password
2061
+ // Starting a connector expresses durable intent: keep it enabled across
2062
+ // transient failures so daemon recovery and server restarts can retry it.
2063
+ if (connector.isEnabled !== true) {
2064
+ connector.isEnabled = true
2065
+ connector.updatedAt = Date.now()
2066
+ connectors[connectorId] = connector
2067
+ saveConnectors(connectors)
2068
+ notify('connectors')
2026
2069
  }
2027
2070
 
2028
- if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
2029
- throw new Error('No bot token configured')
2030
- }
2071
+ try {
2072
+ // Resolve bot token from credential
2073
+ let botToken = ''
2074
+ if (connector.credentialId) {
2075
+ const creds = loadCredentials()
2076
+ const cred = creds[connector.credentialId]
2077
+ if (cred?.encryptedKey) {
2078
+ try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
2079
+ }
2080
+ }
2081
+ // Also check config for inline token (some platforms)
2082
+ if (!botToken && connector.config.botToken) {
2083
+ botToken = connector.config.botToken
2084
+ }
2085
+ if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
2086
+ botToken = connector.config.password
2087
+ }
2031
2088
 
2032
- const platform = await getPlatform(connector.platform)
2089
+ if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
2090
+ throw new Error('No bot token configured')
2091
+ }
2033
2092
 
2034
- // Bump generation counter so stale events from previous instances are ignored
2035
- generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
2093
+ const platform = await getPlatform(connector.platform)
2094
+
2095
+ // Bump generation counter so stale events from previous instances are ignored
2096
+ generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
2036
2097
 
2037
- try {
2038
2098
  const instance = await platform.start(
2039
2099
  connector,
2040
2100
  botToken,
@@ -2049,6 +2109,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2049
2109
  connector.updatedAt = Date.now()
2050
2110
  connectors[connectorId] = connector
2051
2111
  saveConnectors(connectors)
2112
+ clearReconnectState(connectorId)
2052
2113
  notify('connectors')
2053
2114
 
2054
2115
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
@@ -2056,7 +2117,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2056
2117
  } catch (err: unknown) {
2057
2118
  const errMsg = err instanceof Error ? err.message : String(err)
2058
2119
  connector.status = 'error'
2059
- connector.isEnabled = false
2120
+ connector.isEnabled = true
2060
2121
  connector.lastError = errMsg
2061
2122
  connector.updatedAt = Date.now()
2062
2123
  connectors[connectorId] = connector
@@ -2074,6 +2135,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
2074
2135
  await instance.stop()
2075
2136
  running.delete(connectorId)
2076
2137
  }
2138
+ clearReconnectState(connectorId)
2077
2139
 
2078
2140
  for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
2079
2141
  if (entry.connector.id !== connectorId) continue
@@ -2141,6 +2203,7 @@ export async function repairConnector(connectorId: string): Promise<void> {
2141
2203
  await instance.stop()
2142
2204
  running.delete(connectorId)
2143
2205
  }
2206
+ clearReconnectState(connectorId)
2144
2207
 
2145
2208
  // Clear auth directory
2146
2209
  const { clearAuthDir } = await import('./whatsapp')
@@ -2609,7 +2672,7 @@ export async function checkConnectorHealth(): Promise<void> {
2609
2672
  // Connector is healthy — clear any reconnect state
2610
2673
  if (reconnectState.has(id)) {
2611
2674
  console.log(`[connector-health] Connector "${instance.connector.name}" recovered`)
2612
- reconnectState.delete(id)
2675
+ clearReconnectState(id)
2613
2676
  }
2614
2677
  continue
2615
2678
  }
@@ -2627,68 +2690,30 @@ export async function checkConnectorHealth(): Promise<void> {
2627
2690
 
2628
2691
  // If the connector is not enabled, don't attempt reconnect
2629
2692
  if (!connector.isEnabled) {
2630
- reconnectState.delete(id)
2693
+ clearReconnectState(id)
2631
2694
  continue
2632
2695
  }
2633
2696
 
2634
- // Attempt reconnect with backoff
2635
- const state = reconnectState.get(id) ?? {
2636
- attempts: 0,
2637
- lastAttemptAt: 0,
2638
- nextRetryAt: 0,
2639
- backoffMs: RECONNECT_INITIAL_BACKOFF_MS,
2640
- error: '',
2641
- }
2642
-
2643
- // Check if we've exceeded max attempts
2644
- if (state.attempts >= RECONNECT_MAX_ATTEMPTS) {
2645
- console.warn(`[connector-health] Connector "${connector.name}" exceeded ${RECONNECT_MAX_ATTEMPTS} reconnect attempts — marking as error`)
2646
- connector.status = 'error'
2647
- connector.lastError = `Auto-reconnect gave up after ${RECONNECT_MAX_ATTEMPTS} attempts: ${state.error}`
2648
- connector.updatedAt = Date.now()
2649
- connectors[id] = connector
2650
- connectorsDirty = true
2651
- reconnectState.delete(id)
2652
- notify('connectors')
2653
- continue
2654
- }
2655
-
2656
- const now = Date.now()
2657
-
2658
- // Check if enough time has passed for the next retry
2659
- if (now < state.nextRetryAt) {
2660
- // Not yet time to retry — keep state and skip
2661
- continue
2662
- }
2663
-
2664
- state.attempts += 1
2665
- state.lastAttemptAt = now
2666
- reconnectState.set(id, state)
2667
-
2668
- try {
2669
- console.log(`[connector-health] Reconnecting "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS})`)
2670
- await startConnector(id)
2671
- // Success — clear reconnect state
2672
- reconnectState.delete(id)
2673
- console.log(`[connector-health] Connector "${connector.name}" reconnected successfully`)
2674
- recordHealthEvent(id, 'reconnected', `Connector "${connector.name}" reconnected after ${state.attempts} attempt(s)`)
2675
- } catch (err: unknown) {
2676
- const errorMsg = err instanceof Error ? err.message : String(err)
2677
- state.error = errorMsg
2678
- state.backoffMs = Math.min(RECONNECT_MAX_BACKOFF_MS, RECONNECT_INITIAL_BACKOFF_MS * (2 ** state.attempts))
2679
- state.nextRetryAt = now + state.backoffMs
2680
- reconnectState.set(id, state)
2681
- console.warn(`[connector-health] Reconnect failed for "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS}): ${errorMsg}. Next retry at ${new Date(state.nextRetryAt).toISOString()}`)
2697
+ connector.status = 'error'
2698
+ connector.lastError = connector.lastError || 'Connection lost'
2699
+ connector.updatedAt = Date.now()
2700
+ connectors[id] = connector
2701
+ connectorsDirty = true
2702
+ if (!reconnectState.has(id)) {
2703
+ setReconnectState(id, createConnectorReconnectState({
2704
+ error: connector.lastError || 'Connection lost',
2705
+ }))
2682
2706
  }
2683
2707
  }
2684
2708
 
2685
2709
  if (connectorsDirty) {
2686
2710
  saveConnectors(connectors)
2711
+ notify('connectors')
2687
2712
  }
2688
2713
 
2689
2714
  // Purge reconnect state for connectors that no longer exist
2690
2715
  for (const id of reconnectState.keys()) {
2691
- if (!connectors[id]) reconnectState.delete(id)
2716
+ if (!connectors[id] || connectors[id]?.isEnabled !== true || running.has(id)) clearReconnectState(id)
2692
2717
  }
2693
2718
  }
2694
2719
 
@@ -11,6 +11,12 @@ import {
11
11
  startConnector,
12
12
  getConnectorStatus,
13
13
  checkConnectorHealth,
14
+ createConnectorReconnectState,
15
+ advanceConnectorReconnectState,
16
+ clearReconnectState,
17
+ getAllReconnectStates,
18
+ getReconnectState,
19
+ setReconnectState,
14
20
  } from './connectors/manager'
15
21
  import { startHeartbeatService, stopHeartbeatService, getHeartbeatServiceStatus } from './heartbeat-service'
16
22
  import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGateway } from './openclaw-gateway'
@@ -34,6 +40,7 @@ const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
34
40
  const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
35
41
  const BROWSER_MAX_AGE = 10 * 60 * 1000 // 10 minutes idle = orphaned
36
42
  const HEALTH_CHECK_INTERVAL = 120_000 // 2 minutes
43
+ const CONNECTOR_HEALTH_CHECK_INTERVAL = 5_000 // 5 seconds
37
44
  const MEMORY_CONSOLIDATION_INTERVAL = 6 * 3600_000 // 6 hours
38
45
  const MEMORY_CONSOLIDATION_INITIAL_DELAY = 60_000 // 1 minute after daemon start
39
46
  const STALE_MULTIPLIER = 4 // session is stale after N × heartbeat interval
@@ -87,12 +94,12 @@ const ds: {
87
94
  queueIntervalId: ReturnType<typeof setInterval> | null
88
95
  browserSweepId: ReturnType<typeof setInterval> | null
89
96
  healthIntervalId: ReturnType<typeof setInterval> | null
97
+ connectorHealthIntervalId: ReturnType<typeof setInterval> | null
90
98
  memoryConsolidationTimeoutId: ReturnType<typeof setTimeout> | null
91
99
  memoryConsolidationIntervalId: ReturnType<typeof setInterval> | null
92
100
  evalSchedulerIntervalId: ReturnType<typeof setInterval> | null
93
101
  /** Session IDs we've already alerted as stale (alert-once semantics). */
94
102
  staleSessionIds: Set<string>
95
- connectorRestartState: Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>
96
103
  /** OpenClaw gateway agent IDs currently considered down. */
97
104
  openclawDownAgentIds: Set<string>
98
105
  /** Per-agent auto-repair state for OpenClaw gateways. */
@@ -107,11 +114,11 @@ const ds: {
107
114
  queueIntervalId: null,
108
115
  browserSweepId: null,
109
116
  healthIntervalId: null,
117
+ connectorHealthIntervalId: null,
110
118
  memoryConsolidationTimeoutId: null,
111
119
  memoryConsolidationIntervalId: null,
112
120
  evalSchedulerIntervalId: null,
113
121
  staleSessionIds: new Set<string>(),
114
- connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>(),
115
122
  openclawDownAgentIds: new Set<string>(),
116
123
  openclawRepairState: new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>(),
117
124
  lastIntegrityCheckAt: null,
@@ -123,7 +130,6 @@ const ds: {
123
130
 
124
131
  // Backfill fields for hot-reloaded daemon state objects from older code versions.
125
132
  if (!ds.staleSessionIds) ds.staleSessionIds = new Set<string>()
126
- if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>()
127
133
  if (!ds.openclawDownAgentIds) ds.openclawDownAgentIds = new Set<string>()
128
134
  if (!ds.openclawRepairState) ds.openclawRepairState = new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>()
129
135
  if (ds.lastIntegrityCheckAt === undefined) ds.lastIntegrityCheckAt = null
@@ -132,6 +138,7 @@ if (ds.lastIntegrityDriftCount === undefined) ds.lastIntegrityDriftCount = 0
132
138
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
139
  if ((ds as any).issueLastAlertAt) delete (ds as any).issueLastAlertAt
134
140
  if (ds.healthIntervalId === undefined) ds.healthIntervalId = null
141
+ if (ds.connectorHealthIntervalId === undefined) ds.connectorHealthIntervalId = null
135
142
  if (ds.manualStopRequested === undefined) ds.manualStopRequested = false
136
143
  if (ds.memoryConsolidationTimeoutId === undefined) ds.memoryConsolidationTimeoutId = null
137
144
  if (ds.memoryConsolidationIntervalId === undefined) ds.memoryConsolidationIntervalId = null
@@ -156,6 +163,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
156
163
  startQueueProcessor()
157
164
  startBrowserSweep()
158
165
  startHealthMonitor()
166
+ startConnectorHealthMonitor()
159
167
  startHeartbeatService()
160
168
  startMemoryConsolidation()
161
169
  startEvalScheduler()
@@ -173,6 +181,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
173
181
  startQueueProcessor()
174
182
  startBrowserSweep()
175
183
  startHealthMonitor()
184
+ startConnectorHealthMonitor()
176
185
  startHeartbeatService()
177
186
  startMemoryConsolidation()
178
187
  startEvalScheduler()
@@ -201,6 +210,7 @@ export function stopDaemon(options?: { source?: string; manualStop?: boolean })
201
210
  stopQueueProcessor()
202
211
  stopBrowserSweep()
203
212
  stopHealthMonitor()
213
+ stopConnectorHealthMonitor()
204
214
  stopHeartbeatService()
205
215
  stopMemoryConsolidation()
206
216
  stopEvalScheduler()
@@ -278,7 +288,8 @@ async function sendHealthAlert(text: string) {
278
288
  }
279
289
 
280
290
  async function runConnectorHealthChecks(now: number) {
281
- // First, check isAlive() on running instances and attempt reconnection for dead ones
291
+ // First, collapse dead runtime instances into persisted error state so the
292
+ // daemon can own the restart cadence and backoff policy.
282
293
  try {
283
294
  await checkConnectorHealth()
284
295
  } catch (err: unknown) {
@@ -289,48 +300,30 @@ async function runConnectorHealthChecks(now: number) {
289
300
  for (const connector of Object.values(connectors) as Record<string, unknown>[]) {
290
301
  if (!connector?.id || typeof connector.id !== 'string') continue
291
302
  if (connector.isEnabled !== true) {
292
- ds.connectorRestartState.delete(connector.id)
303
+ clearReconnectState(connector.id)
293
304
  continue
294
305
  }
295
306
 
296
307
  const runtimeStatus = getConnectorStatus(connector.id)
297
308
  if (runtimeStatus === 'running') {
298
- ds.connectorRestartState.delete(connector.id)
309
+ clearReconnectState(connector.id)
299
310
  continue
300
311
  }
301
312
 
302
- const current = ds.connectorRestartState.get(connector.id) || { lastAttemptAt: 0, failCount: 0, wakeAttempts: 0 }
303
- // Backfill wakeAttempts for state objects created before this field existed
304
- if (typeof current.wakeAttempts !== 'number') current.wakeAttempts = 0
305
-
306
- // Cap wake attempts — stop retrying after MAX_WAKE_ATTEMPTS consecutive failures
307
- if (current.wakeAttempts >= MAX_WAKE_ATTEMPTS) {
308
- console.warn(`[health] Connector "${connector.name}" exceeded ${MAX_WAKE_ATTEMPTS} wake attempts — giving up`)
309
- connector.status = 'error'
310
- connector.lastError = `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures`
311
- connector.updatedAt = Date.now()
312
- connectors[connector.id] = connector
313
- saveConnectors(connectors)
314
- ds.connectorRestartState.delete(connector.id)
315
- createNotification({
316
- type: 'error',
317
- title: `Connector "${connector.name}" failed`,
318
- message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures.`,
319
- dedupKey: `connector-gave-up:${connector.id}`,
320
- entityType: 'connector',
321
- entityId: connector.id,
322
- })
313
+ const current = getReconnectState(connector.id)
314
+ ?? createConnectorReconnectState(
315
+ { error: typeof connector.lastError === 'string' ? connector.lastError : '' },
316
+ { initialBackoffMs: CONNECTOR_RESTART_BASE_MS },
317
+ )
318
+
319
+ if (current.exhausted) {
323
320
  continue
324
321
  }
325
322
 
326
- const backoffMs = Math.min(
327
- CONNECTOR_RESTART_MAX_MS,
328
- CONNECTOR_RESTART_BASE_MS * (2 ** Math.min(6, current.failCount)),
329
- )
330
- if ((now - current.lastAttemptAt) < backoffMs) continue
323
+ if (current.nextRetryAt > now) continue
331
324
 
332
325
  // Notify on first detection of a down connector
333
- if (current.wakeAttempts === 0) {
326
+ if (current.attempts === 0) {
334
327
  createNotification({
335
328
  type: 'warning',
336
329
  title: `Connector "${connector.name}" is down`,
@@ -341,24 +334,43 @@ async function runConnectorHealthChecks(now: number) {
341
334
  })
342
335
  }
343
336
 
344
- current.lastAttemptAt = now
345
- ds.connectorRestartState.set(connector.id, current)
346
337
  try {
347
338
  await startConnector(connector.id)
348
- ds.connectorRestartState.delete(connector.id)
339
+ clearReconnectState(connector.id)
349
340
  await sendHealthAlert(`Connector "${connector.name}" (${connector.platform}) was down and has been auto-restarted.`)
350
341
  } catch (err: unknown) {
351
- current.failCount += 1
352
- current.wakeAttempts += 1
353
- ds.connectorRestartState.set(connector.id, current)
354
342
  const message = err instanceof Error ? err.message : String(err)
355
- console.warn(`[health] Connector auto-restart failed for ${connector.name} (attempt ${current.wakeAttempts}/${MAX_WAKE_ATTEMPTS}): ${message}`)
343
+ const next = advanceConnectorReconnectState(current, message, now, {
344
+ initialBackoffMs: CONNECTOR_RESTART_BASE_MS,
345
+ maxBackoffMs: CONNECTOR_RESTART_MAX_MS,
346
+ maxAttempts: MAX_WAKE_ATTEMPTS,
347
+ })
348
+ setReconnectState(connector.id, next)
349
+ if (next.exhausted) {
350
+ console.warn(`[health] Connector "${connector.name}" exceeded ${MAX_WAKE_ATTEMPTS} auto-restart attempts — giving up until the server restarts or the user retries manually`)
351
+ connector.status = 'error'
352
+ connector.lastError = `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} attempts: ${message}`
353
+ connector.updatedAt = Date.now()
354
+ connectors[connector.id] = connector
355
+ saveConnectors(connectors)
356
+ notify('connectors')
357
+ createNotification({
358
+ type: 'error',
359
+ title: `Connector "${connector.name}" failed`,
360
+ message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} attempts.`,
361
+ dedupKey: `connector-gave-up:${connector.id}`,
362
+ entityType: 'connector',
363
+ entityId: connector.id,
364
+ })
365
+ } else {
366
+ console.warn(`[health] Connector auto-restart failed for ${connector.name} (attempt ${next.attempts}/${MAX_WAKE_ATTEMPTS}): ${message}`)
367
+ }
356
368
  }
357
369
  }
358
370
 
359
371
  // Purge restart state for connectors that no longer exist in storage
360
- for (const id of ds.connectorRestartState.keys()) {
361
- if (!connectors[id]) ds.connectorRestartState.delete(id)
372
+ for (const id of Object.keys(getAllReconnectStates())) {
373
+ if (!connectors[id] || connectors[id]?.isEnabled !== true) clearReconnectState(id)
362
374
  }
363
375
  }
364
376
 
@@ -776,8 +788,6 @@ async function runHealthChecks() {
776
788
 
777
789
  if (sessionsDirty) saveSessions(sessions)
778
790
 
779
- await runConnectorHealthChecks(now)
780
-
781
791
  // Provider reachability checks
782
792
  try {
783
793
  await runProviderHealthChecks()
@@ -851,6 +861,26 @@ function stopHealthMonitor() {
851
861
  }
852
862
  }
853
863
 
864
+ function startConnectorHealthMonitor() {
865
+ if (ds.connectorHealthIntervalId) return
866
+
867
+ const tick = () => {
868
+ runConnectorHealthChecks(Date.now()).catch((err) => {
869
+ console.error('[daemon] Connector health tick failed:', err instanceof Error ? err.message : String(err))
870
+ })
871
+ }
872
+
873
+ tick()
874
+ ds.connectorHealthIntervalId = setInterval(tick, CONNECTOR_HEALTH_CHECK_INTERVAL)
875
+ }
876
+
877
+ function stopConnectorHealthMonitor() {
878
+ if (ds.connectorHealthIntervalId) {
879
+ clearInterval(ds.connectorHealthIntervalId)
880
+ ds.connectorHealthIntervalId = null
881
+ }
882
+ }
883
+
854
884
  function runConsolidationTick() {
855
885
  import('./memory-consolidation').then(({ runDailyConsolidation }) =>
856
886
  runDailyConsolidation().then((stats) => {
@@ -951,12 +981,16 @@ function stopEvalScheduler() {
951
981
  }
952
982
 
953
983
  export async function runDaemonHealthCheckNow() {
954
- await runHealthChecks()
984
+ await Promise.all([
985
+ runHealthChecks(),
986
+ runConnectorHealthChecks(Date.now()),
987
+ ])
955
988
  }
956
989
 
957
990
  export function getDaemonStatus() {
958
991
  const queue = loadQueue()
959
992
  const schedules = loadSchedules()
993
+ const reconnectStates = Object.values(getAllReconnectStates())
960
994
 
961
995
  // Find next scheduled task
962
996
  let nextScheduled: number | null = null
@@ -985,9 +1019,12 @@ export function getDaemonStatus() {
985
1019
  heartbeat: getHeartbeatServiceStatus(),
986
1020
  health: {
987
1021
  monitorActive: !!ds.healthIntervalId,
1022
+ connectorMonitorActive: !!ds.connectorHealthIntervalId,
988
1023
  staleSessions: ds.staleSessionIds.size,
989
- connectorsInBackoff: ds.connectorRestartState.size,
1024
+ connectorsInBackoff: reconnectStates.filter((state) => !state.exhausted).length,
1025
+ connectorsExhausted: reconnectStates.filter((state) => state.exhausted).length,
990
1026
  checkIntervalSec: Math.trunc(HEALTH_CHECK_INTERVAL / 1000),
1027
+ connectorCheckIntervalSec: Math.trunc(CONNECTOR_HEALTH_CHECK_INTERVAL / 1000),
991
1028
  integrity: {
992
1029
  enabled: loadSettings().integrityMonitorEnabled !== false,
993
1030
  lastCheckedAt: ds.lastIntegrityCheckAt,
@@ -1,12 +1,61 @@
1
1
  import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { requestElevenLabsMp3Stream, synthesizeElevenLabsMp3 } from './elevenlabs'
3
+ import { requestElevenLabsMp3Stream, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from './elevenlabs'
4
+ import { loadSettings, saveSettings } from './storage'
4
5
 
5
6
  describe('elevenlabs helpers', () => {
7
+ it('prefers agent override first, then settings default, then env fallback', () => {
8
+ const originalSettings = loadSettings()
9
+ const originalKey = process.env.ELEVENLABS_API_KEY
10
+ const originalVoice = process.env.ELEVENLABS_VOICE
11
+
12
+ try {
13
+ saveSettings({
14
+ ...originalSettings,
15
+ elevenLabsApiKey: 'settings-key',
16
+ elevenLabsVoiceId: 'settings-voice',
17
+ })
18
+ process.env.ELEVENLABS_API_KEY = 'env-key'
19
+ process.env.ELEVENLABS_VOICE = 'env-voice'
20
+
21
+ assert.deepEqual(resolveElevenLabsConfig('agent-voice'), {
22
+ apiKey: 'settings-key',
23
+ voiceId: 'agent-voice',
24
+ })
25
+ assert.deepEqual(resolveElevenLabsConfig(null), {
26
+ apiKey: 'settings-key',
27
+ voiceId: 'settings-voice',
28
+ })
29
+
30
+ saveSettings({
31
+ ...originalSettings,
32
+ elevenLabsApiKey: 'settings-key',
33
+ elevenLabsVoiceId: null,
34
+ })
35
+
36
+ assert.deepEqual(resolveElevenLabsConfig(undefined), {
37
+ apiKey: 'settings-key',
38
+ voiceId: 'env-voice',
39
+ })
40
+ } finally {
41
+ saveSettings(originalSettings)
42
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
43
+ else process.env.ELEVENLABS_API_KEY = originalKey
44
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
45
+ else process.env.ELEVENLABS_VOICE = originalVoice
46
+ }
47
+ })
48
+
6
49
  it('synthesizeElevenLabsMp3 posts TTS request and returns audio bytes', async () => {
7
50
  const originalFetch = global.fetch
51
+ const originalSettings = loadSettings()
8
52
  const originalKey = process.env.ELEVENLABS_API_KEY
9
53
  const originalVoice = process.env.ELEVENLABS_VOICE
54
+ saveSettings({
55
+ ...originalSettings,
56
+ elevenLabsApiKey: null,
57
+ elevenLabsVoiceId: null,
58
+ })
10
59
  process.env.ELEVENLABS_API_KEY = 'test-key'
11
60
  process.env.ELEVENLABS_VOICE = 'voice-123'
12
61
 
@@ -25,6 +74,7 @@ describe('elevenlabs helpers', () => {
25
74
  assert.equal(out.toString('utf8'), 'abc')
26
75
  } finally {
27
76
  global.fetch = originalFetch
77
+ saveSettings(originalSettings)
28
78
  if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
29
79
  else process.env.ELEVENLABS_API_KEY = originalKey
30
80
  if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
@@ -34,14 +84,21 @@ describe('elevenlabs helpers', () => {
34
84
 
35
85
  it('requestElevenLabsMp3Stream calls streaming endpoint', async () => {
36
86
  const originalFetch = global.fetch
87
+ const originalSettings = loadSettings()
37
88
  const originalKey = process.env.ELEVENLABS_API_KEY
38
89
  const originalVoice = process.env.ELEVENLABS_VOICE
90
+ saveSettings({
91
+ ...originalSettings,
92
+ elevenLabsApiKey: null,
93
+ elevenLabsVoiceId: null,
94
+ })
39
95
  process.env.ELEVENLABS_API_KEY = 'test-key'
40
96
  process.env.ELEVENLABS_VOICE = 'voice-xyz'
41
97
 
42
98
  global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
43
99
  assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-xyz/stream')
44
100
  assert.equal(init?.method, 'POST')
101
+ assert.equal((init?.headers as Record<string, string>)['xi-api-key'], 'test-key')
45
102
  return new Response('stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
46
103
  }) as typeof fetch
47
104
 
@@ -51,6 +108,7 @@ describe('elevenlabs helpers', () => {
51
108
  assert.equal(await res.text(), 'stream')
52
109
  } finally {
53
110
  global.fetch = originalFetch
111
+ saveSettings(originalSettings)
54
112
  if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
55
113
  else process.env.ELEVENLABS_API_KEY = originalKey
56
114
  if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
@@ -12,6 +12,7 @@ import { log } from './logger'
12
12
  import { WORKSPACE_DIR } from './data-dir'
13
13
  import { drainSystemEvents } from './system-events'
14
14
  import { buildIdentityContinuityContext } from './identity-continuity'
15
+ import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
15
16
  import { ensureAgentThreadSession } from './agent-thread-session'
16
17
 
17
18
  const HEARTBEAT_TICK_MS = 5_000
@@ -434,7 +435,10 @@ async function tickHeartbeats() {
434
435
  if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
435
436
  continue
436
437
  }
437
- const heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
438
+ const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
439
+ const heartbeatMessage = isMainSession(session)
440
+ ? buildMainLoopHeartbeatPrompt(session, baseHeartbeatMessage)
441
+ : baseHeartbeatMessage
438
442
 
439
443
  const enqueue = enqueueSessionRun({
440
444
  sessionId: session.id,