Skip to content
Automation
Hook

Hooks

What ai-maestro runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
ai-maestro
84133 hooks1 MCP

Where it lives

  • hooks/useAgentBoard.tsGitHub
    Read the script
    'use client'
    
    import { useState, useEffect, useCallback, useMemo } from 'react'
    import type { TaskWithDeps, TaskStatus } from '@/types/task'
    import type { Host } from '@/types/host'
    import { isSelfHost } from '@/lib/host-kind'
    import { cardUrl, dedupeCards, fetchSharedCards } from '@/lib/task-client'
    import type { TaskUpdates, CommentResult } from '@/hooks/useTasks'
    
    /**
     * One agent's board: its own cards plus the cards of its teams assigned to it.
     * `hostUrl` is the host the agent lives on ('' = this host). Every call goes to that host: a host
     * that does not own the agent has no cards for it, and a wrong host looks like an empty board.
     */
    /** A card with the host that holds it, set for project cards (their project can be on any host) */
    export type BoardCard = TaskWithDeps & { hostBase?: string }
    
    export function useAgentBoard(agentId: string | null, hostUrl: string = '', hosts: Host[] = [], aliases: string[] = []) {
      const [tasks, setTasks] = useState<BoardCard[]>([])
      const [loading, setLoading] = useState(false)
      const [error, setError] = useState<string | null>(null)
    
      const fetchBoard = useCallback(async () => {
        if (!agentId) return
        try {
          const res = await fetch(`${hostUrl}/api/agents/${agentId}/board`, { signal: AbortSignal.timeout(10000) })
          if (res.status === 404) throw new Error('This host does not know this agent')
          if (!res.ok) throw new Error('Could not load the board')
          const data = await res.json()
          // Project cards for this agent: a project is stored on its owner's host, which may not be this agent's,
          // so every host is asked. A host that does not answer simply adds nothing.
          const targets = hosts.length > 0 ? hosts.filter(h => h.enabled !== false) : [{ id: 'local', name: 'This host', url: '', isSelf: true } as Host]
          const projectCards = (await Promise.all(targets.map(async host => {
            const base = isSelfHost(host) ? '' : host.url.replace(/\/$/, '')
            try {
              const r = await fetch(`${base}/api/projects?assignee=${encodeURIComponent(agentId)}`, { signal: AbortSignal.timeout(6000) })
              if (!r.ok) return []
              const d = await r.json()
              return ((d.projects || []) as Array<{ cards?: TaskWithDeps[] }>).flatMap(p => (p.cards || []).map(c => ({ ...c, hostBase: base })))
            } catch {
              return []
            }
          }))).flat()
          // Cards on OTHER hosts that list this agent as a co-owner: the agent's own host already put its local ones in
          // `data.cards`, so it is not asked again. A host that does not answer adds nothing. The names let a host
          // that does not know the agent match an owner given by name.
          const sharedCards = await fetchSharedCards(agentId, aliases, targets.map(h => (isSelfHost(h) ? '' : h.url.replace(/\/$/, ''))), hostUrl)
          // The same card id can come from more than one answer: it is listed once, the board's own copy first
          setTasks(dedupeCards([...(data.cards || []), ...projectCards, ...sharedCards]))
          setError(null)
        } catch (err) {
          setError(err instanceof Error ? err.message : 'The host is offline or out of reach')
        }
      // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [agentId, hostUrl, aliases.join(','), hosts.map(h => `${h.id}|${h.url}|${h.enabled}`).join(',')])
    
      useEffect(() => {
        if (!agentId) { setTasks([]); return }
        setLoading(true)
        fetchBoard().finally(() => setLoading(false))
        const timer = setInterval(fetchBoard, 5000)
        return () => clearInterval(timer)
      }, [agentId, fetchBoard])
    
      // A card is changed through the API of the board it lives on
      const base = useCallback((task: BoardCard) =>
        // A card shared with this agent stays on the board it was made on, on the host that holds it
        task.shared
          ? cardUrl(task.hostBase ?? hostUrl, { id: task.id, scope: task.scope, teamId: task.teamId, projectId: task.projectId, ownerAgentId: task.ownerAgentId })
          : task.scope === 'project' && task.projectId
          ? cardUrl(task.hostBase ?? '', { id: task.id, scope: 'project', projectId: task.projectId })
          : task.scope === 'team' && task.teamId
            ? `${hostUrl}/api/teams/${task.teamId}/tasks/${task.id}`
            : `${hostUrl}/api/agents/${agentId}/tasks/${task.id}`, [agentId, hostUrl])
    
      const createTask = useCallback(async (data: { subject: string; description?: string; blockedBy?: string[]; priority?: number }) => {
        if (!agentId) return
        // A new card goes on the agent's own board; sharing work with others is a team (or project) card
        const res = await fetch(`${hostUrl}/api/agents/${agentId}/tasks`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ subject: data.subject, description: data.description, blockedBy: data.blockedBy, priority: data.priority }),
        })
        if (!res.ok) throw new Error('Failed to create task')
        await fetchBoard()
      }, [agentId, hostUrl, fetchBoard])
    
      const updateTask = useCallback(async (taskId: string, updates: TaskUpdates) => {
        const task = tasks.find(t => t.id === taskId)
        if (!task) return { unblocked: [] as TaskWithDeps[] }
        setTasks(prev => prev.map(t => t.id === taskId ? { ...t, ...updates, updatedAt: new Date().toISOString() } as TaskWithDeps : t))
        // A card on the agent's own board stays the agent's: the assignee is not sent
        const body = task.scope === 'team' || task.scope === 'project' ? updates : { ...updates, assigneeAgentId: undefined }
        const res = await fetch(base(task), {
          method: task.scope === 'team' || task.scope === 'project' ? 'PUT' : 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        })
        if (!res.ok) {
          await fetchBoard()
          const detail = await res.json().catch(() => ({}))
          throw new Error(detail?.message || detail?.error || 'Failed to update task')
        }
        const data = await res.json()
        await fetchBoard()
        return { unblocked: data.
  • hooks/useAgentPlayback.tsGitHub
    Read the script
    'use client'
    
    import { useState, useEffect, useCallback, useRef } from 'react'
    import type { PlaybackState, PlaybackControl } from '@/types/playback'
    
    /**
     * Default playback speed
     */
    const DEFAULT_PLAYBACK_SPEED = 1.0
    
    /**
     * Valid playback speed options
     */
    export const PLAYBACK_SPEEDS = [0.5, 1.0, 1.5, 2.0] as const
    
    /**
     * Hook for managing agent transcript playback
     *
     * Handles playback state management, controls (play/pause/seek/speed),
     * and message loading for conversation transcript playback.
     *
     * @param agentId - Agent ID to manage playback for
     * @param sessionId - Session ID to play back (optional for cross-session)
     */
    export function useAgentPlayback(
      agentId: string,
      sessionId?: string
    ) {
      const [state, setState] = useState<PlaybackState | null>(null)
      const [loading, setLoading] = useState(true)
      const [error, setError] = useState<Error | null>(null)
      const [messages, setMessages] = useState<Array<{
        role: 'user' | 'assistant' | 'system'
        content: string
        timestamp?: number
        metadata?: Record<string, any>
      }>>([])
      const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
    
      // Refs for timers and tracking
      const autoSaveTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
      const isMountedRef = useRef(true)
    
      /**
       * Load playback state from API
       */
      const loadPlaybackState = useCallback(async () => {
        setLoading(true)
        setError(null)
    
        try {
          console.log(`[useAgentPlayback] Loading state for agent ${agentId}, session ${sessionId || 'all'}`)
    
          const queryParams = sessionId ? `?sessionId=${sessionId}` : ''
          const response = await fetch(`/api/agents/${agentId}/playback${queryParams}`)
    
          if (!response.ok) {
            const errorData = await response.json()
            throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`)
          }
    
          const data = await response.json()
    
          if (!isMountedRef.current) return
    
          if (data.success && data.playbackState) {
            setState(data.playbackState)
            console.log(`[useAgentPlayback] Loaded state: playing=${data.playbackState.isPlaying}, position=${data.playbackState.currentMessageIndex}`)
          } else {
            // Initialize default state if none exists
            setState({
              agentId,
              sessionId,
              isPlaying: false,
              currentMessageIndex: 0,
              speed: DEFAULT_PLAYBACK_SPEED,
              totalMessages: 0,
              createdAt: Date.now(),
              updatedAt: Date.now()
            })
          }
        } catch (err) {
          if (!isMountedRef.current) return
    
          console.error('[useAgentPlayback] Failed to load state:', err)
          setError(err instanceof Error ? err : new Error('Failed to load playback state'))
          
          // Set default state on error
          setState({
            agentId,
            sessionId,
            isPlaying: false,
            currentMessageIndex: 0,
            speed: DEFAULT_PLAYBACK_SPEED,
            totalMessages: 0,
            createdAt: Date.now(),
            updatedAt: Date.now()
          })
        } finally {
          if (isMountedRef.current) {
            setLoading(false)
          }
        }
      }, [agentId, sessionId])
    
      /**
       * Update playback state via API
       */
      const updatePlaybackState = useCallback(async (action: PlaybackControl) => {
        if (!state) return
    
        try {
          console.log(`[useAgentPlayback] Updating state: ${action.action}`, action.value ?? '')
    
          const response = await fetch(`/api/agents/${agentId}/playback`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({
              action: action.action,
              value: action.value,
              sessionId
            })
          })
    
          if (!response.ok) {
            const errorData = await response.json()
            throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`)
          }
    
          const data = await response.json()
    
          if (!isMountedRef.current) return
    
          if (data.success && data.playbackState) {
            setState(data.playbackState)
          }
        } catch (err) {
          if (!isMountedRef.current) return
    
          console.error('[useAgentPlayback] Failed to update state:', err)
          setError(err instanceof Error ? err : new Error('Failed to update playback state'))
        }
      }, [agentId, sessionId, state])
    
      /**
       * Start playback
       */
      const start = useCallback(() => {
        if (!state || state.isPlaying) return
        updatePlaybackState({ action: 'play' })
      }, [state, updatePlaybackState])
    
      /**
       * Pause playback
       */
      const pause = useCallback(() => {
        if (!state || !state.isPlaying) return
        updatePlaybackState({ action: 'pause' })
      }, [state, updatePlaybackState])
    
      /**
       * Toggle play/pause
       */
      const toggle = useCallback(() => {
        if (!state) return
        if (state.isPlaying) {
          pause()
        } else {
          start()
        }
      }, [state, start, pause])
    
      /**
       * Seek to specific message index
       */
      const seek = useCallback((position: number) => {
        if (!state || position < 0) return
        const maxPosition = state.totalMessages !== undefined 
          ? Math.min(position, state.totalMessages - 1) 
          : position
        updatePlaybackState({ action: 'seek', value: maxPosition })
      }, [state, updatePlaybackState])
    
      /**
       * Set playback speed
       */
      const setSpeed = useCallback((speed: number) => {
        if (!state || speed < 0.5 || speed > 2.0) return
        updatePlaybackState({ action: 'setSpeed', value: speed })
      }, [state, updatePlaybackState])
    
      /**
       * Reset playback to beginning
       */
      const reset = useCallback(() => {
        updatePlaybackState({ action: 'reset' })
      }, [updatePlaybackState])
    
      /**
       * Move to next message
       */
      const next = useCallback(() => {
        if (!state) return
        const newPosition = state.currentMessageIndex + 1
        seek(newPosition)
      }, [state, seek])
    
      /**
       * Move to previous message
       */
      const previous = useCallback(() => {
        if (!state) return
        const newPosition = Math.max(0,
  • hooks/useAgentSearch.tsGitHub
    Read the script
    'use client'
    
    import { useState, useEffect, useCallback, useRef } from 'react'
    import type { SearchQuery, SearchResult, HighlightedSearchResult } from '@/types/search'
    
    /**
     * Debounce delay for search queries (in milliseconds)
     */
    const SEARCH_DEBOUNCE_MS = 300
    
    /**
     * Interface for search results with highlighting
     */
    export interface SearchResults {
      results: HighlightedSearchResult[]
      total: number
      query: string
      highlights: string[]
      timestamp: number
    }
    
    /**
     * Hook for searching agent conversation data
     *
     * Provides debounced search, results management, and error handling
     * for searching across agent messages, conversations, and code.
     *
     * @param agentId - Agent ID to search within
     */
    export function useAgentSearch(agentId: string) {
      const [query, setQuery] = useState<string>('')
      const [results, setResults] = useState<SearchResults | null>(null)
      const [loading, setLoading] = useState(false)
      const [error, setError] = useState<Error | null>(null)
      const [debouncedQuery, setDebouncedQuery] = useState<string>('')
    
      // Ref to track debounce timer
      const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
    
      // Ref to track if component is mounted (for async operations)
      const isMountedRef = useRef(true)
    
      /**
       * Perform search with the current debounced query
       */
      const performSearch = useCallback(async (searchQuery: string) => {
        if (!searchQuery.trim()) {
          setResults(null)
          setLoading(false)
          setError(null)
          return
        }
    
        setLoading(true)
        setError(null)
    
        try {
          console.log(`[useAgentSearch] Searching for: "${searchQuery}" in agent ${agentId}`)
    
          const queryParams = new URLSearchParams({
            q: searchQuery
          })
    
          const response = await fetch(`/api/agents/${agentId}/search?${queryParams.toString()}`)
    
          if (!response.ok) {
            const errorData = await response.json()
            throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`)
          }
    
          const data = await response.json()
    
          if (!isMountedRef.current) return
    
          const searchResults: HighlightedSearchResult[] = data.results || []
          const searchTerms = extractSearchTerms(searchQuery)
          const highlightedResults = highlightResults(searchResults, searchTerms)
    
          setResults({
            results: highlightedResults,
            total: data.count || highlightedResults.length,
            query: searchQuery,
            highlights: searchTerms,
            timestamp: Date.now()
          })
    
          console.log(`[useAgentSearch] Found ${data.count} results for query "${searchQuery}"`)
        } catch (err) {
          if (!isMountedRef.current) return
    
          console.error('[useAgentSearch] Search failed:', err)
          setError(err instanceof Error ? err : new Error('Unknown search error'))
          setResults(null)
        } finally {
          if (isMountedRef.current) {
            setLoading(false)
          }
        }
      }, [agentId])
    
      /**
       * Update search query with debouncing
       */
      useEffect(() => {
        if (debounceTimerRef.current) {
          clearTimeout(debounceTimerRef.current)
        }
    
        debounceTimerRef.current = setTimeout(() => {
          setDebouncedQuery(query)
        }, SEARCH_DEBOUNCE_MS)
    
        return () => {
          if (debounceTimerRef.current) {
            clearTimeout(debounceTimerRef.current)
          }
        }
      }, [query])
    
      /**
       * Perform search when debounced query changes
       */
      useEffect(() => {
        performSearch(debouncedQuery)
      }, [debouncedQuery, performSearch])
    
      /**
       * Clear search results and query
       */
      const clearSearch = useCallback(() => {
        setQuery('')
        setResults(null)
        setError(null)
        setLoading(false)
      }, [])
    
      /**
       * Retry the last search
       */
      const retrySearch = useCallback(() => {
        if (debouncedQuery) {
          performSearch(debouncedQuery)
        }
      }, [debouncedQuery, performSearch])
    
      /**
       * Extract search terms from query for highlighting
       * Removes common stop words and punctuation
       */
      function extractSearchTerms(queryText: string): string[] {
        const words = queryText
          .toLowerCase()
          .replace(/[^\w\s]/g, '')
          .split(/\s+/)
          .filter(word => word.length > 2)
    
        const stopWords = new Set([
          'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
          'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during',
          'before', 'after', 'above', 'below', 'between', 'under', 'again'
        ])
    
        return words.filter(word => !stopWords.has(word))
      }
    
      /**
       * Add highlighting to search results
       */
      function highlightResults(
        searchResults: SearchResult[],
        highlightTerms: string[]
      ): HighlightedSearchResult[] {
        return searchResults.map((result) => {
          const text = result.text
          const highlightRanges: Array<{ start: number; end: number }> = []
    
          for (const term of highlightTerms) {
            const lowerText = text.toLowerCase()
            const lowerTerm = term.toLowerCase()
            let position = lowerText.indexOf(lowerTerm)
    
            while (position !== -1) {
              highlightRanges.push({ start: position, end: position + term.length })
              position = lowerText.indexOf(lowerTerm, position + term.length)
            }
          }
    
          highlightRanges.sort((a, b) => a.start - b.start)
    
          let highlightedText = ''
          let lastIndex = 0
    
          for (const range of highlightRanges) {
            highlightedText += text.substring(lastIndex, range.start)
            highlightedText += `<mark>${text.substring(range.start, range.end)}</mark>`
            lastIndex = range.end
          }
    
          highlightedText += text.substring(lastIndex)
    
          return {
            ...result,
            highlightedText,
            highlightRanges
          }
        })
      }
    
      return {
        // State
        query,
        results,
        loading,
        error,
    
        // Actions
        setQuery,
        clearSearch,
        retrySearch
      }
    }
    
  • hooks/useAgents.tsGitHub
    Read the script
    'use client'
    
    import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
    import type { Agent, AgentsApiResponse, AgentStats, AgentHostInfo } from '@/types/agent'
    import type { Host } from '@/types/host'
    import { useHosts } from './useHosts'
    import { cacheRemoteAgents, getCachedAgents } from '@/lib/agent-cache'
    
    const REFRESH_INTERVAL = 10000 // 10 seconds
    const SELF_FETCH_TIMEOUT = 8000 // 8 seconds for self host (tmux queries can be slow)
    const PEER_FETCH_TIMEOUT = 3000 // 3 seconds for peer hosts (fail fast, use cache)
    
    /**
     * Check if a host URL points to localhost (the machine running this dashboard)
     * Used client-side since os.hostname() isn't available in browser
     */
    function isLocalhostUrl(url: string | undefined): boolean {
      if (!url) return true
      const lowered = url.toLowerCase()
      return lowered.includes('localhost') || lowered.includes('127.0.0.1')
    }
    
    /**
     * Aggregated stats across all hosts
     */
    interface AggregatedStats {
      total: number
      online: number
      offline: number
      orphans: number
      newlyRegistered: number
      cached: number // Number of agents loaded from cache
    }
    
    /**
     * Host fetch result
     */
    interface HostFetchResult {
      hostId: string
      success: boolean
      response?: AgentsApiResponse
      error?: Error
      fromCache?: boolean
    }
    
    /** Tell the server log about a client-side event (best effort, never throws). */
    function reportClientEvent(event: string, detail: string) {
      try {
        fetch('/api/debug/client-event', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ event, lastError: detail, path: location.pathname }),
        }).catch(() => { /* diagnostics only */ })
      } catch { /* diagnostics only */ }
    }
    
    /**
     * Fetch agents from a specific host
     */
    /** A host's agent list as this page uses it: host fields on every agent, peers cached for offline use */
    function asHostResult(host: Host, data: AgentsApiResponse, isSelf: boolean): HostFetchResult {
      // Inject host info directly onto agents (for remote hosts, ensure correct hostId/hostName/hostUrl)
      const agents = data.agents.map(agent => ({
        ...agent,
        hostId: host.id,
        hostName: host.name,
        hostUrl: host.url,
        isSelf,
      }))
    
      // Cache peer host agents for offline access (not self host)
      if (!isSelf) {
        cacheRemoteAgents(host.id, agents)
      }
    
      return {
        hostId: host.id,
        success: true,
        response: {
          ...data,
          agents,
          hostInfo: {
            ...data.hostInfo,
            id: host.id,
            name: host.name,
            isSelf,
          }
        }
      }
    }
    
    /** A host on an older mesh protocol: its list is fetched directly, but no status comes with it, so Unknown */
    function markOldVersion<T extends HostFetchResult>(r: T, version: string): T {
      if (!r.response) return r
      return { ...r, response: { ...r.response, agents: r.response.agents.map(a => ({ ...a, hostUnreachable: true, hostOldVersion: version })) } }
    }
    
    /** A host that cannot be reached: keep its last list, with the status shown as Unknown */
    function unreachable(response: AgentsApiResponse): AgentsApiResponse {
      return { ...response, agents: response.agents.map(a => ({ ...a, hostUnreachable: true })) }
    }
    
    /** Every peer's agents as this page's own host received them over /mesh (`?mesh=1`) */
    type MeshPeers = Array<{ hostId: string; agents: Agent[] }>
    /** Peers on an older mesh protocol (hostId -> version): their agents' status cannot be known */
    type OldVersionHosts = Record<string, string>
    
    async function fetchHostAgents(host: Host, query = ''): Promise<HostFetchResult & { peers?: MeshPeers; oldVersionHosts?: OldVersionHosts }> {
      const isSelf = host.isSelf || isLocalhostUrl(host.url)
      const baseUrl = isSelf ? '' : host.url
      const timeout = isSelf ? SELF_FETCH_TIMEOUT : PEER_FETCH_TIMEOUT
    
      try {
        const controller = new AbortController()
        const timeoutId = setTimeout(() => controller.abort(), timeout)
    
        const response = await fetch(`${baseUrl}/api/agents${query}`, {
          signal: controller.signal
        })
    
        clearTimeout(timeoutId)
    
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`)
        }
    
        const { peers, oldVersionHosts, ...data } = await response.json() as AgentsApiResponse & { peers?: MeshPeers; oldVersionHosts?: OldVersionHosts }
        return { ...asHostResult(host, data, isSelf), ...(peers && { peers }), ...(oldVersionHosts && { oldVersionHosts }) }
      } catch (error) {
        console.error(`[useAgents] Failed to fetch from ${host.name} (${host.url}):`, error)
    
        // Try to use cached data for peer hosts (not self)
        if (!isSelf) {
          const cachedAgents = getCachedAgents(host.id)
          if (cachedAgents && cachedAgents.length > 0) {
            console.log(`[useAgents] Using cached data for ${host.name}`)
            return {
              hostId: host.id,
              success: true,
              fromCache: true,
              response: {
                agents: cachedAgents.map(a => ({ ...a, hostUnreachable: true })),
                stats: {
                  total: cachedAgents.length,
                  online: cachedAgents.filter(a => a.session?.status === 'online').length,
                  offline: cachedAgents.filter(a => a.session?.status === 'offline').length,
                  orphans: cachedAgents.filter(a => a.isOrphan).length,
                  newlyRegistered: 0
                },
                hostInfo: {
                  id: host.id,
                  name: host.name,
                  url: host.url,
                  isSelf: false,
                }
              }
            }
          }
        }
    
        return {
          hostId: host.id,
          success: false,
          error: error instanceof Error ? error : new Error('Unknown error')
        }
      }
    }
    
    /**
     * Aggregate results from multiple hosts
     */
    function aggregateResults(results: HostFetchResult[]): {
      agents: Agent[]
      stats: AggregatedStats
      hostErrors: Record<string, Error>
    } {
      const allAgents: Agent[] = []
      const hostErrors: Record<string, Error> = {}
      let cachedCount = 0
    
      for (const result of results) {
        if (re
  • hooks/useCompanionWebSocket.tsGitHub
    Read the script
    'use client'
    
    import { useEffect, useRef, useCallback, useState } from 'react'
    
    interface UseCompanionWebSocketOptions {
      agentId: string | null
      onSpeech: (text: string) => void
      onInterrupt?: () => void
    }
    
    /**
     * Hook for bidirectional communication with the server's cerebellum voice subsystem.
     * Connects to /companion-ws?agent={agentId}, receives speech events,
     * and can send user messages back to the voice subsystem.
     */
    export type CompanionWsStatus = 'connecting' | 'open' | 'failed'
    
    export function useCompanionWebSocket({ agentId, onSpeech, onInterrupt }: UseCompanionWebSocketOptions) {
      const onSpeechRef = useRef(onSpeech)
      onSpeechRef.current = onSpeech
      const onInterruptRef = useRef(onInterrupt)
      onInterruptRef.current = onInterrupt
    
      const wsRef = useRef<WebSocket | null>(null)
      // 'failed' once the retries are used up; 'connecting' covers the first attempt and reconnects
      const [status, setStatus] = useState<CompanionWsStatus>('connecting')
    
      const send = useCallback((data: Record<string, unknown>) => {
        if (wsRef.current?.readyState === WebSocket.OPEN) {
          try {
            wsRef.current.send(JSON.stringify(data))
          } catch {
            // Ignore send errors
          }
        }
      }, [])
    
      useEffect(() => {
        if (!agentId) return
        setStatus('connecting')
    
        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
        const wsUrl = `${protocol}//${window.location.host}/companion-ws?agent=${encodeURIComponent(agentId)}`
    
        let ws: WebSocket | null = null
        let mounted = true
        let retryCount = 0
        const maxRetries = 5
        const retryDelays = [1000, 2000, 3000, 5000, 10000]
    
        function connect() {
          if (!mounted) return
    
          // Close any existing socket to prevent orphaned connections
          if (ws && ws.readyState !== WebSocket.CLOSED) {
            ws.close()
          }
    
          ws = new WebSocket(wsUrl)
          wsRef.current = ws
    
          ws.onopen = () => {
            retryCount = 0
            setStatus('open')
            console.log('[CompanionWS] Connected for agent', agentId?.substring(0, 8))
          }
    
          ws.onmessage = (event) => {
            try {
              const data = JSON.parse(event.data)
              if (data.type === 'speech' && data.text) {
                onSpeechRef.current(data.text)
              } else if (data.type === 'interrupt') {
                onInterruptRef.current?.()
              }
            } catch {
              // Ignore non-JSON messages
            }
          }
    
          ws.onclose = () => {
            // Guard against stale closures from orphaned sockets
            if (wsRef.current !== ws) return
            wsRef.current = null
            if (!mounted) return
            if (retryCount < maxRetries) {
              const delay = retryDelays[retryCount] || retryDelays[retryDelays.length - 1]
              retryCount++
              setStatus('connecting')
              setTimeout(connect, delay)
            } else {
              setStatus('failed')
            }
          }
    
          ws.onerror = () => {
            // onclose will handle reconnection
          }
        }
    
        connect()
    
        return () => {
          mounted = false
          if (ws) {
            ws.close()
            ws = null
          }
          wsRef.current = null
        }
      }, [agentId])
    
      return { send, status }
    }
    
  • hooks/useDeviceType.tsGitHub
    Read the script
    'use client'
    
    import { useState, useEffect } from 'react'
    
    export type DeviceType = 'phone' | 'tablet' | 'desktop'
    
    interface DeviceInfo {
      deviceType: DeviceType
      isTouch: boolean
    }
    
    function detectTouch(): boolean {
      if (typeof window === 'undefined') return false
      // Primary check: CSS media query for coarse pointer (touch screens)
      if (window.matchMedia?.('(pointer: coarse)').matches) return true
      // Fallback: touch event support
      if ('ontouchstart' in window) return true
      // Fallback: navigator check
      if (navigator.maxTouchPoints > 0) return true
      return false
    }
    
    function classify(width: number, isTouch: boolean): DeviceType {
      if (width < 768) return 'phone'
      // Touch devices at any width >= 768 get tablet experience
      // Non-touch devices between 768-1023 also get tablet (small laptop screens are fine with it)
      if (isTouch) return 'tablet'
      if (width < 1024) return 'tablet'
      return 'desktop'
    }
    
    export function useDeviceType(): DeviceInfo {
      const [info, setInfo] = useState<DeviceInfo>(() => {
        if (typeof window === 'undefined') return { deviceType: 'desktop', isTouch: false }
        const isTouch = detectTouch()
        const deviceType = classify(window.innerWidth, isTouch)
        return { deviceType, isTouch }
      })
    
      useEffect(() => {
        const update = () => {
          const isTouch = detectTouch()
          const deviceType = classify(window.innerWidth, isTouch)
          setInfo(prev => {
            if (prev.deviceType === deviceType && prev.isTouch === isTouch) return prev
            return { deviceType, isTouch }
          })
        }
    
        // Listen for resize
        window.addEventListener('resize', update)
    
        // Listen for pointer capability changes (e.g. connecting/disconnecting mouse)
        const mql = window.matchMedia?.('(pointer: coarse)')
        if (mql?.addEventListener) {
          mql.addEventListener('change', update)
        }
    
        // Initial check
        update()
    
        return () => {
          window.removeEventListener('resize', update)
          if (mql?.removeEventListener) {
            mql.removeEventListener('change', update)
          }
        }
      }, [])
    
      return info
    }
    
  • hooks/useDocuments.tsGitHub
  • hooks/useExperiment.tsGitHub
  • hooks/useHosts.tsGitHub
  • hooks/useLaunchChoice.tsGitHub
  • hooks/useMeetingMessages.tsGitHub
  • hooks/useOpenTasks.tsGitHub
  • hooks/useOrgs.tsGitHub
  • hooks/usePeople.tsGitHub
  • hooks/usePrefersReducedMotion.tsGitHub
  • hooks/useProjectBoard.tsGitHub
  • hooks/useProjects.tsGitHub
  • hooks/useReportsTo.tsGitHub
  • hooks/useSecretRequests.tsGitHub
  • hooks/useSessionActivity.tsGitHub
  • hooks/useSpeechLevel.tsGitHub
  • hooks/useSqlWriteRequests.tsGitHub
  • hooks/useTTS.tsGitHub
  • hooks/useTaskSummary.tsGitHub
  • hooks/useTasks.tsGitHub
  • hooks/useTeam.tsGitHub
  • hooks/useTerminal.tsGitHub
  • hooks/useToolReadiness.tsGitHub
  • hooks/useTranscriptExport.tsGitHub
  • hooks/useWebSocket.tsGitHub
  • hooks/useWorldEvents.tsGitHub
  • hooks/useWorldHistory.tsGitHub
  • hooks/useWorldTeams.tsGitHub

All 33 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

@23blocks-os
Ships withai-maestro

I was running 35 AI agents across multiple terminals and became the human mailman between them. So I built AI Maestro. The OS for AI-first organizations — orchestrate any AI agent with persistent memory, agent-to-agent messaging, and multi-machine support.

Stars on GitHub

Momentum

stars in total
841
Star history of 23blocks-OS/ai-maestro on GitHub, a chart by star-history.com

More plugins that ship hooks.

n8n-mcp-skills

n8n-mcp-skills

Expert Claude Code skills for building flawless n8n workflows using the n8n-mcp MCP server.

15 skills3 hooks6.6k
@czlonkowski@czlonkowski
Install Plugin
claude-scholar

claude-scholar

A research assistant plugin for Claude Code that supports literature review, paper writing, citation verification, Zotero and Obsidian integration, and code review and debugging for academic and software work.

45 skills+35.9k
@galaxy-dawn@galaxy-dawn
Install Plugin
octo

octo

Runs multi-AI workflows in Claude Code that split research, requirements scoping, implementation and review across several external AI providers, such as Codex, Gemini-style CLIs, Copilot, Ollama and OpenRouter, to cover each model's blind spots.

70 skills+34.3k
@nyldn@nyldn
Install Plugin
godotmaker

godotmaker

An autonomous pipeline that turns a text description into a playable Godot game, generating design docs, game assets such as backgrounds, characters, props, cards and effects, and running visual checks and playtests.

41 skills+2559
@randallliuxin@randallliuxin
Install Plugin
sutando

sutando

A personal AI assistant with voice, screen and meeting support that learns your patterns and can update its own code. It runs across several Macs and can talk with other people's assistants.

79 skills18 hooks401
@sonichi@sonichi
Install Plugin
nextboard-hardware-solution

nextboard-hardware-sol…

Helps hardware teams turn product requirements into a reviewable, buildable PCB design plan. Covers embedded architecture, MCU or SoC selection, power tree, interfaces, sensors, connectivity, and BOM risk.

1 skill+2395
@leokemp223@leokemp223
Install Plugin
claude-prompts

claude-prompts

MCP server for reusable prompt templates, multi-step workflow chains, and quality gates. Compose agentic workflows with an operator syntax; export as native skills to Claude Code, Cursor, OpenCode, and Gemini CLI.

5 hooks1 MCP191
@minipuft@minipuft
Install Plugin
crypto-trading-desk

crypto-trading-desk

A Claude Code plugin for crypto trading with skills to run multi-agent market analysis, track a portfolio and open trades, close trades with post-mortem review, and monitor stop-loss and take-profit levels on a schedule.

8 skills+2107
@hugoguerrap@hugoguerrap
Install Plugin

Questions about the hooks in ai-maestro.

Which coding agents can use the hooks in ai-maestro?
The hooks in ai-maestro install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.