n8n-mcp-skills
Expert Claude Code skills for building flawless n8n workflows using the n8n-mcp MCP server.
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
'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.'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,'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
}
}
'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'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 }
}
'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
}
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.
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.
Expert Claude Code skills for building flawless n8n workflows using the n8n-mcp MCP server.
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.
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.
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.
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.
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.
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.
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.