diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c59ed5b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md - AI Coding Agent Context + +This file provides context for AI coding agents working on the Rowboat monorepo. + +## Quick Reference Commands + +```bash +# Electron App (apps/x) +cd apps/x && pnpm install # Install dependencies +cd apps/x && npm run deps # Build workspace packages (shared → core → preload) +cd apps/x && npm run dev # Development mode (builds deps, runs app) +cd apps/x && npm run lint # Lint check +cd apps/x/apps/main && npm run package # Production build (.app) +cd apps/x/apps/main && npm run make # Create DMG distributable +``` + +## Monorepo Structure + +``` +rowboat/ +├── apps/ +│ ├── x/ # Electron desktop app (focus of this doc) +│ ├── rowboat/ # Next.js web dashboard +│ ├── rowboatx/ # Next.js frontend +│ ├── cli/ # CLI tool +│ ├── python-sdk/ # Python SDK +│ └── docs/ # Documentation site +├── CLAUDE.md # This file +└── README.md # User-facing readme +``` + +## Electron App Architecture (`apps/x`) + +The Electron app is a **nested pnpm workspace** with its own package management. + +``` +apps/x/ +├── package.json # Workspace root, dev scripts +├── pnpm-workspace.yaml # Defines workspace packages +├── pnpm-lock.yaml # Lockfile +├── apps/ +│ ├── main/ # Electron main process +│ │ ├── src/ # Main process source +│ │ ├── forge.config.cjs # Electron Forge config +│ │ └── bundle.mjs # esbuild bundler +│ ├── renderer/ # React UI (Vite) +│ │ ├── src/ # React components +│ │ └── vite.config.ts +│ └── preload/ # Electron preload scripts +│ └── src/ +└── packages/ + ├── shared/ # @x/shared - Types, utilities, validators + └── core/ # @x/core - Business logic, AI, OAuth, MCP +``` + +### Build Order (Dependencies) + +``` +shared (no deps) + ↓ +core (depends on shared) + ↓ +preload (depends on shared) + ↓ +renderer (depends on shared) +main (depends on shared, core) +``` + +**The `npm run deps` command builds:** shared → core → preload + +### Key Entry Points + +| Component | Entry | Output | +|-----------|-------|--------| +| main | `apps/main/src/main.ts` | `.package/dist/main.cjs` | +| renderer | `apps/renderer/src/main.tsx` | `apps/renderer/dist/` | +| preload | `apps/preload/src/preload.ts` | `apps/preload/dist/preload.js` | + +## Build System + +- **Package manager:** pnpm (required for `workspace:*` protocol) +- **Main bundler:** esbuild (bundles to single CommonJS file) +- **Renderer bundler:** Vite +- **Packaging:** Electron Forge +- **TypeScript:** ES2022 target + +### Why esbuild bundling? + +pnpm uses symlinks for workspace packages. Electron Forge's dependency walker can't follow these symlinks. esbuild bundles everything into a single file, eliminating the need for node_modules in the packaged app. + +## Key Files Reference + +| Purpose | File | +|---------|------| +| Electron main entry | `apps/x/apps/main/src/main.ts` | +| React app entry | `apps/x/apps/renderer/src/main.tsx` | +| Forge config (packaging) | `apps/x/apps/main/forge.config.cjs` | +| Main process bundler | `apps/x/apps/main/bundle.mjs` | +| Vite config | `apps/x/apps/renderer/vite.config.ts` | +| Shared types | `apps/x/packages/shared/src/` | +| Core business logic | `apps/x/packages/core/src/` | +| Workspace config | `apps/x/pnpm-workspace.yaml` | +| Root scripts | `apps/x/package.json` | + +## Common Tasks + +### Add a new shared type +1. Edit `apps/x/packages/shared/src/` +2. Run `cd apps/x && npm run deps` to rebuild + +### Modify main process +1. Edit `apps/x/apps/main/src/` +2. Restart dev server (main doesn't hot-reload) + +### Modify renderer (React UI) +1. Edit `apps/x/apps/renderer/src/` +2. Changes hot-reload automatically in dev mode + +### Add a new dependency to main +1. `cd apps/x/apps/main && pnpm add ` +2. Import in source - esbuild will bundle it + +### Verify compilation +```bash +cd apps/x && npm run deps && npm run lint +``` + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Desktop | Electron 39.x | +| UI | React 19, Vite 7 | +| Styling | TailwindCSS, Radix UI | +| State | React hooks | +| AI | Vercel AI SDK, Anthropic/OpenAI/Google providers | +| IPC | Electron contextBridge | +| Build | TypeScript 5.9, esbuild, Electron Forge | + +## Environment Variables (for packaging) + +For production builds with code signing: +- `APPLE_ID` - Apple Developer ID +- `APPLE_PASSWORD` - App-specific password +- `APPLE_TEAM_ID` - Team ID + +Not required for local development. diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 05e52225..53b2f913 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -296,7 +296,7 @@ export function setupIpcHandlers() { return { success: true }; }, 'runs:stop': async (_event, args) => { - await runsCore.stop(args.runId); + await runsCore.stop(args.runId, args.force); return { success: true }; }, 'runs:fetch': async (_event, args) => { @@ -306,7 +306,7 @@ export function setupIpcHandlers() { return runsCore.listRuns(args.cursor); }, 'oauth:connect': async (_event, args) => { - return await connectProvider(args.provider, args.clientId); + return await connectProvider(args.provider); }, 'oauth:disconnect': async (_event, args) => { return await disconnectProvider(args.provider); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 7ae6ed46..6ddab7bc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; +import { initConfigs } from "@x/core/dist/config/initConfigs.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -96,7 +97,7 @@ function createWindow() { } } -app.whenReady().then(() => { +app.whenReady().then(async () => { // Register custom protocol before creating window (for production builds) if (app.isPackaged) { registerAppProtocol(); @@ -113,6 +114,9 @@ app.whenReady().then(() => { }); } + // Initialize all config files before UI can access them + await initConfigs(); + setupIpcHandlers(); createWindow(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index a00d6e4f..5b55e8b7 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,14 +1,9 @@ import { shell } from 'electron'; +import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; -import { - clearProviderClientIdOverride, - getProviderClientIdOverride, - hasProviderClientIdOverride, - setProviderClientIdOverride, -} from '@x/core/dist/auth/provider-client-id.js'; import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; @@ -20,12 +15,48 @@ import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; // Store active OAuth flows (state -> { codeVerifier, provider, config }) -const activeFlows = new Map(); +// Module-level state for tracking the active OAuth flow +interface ActiveOAuthFlow { + provider: string; + state: string; + server: Server; + cleanupTimeout: NodeJS.Timeout; +} + +let activeFlow: ActiveOAuthFlow | null = null; + +/** + * Cancel any active OAuth flow, cleaning up resources + */ +function cancelActiveFlow(reason: string = 'cancelled'): void { + if (!activeFlow) { + return; + } + + console.log(`[OAuth] Cancelling active flow for ${activeFlow.provider}: ${reason}`); + + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlows.delete(activeFlow.state); + + // Only emit event for user-visible cancellations + if (reason !== 'new_flow_started') { + emitOAuthEvent({ + provider: activeFlow.provider, + success: false, + error: `OAuth flow ${reason}` + }); + } + + activeFlow = null; +} + /** * Get OAuth repository from DI container */ @@ -45,25 +76,14 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { */ async function getProviderConfiguration(provider: string): Promise { const config = getProviderConfig(provider); - const resolveClientId = (): string => { - const override = getProviderClientIdOverride(provider); - if (override) { - return override; - } - if (config.client.mode === 'static' && config.client.clientId) { - return config.client.clientId; - } - throw new Error(`${provider} client ID not configured. Please provide a client ID.`); - }; if (config.discovery.mode === 'issuer') { if (config.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); - const clientId = resolveClientId(); return await oauthClient.discoverConfiguration( config.discovery.issuer, - clientId + config.client.clientId ); } else { // DCR mode - check for existing registration or register new @@ -100,11 +120,10 @@ async function getProviderConfiguration(provider: string): Promise { +export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); + + // Cancel any existing flow before starting a new one + cancelActiveFlow('new_flow_started'); + const oauthRepo = getOAuthRepo(); const providerConfig = getProviderConfig(provider); - if (provider === 'google') { - const trimmedClientId = clientId?.trim(); - if (!trimmedClientId) { - return { success: false, error: 'Google client ID is required to connect.' }; - } - setProviderClientIdOverride(provider, trimmedClientId); - } - // Get or create OAuth configuration const config = await getProviderConfiguration(provider); @@ -148,9 +163,6 @@ export async function connectProvider(provider: string, clientId?: string): Prom state, }); - // Declare timeout variable (will be set after server is created) - let cleanupTimeout: NodeJS.Timeout; - // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { // Validate state @@ -166,7 +178,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom try { // Build callback URL for token exchange const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); - + // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthClient.exchangeCodeForTokens( @@ -198,21 +210,30 @@ export async function connectProvider(provider: string, clientId?: string): Prom } finally { // Clean up activeFlows.delete(state); - server.close(); - clearTimeout(cleanupTimeout); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; + } } }); - // Set timeout to clean up abandoned flows (5 minutes) + // Set timeout to clean up abandoned flows (2 minutes) // This prevents memory leaks if user never completes the OAuth flow - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(state)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - activeFlows.delete(state); - server.close(); - emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' }); + cancelActiveFlow('timed_out'); } - }, 5 * 60 * 1000); // 5 minutes + }, 2 * 60 * 1000); // 2 minutes + + // Store complete flow state for cleanup + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; // Open in system browser (shares cookies/sessions with user's regular browser) shell.openExternal(authUrl.toString()); @@ -235,9 +256,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.clearTokens(provider); - if (provider === 'google') { - clearProviderClientIdOverride(provider); - } return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); @@ -251,9 +269,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function isConnected(provider: string): Promise<{ isConnected: boolean }> { try { const oauthRepo = getOAuthRepo(); - if (provider === 'google' && !hasProviderClientIdOverride(provider)) { - return { isConnected: false }; - } const connected = await oauthRepo.isConnected(provider); return { isConnected: connected }; } catch (error) { @@ -310,10 +325,7 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }> try { const oauthRepo = getOAuthRepo(); const providers = await oauthRepo.getConnectedProviders(); - const filteredProviders = providers.filter((provider) => - provider === 'google' ? hasProviderClientIdOverride(provider) : true - ); - return { providers: filteredProviders }; + return { providers }; } catch (error) { console.error('Get connected providers failed:', error); return { providers: [] }; diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts index 1def7bdd..7d7d53e4 100644 --- a/apps/x/apps/preload/src/preload.ts +++ b/apps/x/apps/preload/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { ipc as ipcShared } from '@x/shared'; type InvokeChannels = ipcShared.InvokeChannels; @@ -51,4 +51,8 @@ const ipc = { }, }; -contextBridge.exposeInMainWorld('ipc', ipc); \ No newline at end of file +contextBridge.exposeInMainWorld('ipc', ipc); + +contextBridge.exposeInMainWorld('electronUtils', { + getPathForFile: (file: File) => webUtils.getPathForFile(file), +}); \ No newline at end of file diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d1a6f1fe..f100b9bb 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -279,7 +279,9 @@ const collectFilePaths = (nodes: TreeNode[]): string[] => // Inner component that uses the controller to access mentions interface ChatInputInnerProps { onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onStop?: () => void isProcessing: boolean + isStopping?: boolean presetMessage?: string onPresetMessageConsumed?: () => void runId?: string | null @@ -287,7 +289,9 @@ interface ChatInputInnerProps { function ChatInputInner({ onSubmit, + onStop, isProcessing, + isStopping, presetMessage, onPresetMessageConsumed, runId, @@ -318,6 +322,37 @@ function ChatInputInner({ } }, [handleSubmit]) + useEffect(() => { + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault() + } + } + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault() + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + const paths = Array.from(e.dataTransfer.files) + .map((f) => window.electronUtils?.getPathForFile(f)) + .filter(Boolean) + if (paths.length > 0) { + const currentText = controller.textInput.value + const pathText = paths.join(' ') + controller.textInput.setInput( + currentText ? `${currentText} ${pathText}` : pathText + ) + } + } + } + document.addEventListener("dragover", onDragOver) + document.addEventListener("drop", onDrop) + return () => { + document.removeEventListener("dragover", onDragOver) + document.removeEventListener("drop", onDrop) + } + }, [controller]) + return (
- + {isProcessing ? ( + + ) : ( + + )}
) } @@ -350,7 +405,9 @@ interface ChatInputWithMentionsProps { recentFiles: string[] visibleFiles: string[] onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onStop?: () => void isProcessing: boolean + isStopping?: boolean presetMessage?: string onPresetMessageConsumed?: () => void runId?: string | null @@ -361,7 +418,9 @@ function ChatInputWithMentions({ recentFiles, visibleFiles, onSubmit, + onStop, isProcessing, + isStopping, presetMessage, onPresetMessageConsumed, runId, @@ -370,7 +429,9 @@ function ChatInputWithMentions({ (null) const runIdRef = useRef(null) const [isProcessing, setIsProcessing] = useState(false) + const [isStopping, setIsStopping] = useState(false) + const [stopClickedAt, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') const [presetMessage, setPresetMessage] = useState(undefined) @@ -758,6 +821,8 @@ function App() { case 'run-processing-end': setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) break case 'start': @@ -936,8 +1001,32 @@ function App() { break } + case 'run-stopped': + setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) + // Clear pending requests since they've been aborted + setPendingPermissionRequests(new Map()) + setPendingAskHumanRequests(new Map()) + // Flush any streaming content as a message + setCurrentAssistantMessage(currentMsg => { + if (currentMsg) { + setConversation(prev => [...prev, { + id: `assistant-stopped-${Date.now()}`, + role: 'assistant', + content: currentMsg, + timestamp: Date.now(), + }]) + } + return '' + }) + setCurrentReasoning('') + break + case 'error': setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) console.error('Run error:', event.error) break } @@ -1009,6 +1098,21 @@ function App() { } } + const handleStop = useCallback(async () => { + if (!runId) return + const now = Date.now() + const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000 + + setStopClickedAt(now) + setIsStopping(true) + + try { + await window.ipc.invoke('runs:stop', { runId, force: isForce }) + } catch (error) { + console.error('Failed to stop run:', error) + } + }, [runId, isStopping, stopClickedAt]) + const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => { if (!runId) return @@ -1779,7 +1883,9 @@ function App() { recentFiles={recentWikiFiles} visibleFiles={visibleKnowledgeFiles} onSubmit={handlePromptSubmit} + onStop={handleStop} isProcessing={isProcessing} + isStopping={isStopping} presetMessage={presetMessage} onPresetMessageConsumed={() => setPresetMessage(undefined)} runId={runId} @@ -1801,6 +1907,8 @@ function App() { currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} isProcessing={isProcessing} + isStopping={isStopping} + onStop={handleStop} message={message} onMessageChange={setMessage} onSubmit={handlePromptSubmit} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 4cddfdf0..4c97d581 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, Expand, Plus } from 'lucide-react' +import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react' import type { ToolUIPart } from 'ai' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -115,6 +115,8 @@ interface ChatSidebarProps { currentAssistantMessage: string currentReasoning: string isProcessing: boolean + isStopping?: boolean + onStop?: () => void message: string onMessageChange: (message: string) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void @@ -139,6 +141,8 @@ export function ChatSidebar({ currentAssistantMessage, currentReasoning, isProcessing, + isStopping, + onStop, message, onMessageChange, onSubmit, @@ -595,19 +599,39 @@ export function ChatSidebar({ style={{ fieldSizing: 'content' } as React.CSSProperties} /> - + {isProcessing ? ( + + ) : ( + + )} {knowledgeFiles.length > 0 && ( ([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) - const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -269,14 +266,15 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + const result = await window.ipc.invoke('oauth:connect', { provider }) if (result.success) { // OAuth flow started - keep isConnecting state, wait for event @@ -299,27 +297,6 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) - return - } - - await startConnect(provider) - }, [startConnect]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) - setGoogleClientIdOpen(false) - startConnect('google', clientId) - }, [startConnect]) - // Disconnect from a provider const handleDisconnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -331,9 +308,6 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const result = await window.ipc.invoke('oauth:disconnect', { provider }) if (result.success) { - if (provider === 'google') { - clearGoogleClientId() - } const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) toast.success(`Disconnected from ${displayName}`) setProviderStates(prev => ({ @@ -421,12 +395,6 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return ( <> - {tooltip ? ( diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx deleted file mode 100644 index 5727d506..00000000 --- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" - -interface GoogleClientIdModalProps { - open: boolean - onOpenChange: (open: boolean) => void - onSubmit: (clientId: string) => void - isSubmitting?: boolean -} - -export function GoogleClientIdModal({ - open, - onOpenChange, - onSubmit, - isSubmitting = false, -}: GoogleClientIdModalProps) { - const [clientId, setClientId] = useState("") - - useEffect(() => { - if (!open) { - setClientId("") - } - }, [open]) - - const trimmedClientId = clientId.trim() - const isValid = trimmedClientId.length > 0 - - const handleSubmit = () => { - if (!isValid || isSubmitting) return - onSubmit(trimmedClientId) - } - - return ( - - - - Enter Google Client ID - - This app does not store the client ID. You will be prompted each session. - - -
- - setClientId(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - handleSubmit() - } - }} - autoFocus - /> -
-
- - -
-
-
- ) -} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 245915fd..074ad645 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -14,9 +14,7 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" -import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" -import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" interface ProviderState { @@ -39,7 +37,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) - const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -248,14 +245,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + const result = await window.ipc.invoke('oauth:connect', { provider }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -274,27 +272,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) - return - } - - await startConnect(provider) - }, [startConnect]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) - setGoogleClientIdOpen(false) - startConnect('google', clientId) - }, [startConnect]) - const handleNext = () => { if (currentStep < 2) { setCurrentStep((prev) => (prev + 1) as Step) @@ -597,12 +574,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return ( <> - void ): () => void; }; + electronUtils: { + getPathForFile: (file: File) => string; + }; } } diff --git a/apps/x/apps/renderer/src/hooks/useOAuth.ts b/apps/x/apps/renderer/src/hooks/useOAuth.ts index f23f5975..b2777c6c 100644 --- a/apps/x/apps/renderer/src/hooks/useOAuth.ts +++ b/apps/x/apps/renderer/src/hooks/useOAuth.ts @@ -50,10 +50,10 @@ export function useOAuth(provider: string) { return cleanup; }, [provider, checkConnection]); - const connect = useCallback(async (clientId?: string) => { + const connect = useCallback(async () => { try { setIsConnecting(true); - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }); + const result = await window.ipc.invoke('oauth:connect', { provider }); if (result.success) { // OAuth flow started - keep isConnecting state, wait for event // Event listener will handle the actual completion diff --git a/apps/x/apps/renderer/src/lib/google-client-id-store.ts b/apps/x/apps/renderer/src/lib/google-client-id-store.ts deleted file mode 100644 index 78898325..00000000 --- a/apps/x/apps/renderer/src/lib/google-client-id-store.ts +++ /dev/null @@ -1,17 +0,0 @@ -let googleClientId: string | null = null; - -export function getGoogleClientId(): string | null { - return googleClientId; -} - -export function setGoogleClientId(clientId: string): void { - const trimmed = clientId.trim(); - if (!trimmed) { - return; - } - googleClientId = trimmed; -} - -export function clearGoogleClientId(): void { - googleClientId = null; -} diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 237746ad..d30c199c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -18,6 +18,8 @@ "@google-cloud/local-auth": "^3.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@openrouter/ai-sdk-provider": "^1.2.6", + "@react-pdf/renderer": "^4.3.2", + "@types/react": "^19.2.7", "@x/shared": "workspace:*", "ai": "^5.0.102", "awilix": "^12.0.5", @@ -28,6 +30,7 @@ "node-html-markdown": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4", "openid-client": "^6.8.1", + "react": "^19.2.3", "yaml": "^2.8.2", "zod": "^4.2.1" }, diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 43a2dc01..9a6b7dcb 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -22,6 +22,7 @@ import { IBus } from "../application/lib/bus.js"; import { IMessageQueue } from "../application/lib/message-queue.js"; import { IRunsRepo } from "../runs/repo.js"; import { IRunsLock } from "../runs/lock.js"; +import { IAbortRegistry } from "../runs/abort-registry.js"; import { PrefixLogger } from "@x/shared"; import { parse } from "yaml"; import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js"; @@ -39,6 +40,7 @@ export class AgentRuntime implements IAgentRuntime { private messageQueue: IMessageQueue; private modelConfigRepo: IModelConfigRepo; private runsLock: IRunsLock; + private abortRegistry: IAbortRegistry; constructor({ runsRepo, @@ -47,6 +49,7 @@ export class AgentRuntime implements IAgentRuntime { messageQueue, modelConfigRepo, runsLock, + abortRegistry, }: { runsRepo: IRunsRepo; idGenerator: IMonotonicallyIncreasingIdGenerator; @@ -54,6 +57,7 @@ export class AgentRuntime implements IAgentRuntime { messageQueue: IMessageQueue; modelConfigRepo: IModelConfigRepo; runsLock: IRunsLock; + abortRegistry: IAbortRegistry; }) { this.runsRepo = runsRepo; this.idGenerator = idGenerator; @@ -61,6 +65,7 @@ export class AgentRuntime implements IAgentRuntime { this.messageQueue = messageQueue; this.modelConfigRepo = modelConfigRepo; this.runsLock = runsLock; + this.abortRegistry = abortRegistry; } async trigger(runId: string): Promise { @@ -68,6 +73,7 @@ export class AgentRuntime implements IAgentRuntime { console.log(`unable to acquire lock on run ${runId}`); return; } + const signal = this.abortRegistry.createForRun(runId); try { await this.bus.publish({ runId, @@ -75,6 +81,11 @@ export class AgentRuntime implements IAgentRuntime { subflow: [], }); while (true) { + // Check for abort before each iteration + if (signal.aborted) { + break; + } + let eventCount = 0; const run = await this.runsRepo.fetch(runId); if (!run) { @@ -84,18 +95,28 @@ export class AgentRuntime implements IAgentRuntime { for (const event of run.log) { state.ingest(event); } - for await (const event of streamAgent({ - state, - idGenerator: this.idGenerator, - runId, - messageQueue: this.messageQueue, - modelConfigRepo: this.modelConfigRepo, - })) { - eventCount++; - if (event.type !== "llm-stream-event") { - await this.runsRepo.appendEvents(runId, [event]); + try { + for await (const event of streamAgent({ + state, + idGenerator: this.idGenerator, + runId, + messageQueue: this.messageQueue, + modelConfigRepo: this.modelConfigRepo, + signal, + abortRegistry: this.abortRegistry, + })) { + eventCount++; + if (event.type !== "llm-stream-event") { + await this.runsRepo.appendEvents(runId, [event]); + } + await this.bus.publish(event); } - await this.bus.publish(event); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // Abort detected — exit cleanly + break; + } + throw error; } // if no events, break @@ -103,7 +124,20 @@ export class AgentRuntime implements IAgentRuntime { break; } } + + // Emit run-stopped event if aborted + if (signal.aborted) { + const stoppedEvent: z.infer = { + runId, + type: "run-stopped", + reason: "user-requested", + subflow: [], + }; + await this.runsRepo.appendEvents(runId, [stoppedEvent]); + await this.bus.publish(stoppedEvent); + } } finally { + this.abortRegistry.cleanup(runId); await this.runsLock.release(runId); await this.bus.publish({ runId, @@ -428,6 +462,39 @@ export class AgentState { return response; } + /** + * Returns tool-result messages for all pending tool calls, marking them as aborted. + * This is called when a run is stopped so the LLM knows what happened to its tool requests. + */ + getAbortedToolResults(): z.infer[] { + const results: z.infer[] = []; + for (const toolCallId of Object.keys(this.pendingToolCalls)) { + const toolCall = this.toolCallIdMap[toolCallId]; + if (toolCall) { + results.push({ + role: "tool", + content: JSON.stringify({ error: "Tool execution aborted" }), + toolCallId, + toolName: toolCall.toolName, + }); + } + } + return results; + } + + /** + * Clear all pending state (permissions, ask-human, tool calls). + * Used when a run is stopped. + */ + clearAllPending(): void { + this.pendingToolPermissionRequests = {}; + this.pendingAskHumanRequests = {}; + // Recursively clear subflows + for (const subflow of Object.values(this.subflowStates)) { + subflow.clearAllPending(); + } + } + finalResponse(): string { if (!this.lastAssistantMsg) { return ''; @@ -526,12 +593,16 @@ export async function* streamAgent({ runId, messageQueue, modelConfigRepo, + signal, + abortRegistry, }: { state: AgentState, idGenerator: IMonotonicallyIncreasingIdGenerator; runId: string; messageQueue: IMessageQueue; modelConfigRepo: IModelConfigRepo; + signal: AbortSignal; + abortRegistry: IAbortRegistry; }): AsyncGenerator, void, unknown> { const logger = new PrefixLogger(`run-${runId}-${state.agentName}`); @@ -557,6 +628,9 @@ export async function* streamAgent({ let loopCounter = 0; while (true) { + // Check abort at the top of each iteration + signal.throwIfAborted(); + loopCounter++; const loopLogger = logger.child(`iter-${loopCounter}`); loopLogger.log('starting loop iteration'); @@ -598,6 +672,11 @@ export async function* streamAgent({ } // execute approved tool + // Check abort before starting tool execution + if (signal.aborted) { + _logger.log('skipping, reason: aborted'); + break; + } _logger.log('executing tool'); yield* processEvent({ runId, @@ -616,6 +695,8 @@ export async function* streamAgent({ runId, messageQueue, modelConfigRepo, + signal, + abortRegistry, })) { yield* processEvent({ ...event, @@ -626,7 +707,7 @@ export async function* streamAgent({ result = subflowState.finalResponse(); } } else { - result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments); + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); } const resultPayload = result === undefined ? null : result; const resultMsg: z.infer = { @@ -709,6 +790,7 @@ export async function* streamAgent({ state.messages, instructionsWithDateTime, tools, + signal, )) { // Only log significant events (not text-delta to reduce noise) if (event.type !== 'text-delta') { @@ -791,6 +873,7 @@ async function* streamLlm( messages: z.infer, instructions: string, tools: ToolSet, + signal?: AbortSignal, ): AsyncGenerator, void, unknown> { const { fullStream } = streamText({ model, @@ -798,8 +881,11 @@ async function* streamLlm( system: instructions, tools, stopWhen: stepCountIs(1), + abortSignal: signal, }); for await (const event of fullStream) { + // Check abort on every chunk for responsiveness + signal?.throwIfAborted(); // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); switch (event.type) { case "reasoning-start": diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index d28c4262..57f3a446 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -26,6 +26,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs. +**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base. + **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base. **Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending. @@ -150,15 +152,9 @@ When a user asks for ANY task that might require external capabilities (web sear - NEVER ask what OS the user is on - they are on macOS. - Load the \`organize-files\` skill for guidance on file organization tasks. -**Command Approval:** -- Approved shell commands are listed in \`~/.rowboat/config/security.json\`. Read this file to see what commands are allowed. -- Only use commands from the approved list. Commands not in the list will be blocked. -- If you cannot accomplish a task with the approved commands, tell the user which command you need and ask them to add it to \`security.json\`. -- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). - ## Builtin Tools vs Shell Commands -**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries: +**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval: - \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search - \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management @@ -167,13 +163,20 @@ When a user asks for ANY task that might require external capabilities (web sear - \`loadSkill\` - Skill loading - \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. -These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`. +**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. + +**Shell commands via \`executeCommand\`:** +- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately. +- Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need. +- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it. +- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root. +- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). **CRITICAL: MCP Server Configuration** - ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving - NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers - Invalid MCP configs will prevent the agent from starting with validation errors -**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. +**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. -The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools.`; +Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.`; diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts new file mode 100644 index 00000000..594c0f7b --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -0,0 +1,83 @@ +export const skill = String.raw` +# PDF Presentation Generator Skill + +## When to Use + +Activate when the user wants to create presentations, slide decks, or pitch decks. + +## Workflow + +1. Check ~/.rowboat/knowledge/ for relevant context about the company, product, team, etc. +2. Ensure Playwright is installed: 'npm install playwright && npx playwright install chromium' +3. Create an HTML file (e.g., /tmp/presentation.html) with slides (1280x720px each) +4. Create a Node.js script to convert HTML to PDF: + +~~~javascript +// save as /tmp/convert.js +const { chromium } = require('playwright'); +const path = require('path'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' }); + await page.pdf({ + path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'), + width: '1280px', + height: '720px', + printBackground: true, + }); + await browser.close(); + console.log('Done: ~/Desktop/presentation.pdf'); +})(); +~~~ + +5. Run it: 'node /tmp/convert.js' +6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" + +Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it. + +## PDF Export Rules + +**These rules prevent rendering issues in PDF. Violating them causes overlapping rectangles and broken layouts.** + +1. **No layered elements** - Never create separate elements for backgrounds or shadows. Style content elements directly. +2. **No box-shadow** - Use borders instead: \`border: 1px solid #e5e7eb\` +3. **Bullets via CSS only** - Use \`li::before\` pseudo-elements, not separate DOM elements +4. **Content must fit** - Slides are 1280x720px with 60px padding. Safe area is 1160x600px. Use \`overflow: hidden\`. + +## Required CSS + +~~~css +@page { size: 1280px 720px; margin: 0; } +html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } +.slide { + width: 1280px; + height: 720px; + padding: 60px; + overflow: hidden; + page-break-after: always; + page-break-inside: avoid; +} +.slide:last-child { page-break-after: auto; } +~~~ + +## Playwright Export + +~~~typescript +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); +await page.pdf({ + path: '~/Desktop/presentation.pdf', + width: '1280px', + height: '720px', + printBackground: true, +}); +await browser.close(); +~~~ +`; + +export default skill; \ No newline at end of file diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 76e5294c..3e132ac0 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -9,6 +9,7 @@ import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; import slackSkill from "./slack/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; +import createPresentationsSkill from "./create-presentations/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -28,6 +29,13 @@ type ResolvedSkill = { }; const definitions: SkillDefinition[] = [ + { + id: "create-presentations", + title: "Create Presentations", + folder: "create-presentations", + summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.", + content: createPresentationsSkill, + }, { id: "doc-collab", title: "Document Collaboration", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index b2c73020..9411eb41 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -2,7 +2,7 @@ import { z, ZodType } from "zod"; import * as path from "path"; import { execSync } from "child_process"; import { glob } from "glob"; -import { executeCommand } from "./command-executor.js"; +import { executeCommand, executeCommandAbortable } from "./command-executor.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; import container from "../../di/container.js"; @@ -14,13 +14,14 @@ import { WorkDir } from "../../config/config.js"; import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; +import type { ToolContext } from "./exec-tool.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), inputSchema: z.custom(), execute: z.function({ - input: z.any(), + input: z.any(), // (input, ctx?) => Promise output: z.promise(z.any()), }), })); @@ -838,17 +839,17 @@ export const BuiltinTools: z.infer = { description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', inputSchema: z.object({ command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), - cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root)'), + cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root). You do not need to set this unless absolutely necessary.'), }), - execute: async ({ command, cwd }: { command: string, cwd?: string }) => { + execute: async ({ command, cwd }: { command: string, cwd?: string }, ctx?: ToolContext) => { try { const rootDir = path.resolve(WorkDir); const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir; - const rootPrefix = rootDir.endsWith(path.sep) - ? rootDir - : `${rootDir}${path.sep}`; // TODO: Re-enable this check + // const rootPrefix = rootDir.endsWith(path.sep) + // ? rootDir + // : `${rootDir}${path.sep}`; // if (workingDir !== rootDir && !workingDir.startsWith(rootPrefix)) { // return { // success: false, @@ -858,6 +859,30 @@ export const BuiltinTools: z.infer = { // }; // } + // Use abortable version when we have a signal + if (ctx?.signal) { + const { promise, process: proc } = executeCommandAbortable(command, { + cwd: workingDir, + signal: ctx.signal, + }); + + // Register process with abort registry for force-kill + ctx.abortRegistry.registerProcess(ctx.runId, proc); + + const result = await promise; + + return { + success: result.exitCode === 0 && !result.wasAborted, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + wasAborted: result.wasAborted, + command, + workingDir, + }; + } + + // Fallback to original for backward compatibility const result = await executeCommand(command, { cwd: workingDir }); return { diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 01be2ee7..e865e98d 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -1,4 +1,4 @@ -import { exec, execSync } from 'child_process'; +import { exec, execSync, spawn, ChildProcess } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList } from '../../config/security.js'; @@ -110,6 +110,159 @@ export async function executeCommand( } } +export interface AbortableCommandResult extends CommandResult { + wasAborted: boolean; +} + +const SIGKILL_GRACE_MS = 200; + +/** + * Kill a process tree using negative PID (process group kill on Unix). + * Falls back to direct kill if group kill fails. + */ +function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!proc.pid || proc.killed) return; + try { + // Negative PID kills the entire process group (Unix) + process.kill(-proc.pid, signal); + } catch { + try { + proc.kill(signal); + } catch { + // Process may already be dead + } + } +} + +/** + * Executes a shell command with abort support. + * Uses spawn with detached=true to create a process group for proper tree killing. + * Returns both the promise and the child process handle. + */ +export function executeCommandAbortable( + command: string, + options?: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + signal?: AbortSignal; + } +): { promise: Promise; process: ChildProcess } { + // Check if already aborted before spawning + if (options?.signal?.aborted) { + // Return a dummy process and a resolved result + const dummyProc = spawn('true', { shell: true }); + dummyProc.kill(); + return { + process: dummyProc, + promise: Promise.resolve({ + stdout: '', + stderr: '', + exitCode: 130, + wasAborted: true, + }), + }; + } + + const proc = spawn(command, [], { + shell: '/bin/sh', + cwd: options?.cwd, + detached: process.platform !== 'win32', // Create process group on Unix + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const promise = new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let wasAborted = false; + let exited = false; + + // Collect output + proc.stdout?.on('data', (chunk: Buffer) => { + const maxBuffer = options?.maxBuffer || 1024 * 1024; + if (stdout.length < maxBuffer) { + stdout += chunk.toString(); + } + }); + proc.stderr?.on('data', (chunk: Buffer) => { + const maxBuffer = options?.maxBuffer || 1024 * 1024; + if (stderr.length < maxBuffer) { + stderr += chunk.toString(); + } + }); + + // Abort handler + const abortHandler = () => { + wasAborted = true; + killProcessTree(proc, 'SIGTERM'); + // Force kill after grace period + setTimeout(() => { + if (!exited) { + killProcessTree(proc, 'SIGKILL'); + } + }, SIGKILL_GRACE_MS); + }; + + if (options?.signal) { + options.signal.addEventListener('abort', abortHandler, { once: true }); + } + + // Timeout handler + let timeoutId: ReturnType | undefined; + if (options?.timeout) { + timeoutId = setTimeout(() => { + wasAborted = true; + killProcessTree(proc, 'SIGTERM'); + setTimeout(() => { + if (!exited) { + killProcessTree(proc, 'SIGKILL'); + } + }, SIGKILL_GRACE_MS); + }, options.timeout); + } + + proc.once('exit', (code) => { + exited = true; + // Cleanup listeners + if (options?.signal) { + options.signal.removeEventListener('abort', abortHandler); + } + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (wasAborted) { + stdout += '\n\n(Command was aborted)'; + } + + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + wasAborted, + }); + }); + + proc.once('error', (err) => { + exited = true; + if (options?.signal) { + options.signal.removeEventListener('abort', abortHandler); + } + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + wasAborted, + }); + }); + }); + + return { promise, process: proc }; +} + /** * Executes a command synchronously (blocking) * Use with caution - prefer executeCommand for async execution diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index fbe46f57..09983402 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -2,22 +2,36 @@ import { ToolAttachment } from "@x/shared/dist/agent.js"; import { z } from "zod"; import { BuiltinTools } from "./builtin-tools.js"; import { executeTool } from "../../mcp/mcp.js"; +import { IAbortRegistry } from "../../runs/abort-registry.js"; + +/** + * Context passed to every tool execution, providing abort signal and run metadata. + */ +export interface ToolContext { + runId: string; + signal: AbortSignal; + abortRegistry: IAbortRegistry; +} async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: Record): Promise { const result = await executeTool(agentTool.mcpServerName, agentTool.name, input); return result; } -export async function execTool(agentTool: z.infer, input: Record): Promise { +export async function execTool(agentTool: z.infer, input: Record, ctx?: ToolContext): Promise { + // Check abort before starting any tool + ctx?.signal.throwIfAborted(); + switch (agentTool.type) { case "mcp": + // MCP tools: let complete on graceful stop (most are fast) return execMcpTool(agentTool, input); case "builtin": { const builtinTool = BuiltinTools[agentTool.name]; if (!builtinTool || !builtinTool.execute) { throw new Error(`Unsupported builtin tool: ${agentTool.name}`); } - return builtinTool.execute(input); + return builtinTool.execute(input, ctx); } } } \ No newline at end of file diff --git a/apps/x/packages/core/src/auth/provider-client-id.ts b/apps/x/packages/core/src/auth/provider-client-id.ts deleted file mode 100644 index 6f8f6a90..00000000 --- a/apps/x/packages/core/src/auth/provider-client-id.ts +++ /dev/null @@ -1,23 +0,0 @@ -type ProviderClientIdOverrides = Map; - -const providerClientIdOverrides: ProviderClientIdOverrides = new Map(); - -export function setProviderClientIdOverride(provider: string, clientId: string): void { - const trimmed = clientId.trim(); - if (!trimmed) { - return; - } - providerClientIdOverrides.set(provider, trimmed); -} - -export function getProviderClientIdOverride(provider: string): string | undefined { - return providerClientIdOverrides.get(provider); -} - -export function hasProviderClientIdOverride(provider: string): boolean { - return providerClientIdOverrides.has(provider); -} - -export function clearProviderClientIdOverride(provider: string): void { - providerClientIdOverrides.delete(provider); -} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index bb4bdd9f..edda5447 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -22,7 +22,7 @@ const DiscoverySchema = z.discriminatedUnion('mode', [ const ClientSchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('static'), - clientId: z.string().min(1).optional(), + clientId: z.string().min(1), }), z.object({ mode: z.literal('dcr'), @@ -58,11 +58,11 @@ const providerConfigs: ProviderConfig = { }, client: { mode: 'static', + clientId: '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com', }, scopes: [ 'https://www.googleapis.com/auth/gmail.readonly', - 'https://www.googleapis.com/auth/calendar.readonly', - 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/calendar.events.readonly', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts new file mode 100644 index 00000000..1c447e37 --- /dev/null +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -0,0 +1,20 @@ +import container from "../di/container.js"; +import type { IModelConfigRepo } from "../models/repo.js"; +import type { IMcpConfigRepo } from "../mcp/repo.js"; +import { ensureSecurityConfig } from "./security.js"; + +/** + * Initialize all config files at app startup. + * Ensures config files exist before the UI might access them. + */ +export async function initConfigs(): Promise { + // Resolve repos and explicitly call their ensureConfig methods + const modelConfigRepo = container.resolve("modelConfigRepo"); + const mcpConfigRepo = container.resolve("mcpConfigRepo"); + + await Promise.all([ + modelConfigRepo.ensureConfig(), + mcpConfigRepo.ensureConfig(), + ensureSecurityConfig(), + ]); +} diff --git a/apps/x/packages/core/src/config/security.ts b/apps/x/packages/core/src/config/security.ts index 9419e76e..d69eb241 100644 --- a/apps/x/packages/core/src/config/security.ts +++ b/apps/x/packages/core/src/config/security.ts @@ -1,5 +1,6 @@ import path from "path"; import fs from "fs"; +import fsPromises from "fs/promises"; import { WorkDir } from "./config.js"; export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"); @@ -19,7 +20,26 @@ const DEFAULT_ALLOW_LIST = [ let cachedAllowList: string[] | null = null; let cachedMtimeMs: number | null = null; -function ensureSecurityConfig() { +/** + * Async function to ensure security config file exists. + * Called explicitly at app startup via initConfigs(). + */ +export async function ensureSecurityConfig(): Promise { + try { + await fsPromises.access(SECURITY_CONFIG_PATH); + } catch { + await fsPromises.writeFile( + SECURITY_CONFIG_PATH, + JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n", + "utf8", + ); + } +} + +/** + * Sync version for internal use by getSecurityAllowList() and readAllowList(). + */ +function ensureSecurityConfigSync() { if (!fs.existsSync(SECURITY_CONFIG_PATH)) { fs.writeFileSync( SECURITY_CONFIG_PATH, @@ -63,7 +83,7 @@ function parseSecurityPayload(payload: unknown): string[] { } function readAllowList(): string[] { - ensureSecurityConfig(); + ensureSecurityConfigSync(); try { const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8"); @@ -76,7 +96,7 @@ function readAllowList(): string[] { } export function getSecurityAllowList(): string[] { - ensureSecurityConfig(); + ensureSecurityConfigSync(); try { const stats = fs.statSync(SECURITY_CONFIG_PATH); if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2e85d34a..2b3fd2d7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; +import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -22,6 +23,7 @@ container.register({ messageQueue: asClass(InMemoryMessageQueue).singleton(), bus: asClass(InMemoryBus).singleton(), runsLock: asClass(InMemoryRunsLock).singleton(), + abortRegistry: asClass(InMemoryAbortRegistry).singleton(), agentRuntime: asClass(AgentRuntime).singleton(), mcpConfigRepo: asClass(FSMcpConfigRepo).singleton(), diff --git a/apps/x/packages/core/src/index.ts b/apps/x/packages/core/src/index.ts index b004d263..b7718cba 100644 --- a/apps/x/packages/core/src/index.ts +++ b/apps/x/packages/core/src/index.ts @@ -2,4 +2,7 @@ export * as workspace from './workspace/workspace.js'; // Workspace watcher -export * as watcher from './workspace/watcher.js'; \ No newline at end of file +export * as watcher from './workspace/watcher.js'; + +// Config initialization +export { initConfigs } from './config/initConfigs.js'; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts index d8c975c7..a56d39b5 100644 --- a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts @@ -144,13 +144,9 @@ export class FirefliesClientFactory { if (providerConfig.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[Fireflies] Discovery mode: issuer with static client ID`); - const clientId = providerConfig.client.clientId; - if (!clientId) { - throw new Error('Fireflies client ID not configured.'); - } this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, - clientId + providerConfig.client.clientId ); } else { // DCR mode - need existing registration @@ -174,14 +170,10 @@ export class FirefliesClientFactory { } console.log(`[Fireflies] Using static endpoints (no discovery)`); - const clientId = providerConfig.client.clientId; - if (!clientId) { - throw new Error('Fireflies client ID not configured.'); - } this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, - clientId, + providerConfig.client.clientId, providerConfig.discovery.revocationEndpoint ); } diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index 9a1240fb..f32f492c 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -3,7 +3,6 @@ import container from '../di/container.js'; import { IOAuthRepo } from '../auth/repo.js'; import { IClientRegistrationRepo } from '../auth/client-repo.js'; import { getProviderConfig } from '../auth/providers.js'; -import { getProviderClientIdOverride } from '../auth/provider-client-id.js'; import * as oauthClient from '../auth/oauth-client.js'; import type { Configuration } from '../auth/oauth-client.js'; import { OAuthTokens } from '../auth/types.js'; @@ -18,22 +17,12 @@ export class GoogleClientFactory { config: Configuration | null; client: OAuth2Client | null; tokens: OAuthTokens | null; - clientId: string | null; } = { config: null, client: null, tokens: null, - clientId: null, }; - private static resolveClientId(): string { - const override = getProviderClientIdOverride(this.PROVIDER_NAME); - if (!override) { - throw new Error('Google client ID not provided for this session.'); - } - return override; - } - /** * Get or create OAuth2Client, reusing cached instance when possible */ @@ -47,13 +36,7 @@ export class GoogleClientFactory { } // Initialize config cache if needed - try { - await this.initializeConfigCache(); - } catch (error) { - console.error("[OAuth] Failed to initialize Google OAuth configuration:", error); - this.clearCache(); - return null; - } + await this.initializeConfigCache(); if (!this.cache.config) { return null; } @@ -112,10 +95,6 @@ export class GoogleClientFactory { return false; } - if (!getProviderClientIdOverride(this.PROVIDER_NAME)) { - return false; - } - const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); if (!tokens) { return false; @@ -137,21 +116,14 @@ export class GoogleClientFactory { this.cache.config = null; this.cache.client = null; this.cache.tokens = null; - this.cache.clientId = null; } /** * Initialize cached configuration (called once) */ private static async initializeConfigCache(): Promise { - const clientId = this.resolveClientId(); - - if (this.cache.config && this.cache.clientId === clientId) { - return; // Already initialized for this client ID - } - - if (this.cache.clientId && this.cache.clientId !== clientId) { - this.clearCache(); + if (this.cache.config) { + return; // Already initialized } console.log(`[OAuth] Initializing Google OAuth configuration...`); @@ -163,7 +135,7 @@ export class GoogleClientFactory { console.log(`[OAuth] Discovery mode: issuer with static client ID`); this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, - clientId + providerConfig.client.clientId ); } else { // DCR mode - need existing registration @@ -190,12 +162,11 @@ export class GoogleClientFactory { this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, - clientId, + providerConfig.client.clientId, providerConfig.discovery.revocationEndpoint ); } - this.cache.clientId = clientId; console.log(`[OAuth] Google OAuth configuration initialized`); } @@ -203,7 +174,17 @@ export class GoogleClientFactory { * Create OAuth2Client from OAuthTokens */ private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client { - const clientId = this.resolveClientId(); + const providerConfig = getProviderConfig(this.PROVIDER_NAME); + + // Get client ID from config + let clientId: string; + if (providerConfig.client.mode === 'static') { + clientId = providerConfig.client.clientId; + } else { + // For DCR, we'd need to look up the registered client ID + // This is a fallback - normally initializeConfigCache handles this + throw new Error('Cannot create client without static client ID'); + } // Create OAuth2Client directly (PKCE flow doesn't use client secret) const client = new OAuth2Client( diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index f2719357..426de091 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -11,8 +11,8 @@ const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 14; const REQUIRED_SCOPES = [ - 'https://www.googleapis.com/auth/calendar.readonly', - 'https://www.googleapis.com/auth/drive.readonly' + 'https://www.googleapis.com/auth/calendar.events.readonly', + // 'https://www.googleapis.com/auth/drive.readonly' ]; const nhm = new NodeHtmlMarkdown(); @@ -186,7 +186,7 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD for (const event of events) { if (event.id) { await saveEvent(event, syncDir); - await processAttachments(drive, event, syncDir); + // await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); } } diff --git a/apps/x/packages/core/src/mcp/mcp.ts b/apps/x/packages/core/src/mcp/mcp.ts index 4cf664d9..22c8740b 100644 --- a/apps/x/packages/core/src/mcp/mcp.ts +++ b/apps/x/packages/core/src/mcp/mcp.ts @@ -84,6 +84,22 @@ export async function cleanup() { } } +/** + * Force-close all MCP client connections. + * Used during force abort to immediately reject any pending MCP tool calls. + * Clients will be lazily reconnected on next use. + */ +export async function forceCloseAllMcpClients(): Promise { + for (const [serverName, { client }] of Object.entries(clients)) { + try { + await client?.close(); + } catch { + // Ignore errors during force close + } + delete clients[serverName]; + } +} + export async function listServers(): Promise> { const repo = container.resolve('mcpConfigRepo'); const { mcpServers } = await repo.getConfig(); diff --git a/apps/x/packages/core/src/mcp/repo.ts b/apps/x/packages/core/src/mcp/repo.ts index b3ebee33..841f162b 100644 --- a/apps/x/packages/core/src/mcp/repo.ts +++ b/apps/x/packages/core/src/mcp/repo.ts @@ -5,6 +5,7 @@ import path from "path"; import z from "zod"; export interface IMcpConfigRepo { + ensureConfig(): Promise; getConfig(): Promise>; upsert(serverName: string, config: z.infer): Promise; delete(serverName: string): Promise; @@ -13,11 +14,7 @@ export interface IMcpConfigRepo { export class FSMcpConfigRepo implements IMcpConfigRepo { private readonly configPath = path.join(WorkDir, "config", "mcp.json"); - constructor() { - this.ensureDefaultConfig(); - } - - private async ensureDefaultConfig(): Promise { + async ensureConfig(): Promise { try { await fs.access(this.configPath); } catch { diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index cc60937e..33ad2502 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -5,6 +5,7 @@ import path from "path"; import z from "zod"; export interface IModelConfigRepo { + ensureConfig(): Promise; getConfig(): Promise>; upsert(providerName: string, config: z.infer): Promise; delete(providerName: string): Promise; @@ -26,11 +27,7 @@ const defaultConfig: z.infer = { export class FSModelConfigRepo implements IModelConfigRepo { private readonly configPath = path.join(WorkDir, "config", "models.json"); - constructor() { - this.ensureDefaultConfig(); - } - - private async ensureDefaultConfig(): Promise { + async ensureConfig(): Promise { try { await fs.access(this.configPath); } catch { diff --git a/apps/x/packages/core/src/runs/abort-registry.ts b/apps/x/packages/core/src/runs/abort-registry.ts new file mode 100644 index 00000000..7d3ca334 --- /dev/null +++ b/apps/x/packages/core/src/runs/abort-registry.ts @@ -0,0 +1,170 @@ +import { ChildProcess } from "child_process"; + +export interface IAbortRegistry { + /** + * Create and track an AbortController for a run. + * Returns the AbortSignal to thread through all operations. + */ + createForRun(runId: string): AbortSignal; + + /** + * Track a child process for a run (so we can kill it on abort). + */ + registerProcess(runId: string, process: ChildProcess): void; + + /** + * Untrack a child process after it exits. + */ + unregisterProcess(runId: string, process: ChildProcess): void; + + /** + * Graceful abort: + * 1. Fires the AbortSignal (cancels LLM streaming, etc.) + * 2. Sends SIGTERM to all tracked process groups + * 3. Schedules SIGKILL fallback after grace period + */ + abort(runId: string): void; + + /** + * Force abort: + * 1. Fires AbortSignal if not already fired + * 2. Sends SIGKILL to all tracked process groups immediately + */ + forceAbort(runId: string): void; + + /** + * Check if a run has been aborted. + */ + isAborted(runId: string): boolean; + + /** + * Clean up tracking state after a run completes or is fully stopped. + */ + cleanup(runId: string): void; +} + +interface RunAbortState { + controller: AbortController; + processes: Set; + killTimers: Set>; +} + +const SIGKILL_GRACE_MS = 200; + +export class InMemoryAbortRegistry implements IAbortRegistry { + private runs: Map = new Map(); + + createForRun(runId: string): AbortSignal { + // If a previous run state exists, clean it up first + this.cleanup(runId); + + const state: RunAbortState = { + controller: new AbortController(), + processes: new Set(), + killTimers: new Set(), + }; + this.runs.set(runId, state); + return state.controller.signal; + } + + registerProcess(runId: string, process: ChildProcess): void { + const state = this.runs.get(runId); + if (!state) return; + state.processes.add(process); + + // Auto-unregister when process exits + const onExit = () => { + state.processes.delete(process); + }; + process.once("exit", onExit); + process.once("error", onExit); + } + + unregisterProcess(runId: string, process: ChildProcess): void { + const state = this.runs.get(runId); + if (!state) return; + state.processes.delete(process); + } + + abort(runId: string): void { + const state = this.runs.get(runId); + if (!state) return; + + // 1. Fire the abort signal + if (!state.controller.signal.aborted) { + state.controller.abort(); + } + + // 2. SIGTERM all tracked process groups + for (const proc of state.processes) { + this.killProcessTree(proc, "SIGTERM"); + + // 3. Schedule SIGKILL fallback + const timer = setTimeout(() => { + if (!proc.killed) { + this.killProcessTree(proc, "SIGKILL"); + } + state.killTimers.delete(timer); + }, SIGKILL_GRACE_MS); + state.killTimers.add(timer); + } + } + + forceAbort(runId: string): void { + const state = this.runs.get(runId); + if (!state) return; + + // 1. Fire abort signal if not already + if (!state.controller.signal.aborted) { + state.controller.abort(); + } + + // 2. Clear any pending graceful kill timers + for (const timer of state.killTimers) { + clearTimeout(timer); + } + state.killTimers.clear(); + + // 3. SIGKILL all tracked process groups immediately + for (const proc of state.processes) { + this.killProcessTree(proc, "SIGKILL"); + } + } + + isAborted(runId: string): boolean { + const state = this.runs.get(runId); + return state?.controller.signal.aborted ?? false; + } + + cleanup(runId: string): void { + const state = this.runs.get(runId); + if (!state) return; + + // Clear any pending kill timers + for (const timer of state.killTimers) { + clearTimeout(timer); + } + + this.runs.delete(runId); + } + + /** + * Kill a process tree using negative PID (process group kill on Unix). + * Falls back to direct kill if group kill fails. + */ + private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!proc.pid || proc.killed) return; + + try { + // Negative PID kills the entire process group (Unix) + process.kill(-proc.pid, signal); + } catch { + // Fallback: kill just the process directly + try { + proc.kill(signal); + } catch { + // Process may already be dead + } + } + } +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 7d7a8ebb..80ffc80f 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -5,6 +5,8 @@ import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, R import { IRunsRepo } from "./repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; import { IBus } from "../application/lib/bus.js"; +import { IAbortRegistry } from "./abort-registry.js"; +import { forceCloseAllMcpClients } from "../mcp/mcp.js"; export async function createRun(opts: z.infer): Promise> { const repo = container.resolve('runsRepo'); @@ -46,9 +48,21 @@ export async function replyToHumanInputRequest(runId: string, ev: z.infer { - console.log(`Stopping run ${runId}`); - throw new Error('Not implemented'); +export async function stop(runId: string, force: boolean = false): Promise { + const abortRegistry = container.resolve('abortRegistry'); + + if (force && abortRegistry.isAborted(runId)) { + // Second click: aggressive cleanup — SIGKILL + force close MCP clients + console.log(`Force stopping run ${runId}`); + abortRegistry.forceAbort(runId); + await forceCloseAllMcpClients(); + } else { + // First click: graceful — fires AbortSignal + SIGTERM + console.log(`Gracefully stopping run ${runId}`); + abortRegistry.abort(runId); + } + // Note: The run-stopped event is emitted by AgentRuntime.trigger() when it detects the abort. + // This avoids duplicate events and ensures proper sequencing. } export async function fetchRun(runId: string): Promise> { diff --git a/apps/x/packages/core/tsconfig.json b/apps/x/packages/core/tsconfig.json index 3e5b01a3..d9f2b13c 100644 --- a/apps/x/packages/core/tsconfig.json +++ b/apps/x/packages/core/tsconfig.json @@ -4,7 +4,8 @@ "declaration": true, "outDir": "dist", "rootDir": "src", - "types": ["node"] + "types": ["node"], + "jsx": "react-jsx" }, "include": [ "src" diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f95781a3..79746516 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -151,6 +151,7 @@ const ipcSchemas = { 'runs:stop': { req: z.object({ runId: z.string(), + force: z.boolean().optional().default(false), }), res: z.object({ success: z.literal(true), @@ -175,7 +176,6 @@ const ipcSchemas = { 'oauth:connect': { req: z.object({ provider: z.string(), - clientId: z.string().optional(), }), res: z.object({ success: z.boolean(), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 429d827b..eccfb6a7 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -80,6 +80,11 @@ export const RunErrorEvent = BaseRunEvent.extend({ error: z.string(), }); +export const RunStoppedEvent = BaseRunEvent.extend({ + type: z.literal("run-stopped"), + reason: z.enum(["user-requested", "force-stopped"]).optional(), +}); + export const RunEvent = z.union([ RunProcessingStartEvent, RunProcessingEndEvent, @@ -94,6 +99,7 @@ export const RunEvent = z.union([ ToolPermissionRequestEvent, ToolPermissionResponseEvent, RunErrorEvent, + RunStoppedEvent, ]); export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index e9518969..31db92c0 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -302,6 +302,12 @@ importers: '@openrouter/ai-sdk-provider': specifier: ^1.2.6 version: 1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1) + '@react-pdf/renderer': + specifier: ^4.3.2 + version: 4.3.2(react@19.2.3) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 '@x/shared': specifier: workspace:* version: link:../shared @@ -332,6 +338,9 @@ importers: openid-client: specifier: ^6.8.1 version: 6.8.1 + react: + specifier: ^19.2.3 + version: 19.2.3 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -634,6 +643,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1963,6 +1976,49 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-pdf/fns@3.1.2': + resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==} + + '@react-pdf/font@4.0.4': + resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==} + + '@react-pdf/image@3.0.4': + resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==} + + '@react-pdf/layout@4.4.2': + resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==} + + '@react-pdf/pdfkit@4.1.0': + resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==} + + '@react-pdf/png-js@3.0.0': + resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==} + + '@react-pdf/primitives@4.1.1': + resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==} + + '@react-pdf/reconciler@2.0.0': + resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/render@4.3.2': + resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==} + + '@react-pdf/renderer@4.3.2': + resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/stylesheet@6.1.2': + resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==} + + '@react-pdf/textkit@6.1.0': + resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==} + + '@react-pdf/types@2.9.2': + resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -2323,6 +2379,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -2947,6 +3006,9 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abs-svg-path@0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3087,6 +3149,10 @@ packages: base32-encode@1.2.0: resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3094,6 +3160,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3130,6 +3199,12 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3267,6 +3342,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3287,6 +3366,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -3385,6 +3467,9 @@ packages: resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==} engines: {node: '>=12.10'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -3628,6 +3713,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3690,6 +3778,9 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3991,6 +4082,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4314,6 +4408,12 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + hsl-to-hex@1.0.0: + resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==} + + hsl-to-rgb-for-reals@1.1.1: + resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4346,6 +4446,9 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + hyphen@1.14.1: + resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4435,6 +4538,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -4522,6 +4628,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jay-peg@1.1.1: + resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -4695,6 +4804,9 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} @@ -4751,6 +4863,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -4871,6 +4987,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-engine@1.0.3: + resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -5216,6 +5335,9 @@ packages: normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + normalize-svg-path@1.1.0: + resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -5345,6 +5467,12 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5363,6 +5491,9 @@ packages: resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} engines: {node: '>=0.10.0'} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -5456,6 +5587,9 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -5500,6 +5634,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -5596,6 +5733,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -5619,6 +5759,9 @@ packages: peerDependencies: react: ^19.2.3 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -5776,6 +5919,9 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -5838,6 +5984,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.25.0-rc-603e6108-20241029: + resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5928,6 +6077,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -6065,6 +6217,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-arc-to-cubic-bezier@3.2.0: + resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -6108,6 +6263,9 @@ packages: tiny-each-async@2.0.3: resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -6235,6 +6393,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6363,6 +6527,10 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-compatible-readable-stream@3.6.1: + resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} + engines: {node: '>= 6'} + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6546,6 +6714,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -7197,6 +7368,8 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.6': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -8707,6 +8880,107 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-pdf/fns@3.1.2': {} + + '@react-pdf/font@4.0.4': + dependencies: + '@react-pdf/pdfkit': 4.1.0 + '@react-pdf/types': 2.9.2 + fontkit: 2.0.4 + is-url: 1.2.4 + + '@react-pdf/image@3.0.4': + dependencies: + '@react-pdf/png-js': 3.0.0 + jay-peg: 1.1.1 + + '@react-pdf/layout@4.4.2': + dependencies: + '@react-pdf/fns': 3.1.2 + '@react-pdf/image': 3.0.4 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/stylesheet': 6.1.2 + '@react-pdf/textkit': 6.1.0 + '@react-pdf/types': 2.9.2 + emoji-regex-xs: 1.0.0 + queue: 6.0.2 + yoga-layout: 3.2.1 + + '@react-pdf/pdfkit@4.1.0': + dependencies: + '@babel/runtime': 7.28.6 + '@react-pdf/png-js': 3.0.0 + browserify-zlib: 0.2.0 + crypto-js: 4.2.0 + fontkit: 2.0.4 + jay-peg: 1.1.1 + linebreak: 1.1.0 + vite-compatible-readable-stream: 3.6.1 + + '@react-pdf/png-js@3.0.0': + dependencies: + browserify-zlib: 0.2.0 + + '@react-pdf/primitives@4.1.1': {} + + '@react-pdf/reconciler@2.0.0(react@19.2.3)': + dependencies: + object-assign: 4.1.1 + react: 19.2.3 + scheduler: 0.25.0-rc-603e6108-20241029 + + '@react-pdf/render@4.3.2': + dependencies: + '@babel/runtime': 7.28.6 + '@react-pdf/fns': 3.1.2 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/textkit': 6.1.0 + '@react-pdf/types': 2.9.2 + abs-svg-path: 0.1.1 + color-string: 1.9.1 + normalize-svg-path: 1.1.0 + parse-svg-path: 0.1.2 + svg-arc-to-cubic-bezier: 3.2.0 + + '@react-pdf/renderer@4.3.2(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@react-pdf/fns': 3.1.2 + '@react-pdf/font': 4.0.4 + '@react-pdf/layout': 4.4.2 + '@react-pdf/pdfkit': 4.1.0 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/reconciler': 2.0.0(react@19.2.3) + '@react-pdf/render': 4.3.2 + '@react-pdf/types': 2.9.2 + events: 3.3.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + queue: 6.0.2 + react: 19.2.3 + + '@react-pdf/stylesheet@6.1.2': + dependencies: + '@react-pdf/fns': 3.1.2 + '@react-pdf/types': 2.9.2 + color-string: 1.9.1 + hsl-to-hex: 1.0.0 + media-engine: 1.0.3 + postcss-value-parser: 4.2.0 + + '@react-pdf/textkit@6.1.0': + dependencies: + '@react-pdf/fns': 3.1.2 + bidi-js: 1.0.3 + hyphen: 1.14.1 + unicode-properties: 1.4.1 + + '@react-pdf/types@2.9.2': + dependencies: + '@react-pdf/font': 4.0.4 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/stylesheet': 6.1.2 + '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -9152,6 +9426,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -9886,6 +10164,8 @@ snapshots: abbrev@1.1.1: {} + abs-svg-path@0.1.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -10025,10 +10305,16 @@ snapshots: to-data-view: 1.1.0 optional: true + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.1: {} bl@4.1.0: @@ -10078,6 +10364,14 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 @@ -10239,6 +10533,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -10262,6 +10558,11 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + colorette@2.0.20: {} combined-stream@1.0.8: @@ -10342,6 +10643,8 @@ snapshots: cross-zip@4.0.1: {} + crypto-js@4.2.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -10601,6 +10904,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -10714,6 +11019,8 @@ snapshots: transitivePeerDependencies: - supports-color + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -11105,6 +11412,18 @@ snapshots: follow-redirects@1.15.11: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.18 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -11594,6 +11913,12 @@ snapshots: hosted-git-info@2.8.9: {} + hsl-to-hex@1.0.0: + dependencies: + hsl-to-rgb-for-reals: 1.1.1 + + hsl-to-rgb-for-reals@1.1.1: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -11639,6 +11964,8 @@ snapshots: dependencies: ms: 2.1.3 + hyphen@1.14.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -11704,6 +12031,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.4: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -11771,6 +12100,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jay-peg@1.1.1: + dependencies: + restructure: 3.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 25.0.3 @@ -11924,6 +12257,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 @@ -11984,6 +12322,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -12232,6 +12574,8 @@ snapshots: mdurl@2.0.0: {} + media-engine@1.0.3: {} + media-typer@1.1.0: {} mem@4.3.0: @@ -12687,6 +13031,10 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 + normalize-svg-path@1.1.0: + dependencies: + svg-arc-to-cubic-bezier: 3.2.0 + normalize-url@6.1.0: {} npm-run-path@2.0.2: @@ -12805,6 +13153,10 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -12832,6 +13184,8 @@ snapshots: dependencies: error-ex: 1.3.4 + parse-svg-path@0.1.2: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -12908,6 +13262,8 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + postcss-value-parser@4.2.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -12951,6 +13307,12 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.1.0: {} prosemirror-changeset@2.3.1: @@ -13099,6 +13461,10 @@ snapshots: queue-microtask@1.2.3: {} + queue@6.0.2: + dependencies: + inherits: 2.0.4 + quick-lru@5.1.1: {} random-path@0.1.2: @@ -13125,6 +13491,8 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-is@16.13.1: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): @@ -13315,6 +13683,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restructure@3.0.2: {} + retry@0.12.0: {} reusify@1.1.0: {} @@ -13407,6 +13777,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.25.0-rc-603e6108-20241029: {} + scheduler@0.27.0: {} schema-utils@4.3.3: @@ -13520,6 +13892,10 @@ snapshots: signal-exit@4.1.0: {} + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 @@ -13685,6 +14061,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-arc-to-cubic-bezier@3.2.0: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -13727,6 +14105,8 @@ snapshots: tiny-each-async@2.0.3: optional: true + tiny-inflate@1.0.3: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -13839,6 +14219,16 @@ snapshots: undici-types@7.16.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -13974,6 +14364,12 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-compatible-readable-stream@3.6.1: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -14153,6 +14549,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.1(zod@4.2.1): dependencies: zod: 4.2.1