diff --git a/src/hooks/useSwarmPermissionPoller.ts b/src/hooks/useSwarmPermissionPoller.ts index 0223cef..0a09106 100644 --- a/src/hooks/useSwarmPermissionPoller.ts +++ b/src/hooks/useSwarmPermissionPoller.ts @@ -1,31 +1,16 @@ /** - * Swarm Permission Poller Hook + * Swarm permission callback registry helpers. * - * This hook polls for permission responses from the team leader when running - * as a worker agent in a swarm. When a response is received, it calls the - * appropriate callback (onAllow/onReject) to continue execution. - * - * This hook should be used in conjunction with the worker-side integration - * in useCanUseTool.ts, which creates pending requests that this hook monitors. + * Permission requests/responses now flow entirely through teammate mailboxes. + * Workers register callbacks here, and the inbox poller dispatches mailbox + * responses back into those callbacks. */ -import { useCallback, useEffect, useRef } from 'react' -import { useInterval } from 'usehooks-ts' import { logForDebugging } from '../utils/debug.js' -import { errorMessage } from '../utils/errors.js' import { type PermissionUpdate, permissionUpdateSchema, } from '../utils/permissions/PermissionUpdateSchema.js' -import { - isSwarmWorker, - type PermissionResponse, - pollForResponse, - removeWorkerResponse, -} from '../utils/swarm/permissionSync.js' -import { getAgentName, getTeamName } from '../utils/teammate.js' - -const POLL_INTERVAL_MS = 500 /** * Validate permissionUpdates from external sources (mailbox IPC, disk polling). @@ -226,105 +211,9 @@ export function processSandboxPermissionResponse(params: { } /** - * Process a permission response by invoking the registered callback - */ -function processResponse(response: PermissionResponse): boolean { - const callback = pendingCallbacks.get(response.requestId) - - if (!callback) { - logForDebugging( - `[SwarmPermissionPoller] No callback registered for request ${response.requestId}`, - ) - return false - } - - logForDebugging( - `[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`, - ) - - // Remove from registry before invoking callback - pendingCallbacks.delete(response.requestId) - - if (response.decision === 'approved') { - const permissionUpdates = parsePermissionUpdates(response.permissionUpdates) - const updatedInput = response.updatedInput - callback.onAllow(updatedInput, permissionUpdates) - } else { - callback.onReject(response.feedback) - } - - return true -} - -/** - * Hook that polls for permission responses when running as a swarm worker. - * - * This hook: - * 1. Only activates when isSwarmWorker() returns true - * 2. Polls every 500ms for responses - * 3. When a response is found, invokes the registered callback - * 4. Cleans up the response file after processing + * Legacy no-op hook kept for compatibility with older imports. + * Mailbox responses are handled by useInboxPoller instead of disk polling. */ export function useSwarmPermissionPoller(): void { - const isProcessingRef = useRef(false) - - const poll = useCallback(async () => { - // Don't poll if not a swarm worker - if (!isSwarmWorker()) { - return - } - - // Prevent concurrent polling - if (isProcessingRef.current) { - return - } - - // Don't poll if no callbacks are registered - if (pendingCallbacks.size === 0) { - return - } - - isProcessingRef.current = true - - try { - const agentName = getAgentName() - const teamName = getTeamName() - - if (!agentName || !teamName) { - return - } - - // Check each pending request for a response - for (const [requestId, _callback] of pendingCallbacks) { - const response = await pollForResponse(requestId, agentName, teamName) - - if (response) { - // Process the response - const processed = processResponse(response) - - if (processed) { - // Clean up the response from the worker's inbox - await removeWorkerResponse(requestId, agentName, teamName) - } - } - } - } catch (error) { - logForDebugging( - `[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`, - ) - } finally { - isProcessingRef.current = false - } - }, []) - - // Only poll if we're a swarm worker - const shouldPoll = isSwarmWorker() - useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null) - - // Initial poll on mount - useEffect(() => { - if (isSwarmWorker()) { - void poll() - } - }, [poll]) + // Intentionally empty. } diff --git a/src/utils/errorLogSink.ts b/src/utils/errorLogSink.ts index 751c4e8..0bbdd9b 100644 --- a/src/utils/errorLogSink.ts +++ b/src/utils/errorLogSink.ts @@ -116,7 +116,6 @@ function appendToLog(path: string, message: object): void { const messageWithTimestamp = { timestamp: new Date().toISOString(), ...message, - cwd: getFsImplementation().cwd(), userType: process.env.USER_TYPE, sessionId: getSessionId(), version: MACRO.VERSION, @@ -125,25 +124,12 @@ function appendToLog(path: string, message: object): void { getLogWriter(path).write(messageWithTimestamp) } -function extractServerMessage(data: unknown): string | undefined { - if (typeof data === 'string') { - return data +function summarizeUrlForLogs(url: string): string | undefined { + try { + return new URL(url).host || undefined + } catch { + return undefined } - if (data && typeof data === 'object') { - const obj = data as Record - if (typeof obj.message === 'string') { - return obj.message - } - if ( - typeof obj.error === 'object' && - obj.error && - 'message' in obj.error && - typeof (obj.error as Record).message === 'string' - ) { - return (obj.error as Record).message as string - } - } - return undefined } /** @@ -155,15 +141,15 @@ function logErrorImpl(error: Error): void { // Enrich axios errors with request URL, status, and server message for debugging let context = '' if (axios.isAxiosError(error) && error.config?.url) { - const parts = [`url=${error.config.url}`] + const parts: string[] = [] + const host = summarizeUrlForLogs(error.config.url) + if (host) { + parts.push(`host=${host}`) + } if (error.response?.status !== undefined) { parts.push(`status=${error.response.status}`) } - const serverMessage = extractServerMessage(error.response?.data) - if (serverMessage) { - parts.push(`body=${serverMessage}`) - } - context = `[${parts.join(',')}] ` + context = parts.length > 0 ? `[${parts.join(',')}] ` : '' } logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' }) @@ -188,7 +174,6 @@ function logMCPErrorImpl(serverName: string, error: unknown): void { error: errorStr, timestamp: new Date().toISOString(), sessionId: getSessionId(), - cwd: getFsImplementation().cwd(), } getLogWriter(logFile).write(errorInfo) @@ -206,7 +191,6 @@ function logMCPDebugImpl(serverName: string, message: string): void { debug: message, timestamp: new Date().toISOString(), sessionId: getSessionId(), - cwd: getFsImplementation().cwd(), } getLogWriter(logFile).write(debugInfo) diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 1eacb29..10d39e5 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -301,7 +301,7 @@ export const setupGracefulShutdown = memoize(() => { process.on('uncaughtException', error => { logForDiagnosticsNoPII('error', 'uncaught_exception', { error_name: error.name, - error_message: error.message.slice(0, 2000), + has_message: error.message.length > 0, }) logEvent('tengu_uncaught_exception', { error_name: @@ -321,10 +321,10 @@ export const setupGracefulShutdown = memoize(() => { reason instanceof Error ? { error_name: reason.name, - error_message: reason.message.slice(0, 2000), - error_stack: reason.stack?.slice(0, 4000), + has_message: reason.message.length > 0, + has_stack: Boolean(reason.stack), } - : { error_message: String(reason).slice(0, 2000) } + : { reason_type: typeof reason } logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo) logEvent('tengu_unhandled_rejection', { error_name: diff --git a/src/utils/swarm/permissionSync.ts b/src/utils/swarm/permissionSync.ts index 001e7a2..8044e43 100644 --- a/src/utils/swarm/permissionSync.ts +++ b/src/utils/swarm/permissionSync.ts @@ -18,16 +18,10 @@ * 6. Worker polls mailbox for responses and continues execution */ -import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' -import { join } from 'path' -import { z } from 'zod/v4' import { logForDebugging } from '../debug.js' -import { getErrnoCode } from '../errors.js' -import { lazySchema } from '../lazySchema.js' -import * as lockfile from '../lockfile.js' import { logError } from '../log.js' import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' -import { jsonParse, jsonStringify } from '../slowOperations.js' +import { jsonStringify } from '../slowOperations.js' import { getAgentId, getAgentName, @@ -41,53 +35,44 @@ import { createSandboxPermissionResponseMessage, writeToMailbox, } from '../teammateMailbox.js' -import { getTeamDir, readTeamFileAsync } from './teamHelpers.js' +import { readTeamFileAsync } from './teamHelpers.js' -/** - * Full request schema for a permission request from a worker to the leader - */ -export const SwarmPermissionRequestSchema = lazySchema(() => - z.object({ - /** Unique identifier for this request */ - id: z.string(), - /** Worker's CLAUDE_CODE_AGENT_ID */ - workerId: z.string(), - /** Worker's CLAUDE_CODE_AGENT_NAME */ - workerName: z.string(), - /** Worker's CLAUDE_CODE_AGENT_COLOR */ - workerColor: z.string().optional(), - /** Team name for routing */ - teamName: z.string(), - /** Tool name requiring permission (e.g., "Bash", "Edit") */ - toolName: z.string(), - /** Original toolUseID from worker's context */ - toolUseId: z.string(), - /** Human-readable description of the tool use */ - description: z.string(), - /** Serialized tool input */ - input: z.record(z.string(), z.unknown()), - /** Suggested permission rules from the permission result */ - permissionSuggestions: z.array(z.unknown()), - /** Status of the request */ - status: z.enum(['pending', 'approved', 'rejected']), - /** Who resolved the request */ - resolvedBy: z.enum(['worker', 'leader']).optional(), - /** Timestamp when resolved */ - resolvedAt: z.number().optional(), - /** Rejection feedback message */ - feedback: z.string().optional(), - /** Modified input if changed by resolver */ - updatedInput: z.record(z.string(), z.unknown()).optional(), - /** "Always allow" rules applied during resolution */ - permissionUpdates: z.array(z.unknown()).optional(), - /** Timestamp when request was created */ - createdAt: z.number(), - }), -) - -export type SwarmPermissionRequest = z.infer< - ReturnType -> +export type SwarmPermissionRequest = { + /** Unique identifier for this request */ + id: string + /** Worker's CLAUDE_CODE_AGENT_ID */ + workerId: string + /** Worker's CLAUDE_CODE_AGENT_NAME */ + workerName: string + /** Worker's CLAUDE_CODE_AGENT_COLOR */ + workerColor?: string + /** Team name for routing */ + teamName: string + /** Tool name requiring permission (e.g., "Bash", "Edit") */ + toolName: string + /** Original toolUseID from worker's context */ + toolUseId: string + /** Human-readable description of the tool use */ + description: string + /** Serialized tool input */ + input: Record + /** Suggested permission rules from the permission result */ + permissionSuggestions: unknown[] + /** Status of the request */ + status: 'pending' | 'approved' | 'rejected' + /** Who resolved the request */ + resolvedBy?: 'worker' | 'leader' + /** Timestamp when resolved */ + resolvedAt?: number + /** Rejection feedback message */ + feedback?: string + /** Modified input if changed by resolver */ + updatedInput?: Record + /** "Always allow" rules applied during resolution */ + permissionUpdates?: unknown[] + /** Timestamp when request was created */ + createdAt: number +} /** * Resolution data returned when leader/worker resolves a request @@ -105,55 +90,6 @@ export type PermissionResolution = { permissionUpdates?: PermissionUpdate[] } -/** - * Get the base directory for a team's permission requests - * Path: ~/.claude/teams/{teamName}/permissions/ - */ -export function getPermissionDir(teamName: string): string { - return join(getTeamDir(teamName), 'permissions') -} - -/** - * Get the pending directory for a team - */ -function getPendingDir(teamName: string): string { - return join(getPermissionDir(teamName), 'pending') -} - -/** - * Get the resolved directory for a team - */ -function getResolvedDir(teamName: string): string { - return join(getPermissionDir(teamName), 'resolved') -} - -/** - * Ensure the permissions directory structure exists (async) - */ -async function ensurePermissionDirsAsync(teamName: string): Promise { - const permDir = getPermissionDir(teamName) - const pendingDir = getPendingDir(teamName) - const resolvedDir = getResolvedDir(teamName) - - for (const dir of [permDir, pendingDir, resolvedDir]) { - await mkdir(dir, { recursive: true }) - } -} - -/** - * Get the path to a pending request file - */ -function getPendingRequestPath(teamName: string, requestId: string): string { - return join(getPendingDir(teamName), `${requestId}.json`) -} - -/** - * Get the path to a resolved request file - */ -function getResolvedRequestPath(teamName: string, requestId: string): string { - return join(getResolvedDir(teamName), `${requestId}.json`) -} - /** * Generate a unique request ID */ @@ -206,375 +142,6 @@ export function createPermissionRequest(params: { } } -/** - * Write a permission request to the pending directory with file locking - * Called by worker agents when they need permission approval from the leader - * - * @returns The written request - */ -export async function writePermissionRequest( - request: SwarmPermissionRequest, -): Promise { - await ensurePermissionDirsAsync(request.teamName) - - const pendingPath = getPendingRequestPath(request.teamName, request.id) - const lockDir = getPendingDir(request.teamName) - - // Create a directory-level lock file for atomic writes - const lockFilePath = join(lockDir, '.lock') - await writeFile(lockFilePath, '', 'utf-8') - - let release: (() => Promise) | undefined - try { - release = await lockfile.lock(lockFilePath) - - // Write the request file - await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8') - - logForDebugging( - `[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`, - ) - - return request - } catch (error) { - logForDebugging( - `[PermissionSync] Failed to write permission request: ${error}`, - ) - logError(error) - throw error - } finally { - if (release) { - await release() - } - } -} - -/** - * Read all pending permission requests for a team - * Called by the team leader to see what requests need attention - */ -export async function readPendingPermissions( - teamName?: string, -): Promise { - const team = teamName || getTeamName() - if (!team) { - logForDebugging('[PermissionSync] No team name available') - return [] - } - - const pendingDir = getPendingDir(team) - - let files: string[] - try { - files = await readdir(pendingDir) - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - return [] - } - logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`) - logError(e) - return [] - } - - const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock') - - const results = await Promise.all( - jsonFiles.map(async file => { - const filePath = join(pendingDir, file) - try { - const content = await readFile(filePath, 'utf-8') - const parsed = SwarmPermissionRequestSchema().safeParse( - jsonParse(content), - ) - if (parsed.success) { - return parsed.data - } - logForDebugging( - `[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`, - ) - return null - } catch (err) { - logForDebugging( - `[PermissionSync] Failed to read request file ${file}: ${err}`, - ) - return null - } - }), - ) - - const requests = results.filter(r => r !== null) - - // Sort by creation time (oldest first) - requests.sort((a, b) => a.createdAt - b.createdAt) - - return requests -} - -/** - * Read a resolved permission request by ID - * Called by workers to check if their request has been resolved - * - * @returns The resolved request, or null if not yet resolved - */ -export async function readResolvedPermission( - requestId: string, - teamName?: string, -): Promise { - const team = teamName || getTeamName() - if (!team) { - return null - } - - const resolvedPath = getResolvedRequestPath(team, requestId) - - try { - const content = await readFile(resolvedPath, 'utf-8') - const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) - if (parsed.success) { - return parsed.data - } - logForDebugging( - `[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`, - ) - return null - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - return null - } - logForDebugging( - `[PermissionSync] Failed to read resolved request ${requestId}: ${e}`, - ) - logError(e) - return null - } -} - -/** - * Resolve a permission request - * Called by the team leader (or worker in self-resolution cases) - * - * Writes the resolution to resolved/, removes from pending/ - */ -export async function resolvePermission( - requestId: string, - resolution: PermissionResolution, - teamName?: string, -): Promise { - const team = teamName || getTeamName() - if (!team) { - logForDebugging('[PermissionSync] No team name available') - return false - } - - await ensurePermissionDirsAsync(team) - - const pendingPath = getPendingRequestPath(team, requestId) - const resolvedPath = getResolvedRequestPath(team, requestId) - const lockFilePath = join(getPendingDir(team), '.lock') - - await writeFile(lockFilePath, '', 'utf-8') - - let release: (() => Promise) | undefined - try { - release = await lockfile.lock(lockFilePath) - - // Read the pending request - let content: string - try { - content = await readFile(pendingPath, 'utf-8') - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - logForDebugging( - `[PermissionSync] Pending request not found: ${requestId}`, - ) - return false - } - throw e - } - - const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) - if (!parsed.success) { - logForDebugging( - `[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`, - ) - return false - } - - const request = parsed.data - - // Update the request with resolution data - const resolvedRequest: SwarmPermissionRequest = { - ...request, - status: resolution.decision === 'approved' ? 'approved' : 'rejected', - resolvedBy: resolution.resolvedBy, - resolvedAt: Date.now(), - feedback: resolution.feedback, - updatedInput: resolution.updatedInput, - permissionUpdates: resolution.permissionUpdates, - } - - // Write to resolved directory - await writeFile( - resolvedPath, - jsonStringify(resolvedRequest, null, 2), - 'utf-8', - ) - - // Remove from pending directory - await unlink(pendingPath) - - logForDebugging( - `[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`, - ) - - return true - } catch (error) { - logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`) - logError(error) - return false - } finally { - if (release) { - await release() - } - } -} - -/** - * Clean up old resolved permission files - * Called periodically to prevent file accumulation - * - * @param teamName - Team name - * @param maxAgeMs - Maximum age in milliseconds (default: 1 hour) - */ -export async function cleanupOldResolutions( - teamName?: string, - maxAgeMs = 3600000, -): Promise { - const team = teamName || getTeamName() - if (!team) { - return 0 - } - - const resolvedDir = getResolvedDir(team) - - let files: string[] - try { - files = await readdir(resolvedDir) - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - return 0 - } - logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`) - logError(e) - return 0 - } - - const now = Date.now() - const jsonFiles = files.filter(f => f.endsWith('.json')) - - const cleanupResults = await Promise.all( - jsonFiles.map(async file => { - const filePath = join(resolvedDir, file) - try { - const content = await readFile(filePath, 'utf-8') - const request = jsonParse(content) as SwarmPermissionRequest - - // Check if the resolution is old enough to clean up - // Use >= to handle edge case where maxAgeMs is 0 (clean up everything) - const resolvedAt = request.resolvedAt || request.createdAt - if (now - resolvedAt >= maxAgeMs) { - await unlink(filePath) - logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`) - return 1 - } - return 0 - } catch { - // If we can't parse it, clean it up anyway - try { - await unlink(filePath) - return 1 - } catch { - // Ignore deletion errors - return 0 - } - } - }), - ) - - const cleanedCount = cleanupResults.reduce((sum, n) => sum + n, 0) - - if (cleanedCount > 0) { - logForDebugging( - `[PermissionSync] Cleaned up ${cleanedCount} old resolutions`, - ) - } - - return cleanedCount -} - -/** - * Legacy response type for worker polling - * Used for backward compatibility with worker integration code - */ -export type PermissionResponse = { - /** ID of the request this responds to */ - requestId: string - /** Decision: approved or denied */ - decision: 'approved' | 'denied' - /** Timestamp when response was created */ - timestamp: string - /** Optional feedback message if denied */ - feedback?: string - /** Optional updated input if the resolver modified it */ - updatedInput?: Record - /** Permission updates to apply (e.g., "always allow" rules) */ - permissionUpdates?: unknown[] -} - -/** - * Poll for a permission response (worker-side convenience function) - * Converts the resolved request into a simpler response format - * - * @returns The permission response, or null if not yet resolved - */ -export async function pollForResponse( - requestId: string, - _agentName?: string, - teamName?: string, -): Promise { - const resolved = await readResolvedPermission(requestId, teamName) - if (!resolved) { - return null - } - - return { - requestId: resolved.id, - decision: resolved.status === 'approved' ? 'approved' : 'denied', - timestamp: resolved.resolvedAt - ? new Date(resolved.resolvedAt).toISOString() - : new Date(resolved.createdAt).toISOString(), - feedback: resolved.feedback, - updatedInput: resolved.updatedInput, - permissionUpdates: resolved.permissionUpdates, - } -} - -/** - * Remove a worker's response after processing - * This is an alias for deleteResolvedPermission for backward compatibility - */ -export async function removeWorkerResponse( - requestId: string, - _agentName?: string, - teamName?: string, -): Promise { - await deleteResolvedPermission(requestId, teamName) -} - /** * Check if the current agent is a team leader */ @@ -600,46 +167,6 @@ export function isSwarmWorker(): boolean { return !!teamName && !!agentId && !isTeamLeader() } -/** - * Delete a resolved permission file - * Called after a worker has processed the resolution - */ -export async function deleteResolvedPermission( - requestId: string, - teamName?: string, -): Promise { - const team = teamName || getTeamName() - if (!team) { - return false - } - - const resolvedPath = getResolvedRequestPath(team, requestId) - - try { - await unlink(resolvedPath) - logForDebugging( - `[PermissionSync] Deleted resolved permission: ${requestId}`, - ) - return true - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - return false - } - logForDebugging( - `[PermissionSync] Failed to delete resolved permission: ${e}`, - ) - logError(e) - return false - } -} - -/** - * Submit a permission request (alias for writePermissionRequest) - * Provided for backward compatibility with worker integration code - */ -export const submitPermissionRequest = writePermissionRequest - // ============================================================================ // Mailbox-Based Permission System // ============================================================================ diff --git a/src/utils/swarm/teamHelpers.ts b/src/utils/swarm/teamHelpers.ts index d7d4514..d0045c6 100644 --- a/src/utils/swarm/teamHelpers.ts +++ b/src/utils/swarm/teamHelpers.ts @@ -66,7 +66,6 @@ export type TeamFile = { description?: string createdAt: number leadAgentId: string - leadSessionId?: string // Legacy field; stripped from persisted configs hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking members: Array<{ @@ -81,8 +80,6 @@ export type TeamFile = { tmuxPaneId: string cwd: string worktreePath?: string - sessionId?: string // Legacy field; stripped from persisted configs - subscriptions?: string[] // Legacy field; stripped from persisted configs backendType?: BackendType isActive?: boolean // false when idle, undefined/true when active mode?: PermissionMode // Current permission mode for this teammate