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.
Where it lives
- 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>() 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, state.currentMessage - 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>() // 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 } /** * Fetch agents from a specific host */ async function fetchHostAgents(host: Host): Promise<HostFetchResult> { 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`, { signal: controller.signal }) clearTimeout(timeoutId) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const data: AgentsApiResponse = await response.json() // 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, } } } } 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, 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 (result.success && result.response) { allAgents.push(...result.response.agents) if (result.fromCache) { cachedCount += result.response.agents.length } } else if (result.error) { hostErrors[result.hostId] = result.error } } // Filter out system agents (prefixed with _aim-) from the public list const publicAgents = allAgents.filter(a => { const name = a.name || a.alias || '' return !name.startsWith('_aim-') }) // OPTIMIZED: Use toSorted() for immutability instead of sort() which mutates // Sort: online first, then alphabetically by alias const sortedAgents = publicAgents.toSorted((a, b) => { // Online first if (a.session?.status === 'online' && b.session?.status !== 'online') return -1 if (a.session?.status !== 'online' && b.session?.status === 'online') return 1 // Then alphabetically by name (case-insensitive) const nameA = (a.name || a.alias || '').toLowerCase() const nameB = (b.name || b.alias || '').toLowerCase() return nameA.localeCompare(nameB) }) // OPTIMIZED: Calculate stats in a single loop instead of multiple filter() calls // Reduces from 4 array iterations (3 filter + 1 length) to 1 iteration let online = 0 let offline = 0 let orphans = 0 for (const agent of sortedAgents) { if (agent.session?.status === 'online') online++ if (agent.session?.status === 'offline') offline++ if (agent.isOrphan) orphans++ } const stats: AggregatedStats = { total: sortedAgents.length, online, offline, orphans, newlyRegistered: results.reduce((sum, r) => sum + (r.response?.stats.newlyRegistered || 0), 0), cached: cachedCount } return { agents: sortedAgents, stats, hostErrors } } /** * Hook to manage agents across multiple hosts * * Fetches agents from all confi - hooks/useCompanionWebSocket.tsGitHub
Read the script
'use client' import { useEffect, useRef, useCallback } 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 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) 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 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 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 && retryCount < maxRetries) { const delay = retryDelays[retryCount] || retryDelays[retryDelays.length - 1] retryCount++ setTimeout(connect, delay) } } ws.onerror = () => { // onclose will handle reconnection } } connect() return () => { mounted = false if (ws) { ws.close() ws = null } wsRef.current = null } }, [agentId]) return { send } } - 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
Read the script
'use client' import { useState, useEffect, useCallback, useRef } from 'react' import type { TeamDocument } from '@/types/document' interface UseDocumentsResult { documents: TeamDocument[] loading: boolean error: string | null createDocument: (data: { title: string; content: string; pinned?: boolean; tags?: string[] }) => Promise<void> updateDocument: (docId: string, updates: { title?: string; content?: string; pinned?: boolean; tags?: string[] }) => Promise<void> deleteDocument: (docId: string) => Promise<void> refreshDocuments: () => Promise<void> } export function useDocuments(teamId: string | null): UseDocumentsResult { const [documents, setDocuments] = useState<TeamDocument[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null) const intervalRef = useRef<NodeJS.Timeout | null>(null) const fetchDocuments = useCallback(async () => { if (!teamId) return try { const res = await fetch(`/api/teams/${teamId}/documents`) if (!res.ok) throw new Error('Failed to fetch documents') const data = await res.json() setDocuments(data.documents || []) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch documents') } }, [teamId]) // Initial fetch useEffect(() => { if (!teamId) { setDocuments([]) return } setLoading(true) fetchDocuments().finally(() => setLoading(false)) }, [teamId, fetchDocuments]) // Poll every 5s for multi-tab sync useEffect(() => { if (!teamId) return intervalRef.current = setInterval(fetchDocuments, 5000) return () => { if (intervalRef.current) clearInterval(intervalRef.current) } }, [teamId, fetchDocuments]) const createDocument = useCallback(async (data: { title: string; content: string; pinned?: boolean; tags?: string[] }) => { if (!teamId) return const res = await fetch(`/api/teams/${teamId}/documents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!res.ok) throw new Error('Failed to create document') await fetchDocuments() }, [teamId, fetchDocuments]) const updateDocument = useCallback(async (docId: string, updates: { title?: string; content?: string; pinned?: boolean; tags?: string[] }) => { if (!teamId) return // Optimistic update setDocuments(prev => prev.map(d => d.id === docId ? { ...d, ...updates, updatedAt: new Date().toISOString() } : d)) const res = await fetch(`/api/teams/${teamId}/documents/${docId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }) if (!res.ok) { await fetchDocuments() // Revert optimistic update throw new Error('Failed to update document') } await fetchDocuments() }, [teamId, fetchDocuments]) const deleteDocument = useCallback(async (docId: string) => { if (!teamId) return // Optimistic update setDocuments(prev => prev.filter(d => d.id !== docId)) const res = await fetch(`/api/teams/${teamId}/documents/${docId}`, { method: 'DELETE' }) if (!res.ok) { await fetchDocuments() // Revert throw new Error('Failed to delete document') } }, [teamId, fetchDocuments]) return { documents, loading, error, createDocument, updateDocument, deleteDocument, refreshDocuments: fetchDocuments, } } - hooks/useHosts.tsGitHub
- hooks/useMeetingMessages.tsGitHub
- hooks/useSessionActivity.tsGitHub
- hooks/useTTS.tsGitHub
- hooks/useTasks.tsGitHub
- hooks/useTeam.tsGitHub
- hooks/useTerminal.tsGitHub
- hooks/useTranscriptExport.tsGitHub
- hooks/useWebSocket.tsGitHub
All 15 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.
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.
Get the whole plugin
Stats
744
Stars
95
Forks
Active
Maintenance
TypeScript
Language
MIT
License
2d ago
Last commit
10mo ago
Created
Repo: 23blocks-OS/ai-maestro

