diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 6566f105..787e28e6 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,14 +16,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -39,17 +39,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -61,25 +61,25 @@ jobs: # Create a temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - + # Create keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Decode and import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - + # Allow codesign to access the keychain security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Add keychain to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain - + # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" - + # Clean up certificate file rm -f $RUNNER_TEMP/certificate.p12 @@ -111,6 +111,7 @@ jobs: with: name: distributables path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-linux: @@ -121,14 +122,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -144,17 +145,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -175,6 +176,7 @@ jobs: with: name: distributables-linux path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-windows: @@ -185,14 +187,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -210,17 +212,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -241,4 +243,5 @@ jobs: with: name: distributables-windows path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 572e9a6f..2d9816d0 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,6 +16,8 @@ ## Event catalog +All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. + ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -101,6 +103,7 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 9ae77e0e..976e8db3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,11 +10,13 @@ */ import * as esbuild from 'esbuild'; +import { readFile } from 'node:fs/promises'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, // and we use define to replace all import.meta.url references with it. const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; +const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -36,6 +38,7 @@ await esbuild.build({ // Empty strings disable analytics gracefully. 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), + 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..7806f6cd 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -56,6 +56,7 @@ module.exports = { description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, + setupIcon: path.join(__dirname, 'icons/icon.ico'), }) }, { @@ -66,7 +67,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,7 +80,9 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 00000000..0e5ac870 Binary files /dev/null and b/apps/x/apps/main/icons/icon.ico differ diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..3330c3c0 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,8 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2f5730ce..e5d407f8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -32,6 +32,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; @@ -455,6 +456,7 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, + appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { @@ -535,6 +537,11 @@ export function setupIpcHandlers() { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, + 'codeRun:resolvePermission': async (_event, args) => { + const registry = container.resolve('codePermissionRegistry'); + registry.resolve(args.requestId, args.decision); + return { success: true }; + }, 'runs:provideHumanInput': async (_event, args) => { await runsCore.replyToHumanInputRequest(args.runId, args.reply); return { success: true }; @@ -636,11 +643,11 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve('codeModeConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; }, 'codeMode:setConfig': async (_event, args) => { const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..f4415b5d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -40,7 +40,8 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -220,6 +221,7 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, + icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -416,6 +418,12 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. + try { + container.resolve('codeModeManager').disposeAll(); + } catch { + // nothing live to dispose + } shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a193b3f1..67876189 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@eigenpal/docx-editor-react": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -46,6 +47,15 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 5c072b2a..b850b57f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,19 +5,20 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { ImageFileViewer } from '@/components/image-file-viewer'; import { VideoFileViewer } from '@/components/video-file-viewer'; import { AudioFileViewer } from '@/components/audio-file-viewer'; +import { DocxFileViewer } from '@/components/docx-file-viewer'; import { PersistentViewerCache } from '@/components/persistent-viewer-cache'; import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; @@ -28,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; +import { CodingRunBlock } from '@/components/coding-run'; import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; @@ -55,9 +57,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -115,6 +118,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -163,6 +167,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -734,6 +739,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -763,7 +771,7 @@ function App() { // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -960,7 +968,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -1179,6 +1187,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1192,6 +1201,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1202,6 +1212,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -2025,6 +2036,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2033,6 +2045,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2065,6 +2079,7 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2184,19 +2199,6 @@ function App() { status: 'running', timestamp: Date.now(), }]) - // Detect acpx-driven coding-agent runs so the composer can retroactively - // flip code mode on with the right agent (when the user reached the skill - // via plain prompt rather than the explicit toggle). - if (llmEvent.toolName === 'executeCommand') { - const input = llmEvent.input as { command?: unknown } | undefined - const cmd = typeof input?.command === 'string' ? input.command : '' - const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) - if (match) { - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, - })) - } - } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2294,6 +2296,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2374,6 +2378,43 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2490,6 +2531,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2529,6 +2571,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2704,6 +2747,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -2733,6 +2796,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2759,6 +2823,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2784,6 +2849,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -5056,7 +5122,11 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5114,6 +5184,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5154,6 +5239,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5215,6 +5303,17 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5292,10 +5391,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5407,7 +5507,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5506,7 +5610,11 @@ function App() { remove: knowledgeActions.remove, copyPath: knowledgeActions.copyPath, revealInFileManager: knowledgeActions.revealInFileManager, + createNote: knowledgeActions.createNote, + createFolder: knowledgeActions.createFolder, + onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} /> @@ -5718,6 +5826,10 @@ function App() {
+ ) : selectedPath && getViewerType(selectedPath) === 'docx' ? ( +
+ +
) : (
@@ -5781,7 +5893,7 @@ function App() { <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map(item => { if (isToolGroup(item)) { return ( @@ -5793,41 +5905,43 @@ function App() { /> ) } - const rendered = renderConversationItem(item, tab.id) + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || permRequest) { const response = tabState.permissionResponses.get(item.id) || null return ( - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - onSwitchAgent={async (newAgent) => { - const runIdForSwitch = tab.runId - await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: runIdForSwitch, agent: newAgent }, - })) - if (runIdForSwitch) { - try { - await window.ipc.invoke('runs:createMessage', { - runId: runIdForSwitch, - message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, - codeMode: newAgent, - }) - } catch (err) { - console.error('Failed to send swap-agent follow-up', err) - } - } - }} - isProcessing={isActive && isProcessing} - response={response} - /> + {deniedAutoDecision && ( + + )} + {permRequest && ( + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} ) @@ -5930,10 +6044,13 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( & { + toolCall: z.infer; + decision: "allow" | "deny"; + reason: string; + permission?: z.infer; +}; + +const fileActionLabels: Record = { + read: "Read file", + list: "List folder", + search: "Search files", + write: "Write files", + delete: "Delete path", +}; + +export function AutoPermissionDecision({ + className, + toolCall, + decision, + reason, + permission, + ...props +}: AutoPermissionDecisionProps) { + const command = permission?.kind === "command" || toolCall.toolName === "executeCommand" + ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments + ? String(toolCall.arguments.command) + : JSON.stringify(toolCall.arguments)) + : null; + const filePermission = permission?.kind === "file" ? permission : null; + const allowed = decision === "allow"; + + return ( +
+
+
+ {allowed ? ( + + ) : ( + + )} +
+
+

+ {allowed ? "Auto Allowed" : "Auto Denied"} +

+ + + {toolCall.toolName} + +
+

{reason}

+
+
+ {command && ( +
+

Command

+
{command}
+
+ )} + {filePermission && ( +
+
+

Action

+

+ {fileActionLabels[filePermission.operation] ?? filePermission.operation} +

+
+
+

+ Path{filePermission.paths.length === 1 ? "" : "s"} +

+
+                {filePermission.paths.join("\n")}
+              
+
+
+ )} +
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index d99d2e8b..cdcafc27 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -9,7 +8,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { useState, type ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; @@ -21,7 +20,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onApproveSession?: () => void; onApproveAlways?: () => void; onDeny?: () => void; - onSwitchAgent?: (newAgent: 'claude' | 'codex') => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; permission?: z.infer; @@ -42,7 +40,6 @@ export const PermissionRequest = ({ onApproveSession, onApproveAlways, onDeny, - onSwitchAgent, isProcessing = false, response = null, permission, @@ -56,17 +53,6 @@ export const PermissionRequest = ({ : null; const filePermission = permission?.kind === "file" ? permission : null; - // Detect acpx coding-agent invocations so we can show the agent identity and - // offer a one-click swap-and-retry. - const acpxAgent: 'claude' | 'codex' | null = (() => { - if (!command) return null; - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/); - return match ? (match[1] as 'claude' | 'codex') : null; - })(); - const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null; - const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null; - const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null; - const isResponded = response !== null; const isApproved = response === 'approve'; @@ -104,15 +90,6 @@ export const PermissionRequest = ({

{isResponded ? "Requested:" : "The agent wants to execute:"} {toolCall.toolName} - {agentDisplay && ( - - - {agentDisplay} - - )}

{isResponded && ( @@ -220,18 +197,6 @@ export const PermissionRequest = ({ Deny - {otherAgent && otherDisplay && onSwitchAgent && ( - - )} )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ ); -export type ToolProps = ComponentProps; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +export type ToolProps = ComponentProps & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + + {children} + + ); + + if (!autoPermissionDetail) return toolCard; + + return ( +
+ {toolCard} +
+ + + + + Auto-approved + + + + {autoPermissionDetail.reason} + + +
+
+ ); +}; export type ToolHeaderProps = { title?: string; diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index b4574b58..4ba7479f 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -1237,6 +1237,8 @@ function TaskDetail({ const [confirmingDelete, setConfirmingDelete] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(true) const [outputRefreshKey, setOutputRefreshKey] = useState(0) + // Whether we've already chosen the initial sidebar state for this task. + const sidebarInitialized = useRef(false) const agentStatus = useBackgroundTaskAgentStatus() const liveStatus = agentStatus.get(slug) @@ -1252,6 +1254,23 @@ function TaskDetail({ if (result.success && result.task) { setTask(result.task) setDraft(result.task) + // On first open, collapse the details sidebar when the agent + // already has output — let the user read it without chrome. + // Resolved before `loading` clears so the sidebar never flashes. + if (!sidebarInitialized.current) { + sidebarInitialized.current = true + try { + const out = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.md`, + }) + const body = (out.data ?? '').trim() + if (body && body !== `# ${result.task.name}`) { + setSidebarOpen(false) + } + } catch { + // No output file yet — keep the sidebar open. + } + } } } finally { setLoading(false) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 360a8657..0254cdfd 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -10,13 +10,18 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCheck, + FolderClock, FolderCog, + FolderOpen, Globe, Headphones, ImagePlus, LoaderIcon, Mic, + MoreHorizontal, Plus, + ShieldCheck, Square, Terminal, X, @@ -25,10 +30,14 @@ import { import { Button } from '@/components/ui/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -60,6 +69,12 @@ export type StagedAttachment = { } const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +const MAX_VISIBLE_RECENT_WORK_DIRS = 3 +const MAX_STORED_RECENT_WORK_DIRS = 8 +// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and +// stays consistent with the other config/*.json files (e.g. coding-agents.json). +const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json' +const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed' const providerDisplayNames: Record = { @@ -80,11 +95,18 @@ interface ConfiguredModel { model: string } +type RecentWorkDir = { + path: string + lastUsedAt: number +} + export interface SelectedModel { provider: string model: string } +export type PermissionMode = 'manual' | 'auto' + function getSelectedModelDisplayName(model: string) { return model.split('/').pop() || model } @@ -108,8 +130,86 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } } +function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null { + if (typeof value === 'string') { + const path = value.trim() + return path ? { path, lastUsedAt: 0 } : null + } + if (!value || typeof value !== 'object') return null + const entry = value as Record + const path = typeof entry.path === 'string' ? entry.path.trim() : '' + const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt) + ? entry.lastUsedAt + : 0 + return path ? { path, lastUsedAt } : null +} + +async function readRecentWorkDirs(): Promise { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH }) + const parsed = JSON.parse(result.data) + if (!Array.isArray(parsed)) return [] + const seen = new Set() + const dirs: RecentWorkDir[] = [] + for (const value of parsed) { + const entry = normalizeRecentWorkDir(value) + if (!entry || seen.has(entry.path)) continue + seen.add(entry.path) + dirs.push(entry) + if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break + } + return dirs + } catch { + // File missing or invalid — no recents yet. + return [] + } +} + +async function writeRecentWorkDirs(dirs: RecentWorkDir[]) { + try { + await window.ipc.invoke('workspace:writeFile', { + path: RECENT_WORK_DIRS_CONFIG_PATH, + data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2), + }) + } catch (err) { + console.error('Failed to persist recent work directories', err) + } + // Notify other mounted chat inputs in this window to re-read. + window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT)) +} + +function formatRecentWorkDirTime(lastUsedAt: number) { + if (!lastUsedAt) return '' + const now = Date.now() + const diffMs = Math.max(0, now - lastUsedAt) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diffMs < minute) return 'now' + if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago` + if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago` + + const used = new Date(lastUsedAt) + const yesterday = new Date(now - day) + if ( + used.getFullYear() === yesterday.getFullYear() && + used.getMonth() === yesterday.getMonth() && + used.getDate() === yesterday.getDate() + ) { + return 'Yesterday' + } + if (diffMs < 7 * day) { + return used.toLocaleDateString(undefined, { weekday: 'short' }) + } + return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function compactWorkDirPath(path: string) { + return path.replace(/^\/Users\/[^/]+/, '~') +} + interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -182,11 +282,59 @@ function ChatInputInner({ const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) + const [permissionMode, setPermissionMode] = useState('auto') + const [recentWorkDirs, setRecentWorkDirs] = useState([]) + + // Responsive toolbar: measure real overflow and progressively collapse items + // right→left until everything fits. Stages: + // 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon + // 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu + // Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden. + // overflow-hidden on the left group is the hard guarantee against any overlap. + const toolbarRef = useRef(null) + const leftGroupRef = useRef(null) + const lastWidthRef = useRef(0) + const [collapseLevel, setCollapseLevel] = useState(0) + + // Re-evaluate from scratch (level 0) whenever the available width changes… + useEffect(() => { + const outer = toolbarRef.current + if (!outer) return + const ro = new ResizeObserver(() => { + const w = outer.clientWidth + if (w !== lastWidthRef.current) { + lastWidthRef.current = w + setCollapseLevel(0) + } + }) + ro.observe(outer) + return () => ro.disconnect() + }, []) + + // …or when the *set* of items changes (an item appears/disappears, or the model + // name width changes). Deliberately excludes the in-place toggles (searchEnabled, + // permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu + // for items already inside it, so resetting here would unmount the open menu. The + // no-dep effect below still re-collapses if any toggle happens to widen the row. + useLayoutEffect(() => { + setCollapseLevel(0) + }, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey]) + + // After each render, if the left group still overflows, collapse one more step. + // Runs before paint, so the intermediate (overflowing) state is never visible. + useLayoutEffect(() => { + const el = leftGroupRef.current + if (!el) return + if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) { + setCollapseLevel((l) => Math.min(8, l + 1)) + } + }) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { if (!runId) { setLockedModel(null) + setPermissionMode('auto') return } let cancelled = false @@ -195,10 +343,20 @@ function ChatInputInner({ if (run.provider && run.model) { setLockedModel({ provider: run.provider, model: run.model }) } + setPermissionMode(run.permissionMode ?? 'manual') }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) return () => { cancelled = true } }, [runId]) + useEffect(() => { + const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) } + syncRecentWorkDirs() + window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + return () => { + window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + } + }, []) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -283,20 +441,6 @@ function ChatInputInner({ } }, [codeModeFeatureEnabled, codeModeEnabled]) - // Listen for coding-agent runs that were triggered without the explicit code-mode - // toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We - // flip the pill on with the detected agent so the UI reflects what's happening. - useEffect(() => { - const handler = (ev: Event) => { - const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail - if (!detail || !detail.agent) return - if (runId && detail.runId && detail.runId !== runId) return - setCodeModeEnabled(true) - setCodingAgent(detail.agent) - } - window.addEventListener('code-mode-detected', handler) - return () => window.removeEventListener('code-mode-detected', handler) - }, [runId]) // Cross-platform basename — handles both / and \ separators. const basename = useCallback((p: string): string => { @@ -305,6 +449,17 @@ function ChatInputInner({ return idx >= 0 ? trimmed.slice(idx + 1) : trimmed }, []) + const rememberWorkDir = useCallback(async (dir: string) => { + const trimmed = dir.trim() + if (!trimmed) return + const next = [ + { path: trimmed, lastUsedAt: Date.now() }, + ...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed), + ].slice(0, MAX_STORED_RECENT_WORK_DIRS) + setRecentWorkDirs(next) + await writeRecentWorkDirs(next) + }, []) + // Load coding-agent preference for a given workdir. // Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' } const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => { @@ -321,7 +476,7 @@ function ChatInputInner({ }, []) const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { - let existing: Record = {} + const existing: Record = {} try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const parsed = JSON.parse(result.data) as Record @@ -347,6 +502,10 @@ function ChatInputInner({ return () => { cancelled = true } }, [workDir, loadCodingAgentFor]) + useEffect(() => { + if (isActive && workDir) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir]) + const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -367,13 +526,21 @@ function ChatInputInner({ }) if (!chosen) return onWorkDirChange?.(chosen) + await rememberWorkDir(chosen) setCodingAgent(await loadCodingAgentFor(chosen)) toast.success(`Work directory set: ${chosen}`) } catch (err) { console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir, onWorkDirChange, loadCodingAgentFor]) + }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) + + const handleSelectRecentWorkDir = useCallback(async (dir: string) => { + onWorkDirChange?.(dir) + await rememberWorkDir(dir) + setCodingAgent(await loadCodingAgentFor(dir)) + toast.success(`Work directory set: ${dir}`) + }, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { onWorkDirChange?.(null) @@ -482,13 +649,13 @@ function ChatInputInner({ if (!canSubmit) return // codeMode is sticky per conversation — don't reset after send. const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -527,6 +694,12 @@ function ChatInputInner({ } }, [addFiles, isActive]) + const visibleRecentWorkDirs = recentWorkDirs + .filter((entry) => entry.path !== workDir) + .slice(0, MAX_VISIBLE_RECENT_WORK_DIRS) + const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set' + const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : '' + return (
{attachments.length > 0 && ( @@ -631,7 +804,8 @@ function ChatInputInner({ className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" />
-
+
+
@@ -645,39 +819,123 @@ function ChatInputInner({ - Add files or set work directory + + {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + - - fileInputRef.current?.click()}> - - Add files or photos - - { void handleSetWorkDir() }}> - - {workDir ? 'Change work directory' : 'Set work directory'} - + +
+ fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5"> + + Add files or photos + + + {/* Working directory lives behind a submenu so the main menu stays to two + items. One hover/click away for power users; out of the way otherwise. */} + + + + + Set working directory + + {currentWorkDirLabel} + + + + + {/* Current selection — shown for context only when one is set. */} + {workDir && ( +
+ + + {currentWorkDirLabel} + + {currentWorkDirPath} + + +
+ )} + + {/* Primary action: choose when unset, change when set. Always on top. */} + { void handleSetWorkDir() }} + className="h-9 rounded-[9px] px-2.5" + > + + {workDir ? 'Change folder…' : 'Choose a folder…'} + + + {visibleRecentWorkDirs.length > 0 && ( + <> +
+ Recent +
+ {visibleRecentWorkDirs.map((entry) => { + const name = basename(entry.path) || entry.path + const when = formatRecentWorkDirTime(entry.lastUsedAt) + return ( + { void handleSelectRecentWorkDir(entry.path) }} + className="h-8 rounded-[9px] px-2.5" + > + + {name} + {when && {when}} + + ) + })} + + )} + + {/* Clear — only meaningful once a directory is set. Kept at the bottom. */} + {workDir && ( + <> +
+ + + Clear folder + + + )} + + +
- {workDir && ( + {workDir && collapseLevel < 8 && ( -
+ {/* Level 4: collapse to a square icon */} +
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" + )}> - + {collapseLevel < 4 && ( + + )}
@@ -685,7 +943,7 @@ function ChatInputInner({ )} - {searchAvailable && ( + {searchAvailable && collapseLevel < 7 && ( )} - {codeModeFeatureEnabled && (codeModeEnabled ? ( -
+ {collapseLevel < 6 && ( + + + + + + {runId + ? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}` + : permissionMode === 'auto' + ? 'Auto-permission on — click for manual approval prompts' + : 'Manual approval prompts — click for auto-permission'} + + + )} + {codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? ( + collapseLevel >= 1 ? ( + /* Level 1: collapse the pill to a single icon */ - Code mode on — click to disable + Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable - · - - - - - - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap - - -
+ ) : ( +
+ + + + + Code mode on — click to disable + + · + + + + + + Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + + +
+ ) ) : ( @@ -755,25 +1059,89 @@ function ChatInputInner({ Use a coding agent (Claude Code or Codex) ))} +
+ {collapseLevel >= 5 && ( + + + + + + + + More options + + + {workDir && collapseLevel >= 8 && ( + { void handleSetWorkDir() }}> + + {basename(workDir) || workDir} + + )} + {searchAvailable && collapseLevel >= 7 && ( + e.preventDefault()} + onCheckedChange={(c) => setSearchEnabled(Boolean(c))} + > + Web search + + )} + {collapseLevel >= 6 && ( + e.preventDefault()} + onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')} + > + Auto-approve actions + + )} + {codeModeFeatureEnabled && collapseLevel >= 5 && ( + <> + e.preventDefault()} + onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} + > + Code mode + + {codeModeEnabled && ( + { e.preventDefault(); handleToggleCodingAgent() }}> + + Coding agent + {codingAgent === 'claude' ? 'Claude' : 'Codex'} + + )} + + )} + + + )}
{lockedModel ? ( - {getSelectedModelDisplayName(lockedModel.model)} + {getSelectedModelDisplayName(lockedModel.model)} ) : configuredModels.length > 0 ? ( @@ -915,7 +1283,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 7987e6dd..6300f4cc 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -36,10 +37,11 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' +import type { ChatPaneSize } from '@/contexts/theme-context' import { type ChatViewportAnchorState, type ChatTabViewState, @@ -124,6 +126,9 @@ interface ChatSidebarProps { defaultWidth?: number isOpen?: boolean isMaximized?: boolean + placement?: 'middle' | 'right' + paneSize?: ChatPaneSize + className?: string chatTabs: ChatTab[] activeChatTabId: string getChatTabTitle: (tab: ChatTab) => string @@ -139,7 +144,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -154,6 +159,7 @@ interface ChatSidebarProps { pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] + autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions'] onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean @@ -181,6 +187,9 @@ export function ChatSidebar({ defaultWidth = DEFAULT_WIDTH, isOpen = true, isMaximized = false, + placement = 'right', + paneSize = 'chat-smaller', + className, chatTabs, activeChatTabId, getChatTabTitle, @@ -211,6 +220,7 @@ export function ChatSidebar({ pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), + autoPermissionDecisions = new Map(), onPermissionResponse, onAskHumanResponse, isToolOpenForTab, @@ -243,6 +253,8 @@ export function ChatSidebar({ const startWidthRef = useRef(0) const prevIsMaximizedRef = useRef(isMaximized) const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized + const isMiddlePlacement = placement === 'middle' + const isResizable = paneSize === 'chat-smaller' const getMaxAllowedWidth = useCallback(() => { if (typeof window === 'undefined') return MAX_WIDTH @@ -303,7 +315,9 @@ export function ChatSidebar({ setIsResizing(true) const handleMouseMove = (event: MouseEvent) => { - const delta = startXRef.current - event.clientX + const delta = isMiddlePlacement + ? event.clientX - startXRef.current + : startXRef.current - event.clientX const maxAllowedWidth = getMaxAllowedWidth() setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth)) } @@ -316,7 +330,7 @@ export function ChatSidebar({ document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) - }, [width, getMaxAllowedWidth]) + }, [width, getMaxAllowedWidth, isMiddlePlacement]) const activeTabState = useMemo(() => ({ runId: runId ?? null, @@ -325,6 +339,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, }), [ runId, conversation, @@ -332,6 +347,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) const emptyTabState = useMemo(() => createEmptyChatTabViewState(), []) const getTabState = useCallback((tabId: string): ChatTabViewState => { @@ -358,7 +374,11 @@ export function ChatSidebar({ } }, [activeRunId]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -451,6 +471,7 @@ export function ChatSidebar({ key={item.id} open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > @@ -491,8 +512,11 @@ export function ChatSidebar({ // not add extra width to the right and overflow the app viewport. return { width: 0, flex: '1 1 auto' } } + if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') { + return { width: 0, flex: '1 1 0' } + } return { width, flex: '0 0 auto' } - }, [isOpen, isMaximized, width]) + }, [isOpen, isMaximized, paneSize, width]) return (
- {!isMaximized && ( + {!isMaximized && isResizable && (
- {isMaximized ? : } + {isMaximized + ? (isMiddlePlacement ? : ) + : (isMiddlePlacement ? : )} {isMaximized ? 'Dock to side pane' : 'Expand chat'} @@ -626,7 +655,7 @@ export function ChatSidebar({ <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map((item) => { if (isToolGroup(item)) { return ( @@ -638,22 +667,43 @@ export function ChatSidebar({ /> ) } - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item) && onPermissionResponse) { + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) + if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || (permRequest && onPermissionResponse)) { const response = tabState.permissionResponses.get(item.id) || null return ( - onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> + {deniedAutoDecision && ( + + )} + {permRequest && onPermissionResponse && ( + onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} ) diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx new file mode 100644 index 00000000..4d5dd33b --- /dev/null +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -0,0 +1,253 @@ +import { useMemo, useState } from 'react' +import { + CheckCircle2, + Circle, + CircleDot, + Eye, + FileText, + Loader, + Pencil, + Search, + ShieldQuestion, + Terminal, + Trash2, + Wrench, +} from 'lucide-react' +import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js' +import { cn } from '@/lib/utils' +import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' +import { toToolState, type ToolCall } from '@/lib/chat-conversation' + +// ── Timeline reduction ────────────────────────────────────────────── +// The raw ACP stream is a flat list of events; collapse it into ordered rows, +// folding tool_call + tool_call_update (by id) and the latest plan in place. + +type TextRow = { kind: 'text'; id: string; text: string } +type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] } +type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] } +type PermRow = { kind: 'perm'; id: string; title: string; decision: string } +type Row = TextRow | ToolRow | PlanRow | PermRow + +function reduceEvents(events: CodeRunEvent[]): Row[] { + const rows: Row[] = [] + const toolIdx = new Map() + let planIdx = -1 + + events.forEach((e, i) => { + switch (e.type) { + case 'message': { + if (e.role !== 'agent' || !e.text) return + const last = rows[rows.length - 1] + if (last && last.kind === 'text') last.text += e.text + else rows.push({ kind: 'text', id: `t${i}`, text: e.text }) + break + } + case 'tool_call': { + const id = e.id ?? `tc${i}` + const at = toolIdx.get(id) + if (at != null) { + const r = rows[at] as ToolRow + r.title = e.title ?? r.title + r.toolKind = e.kind ?? r.toolKind + r.status = e.status ?? r.status + } else { + toolIdx.set(id, rows.length) + rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] }) + } + break + } + case 'tool_call_update': { + const id = e.id ?? `tu${i}` + let at = toolIdx.get(id) + if (at == null) { + at = rows.length + toolIdx.set(id, at) + rows.push({ kind: 'tool', id, diffs: [] }) + } + const r = rows[at] as ToolRow + if (e.status) r.status = e.status + for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d) + break + } + case 'plan': { + if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries + else { + planIdx = rows.length + rows.push({ kind: 'plan', id: 'plan', entries: e.entries }) + } + break + } + case 'permission': + rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision }) + break + default: + break + } + }) + return rows +} + +function toolKindIcon(kind?: string) { + switch (kind) { + case 'read': return + case 'edit': return + case 'delete': return + case 'search': return + case 'execute': return + case 'fetch': return + default: return + } +} + +function planMarker(status?: string) { + if (status === 'completed') return + if (status === 'in_progress') return + return +} + +const basename = (p: string) => p.split(/[\\/]/).pop() || p + +function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) { + const rows = useMemo(() => reduceEvents(events), [events]) + if (rows.length === 0) { + return
Starting the agent…
+ } + return ( +
+ {rows.map((row) => { + if (row.kind === 'text') { + return ( +

+ {row.text} +

+ ) + } + if (row.kind === 'tool') { + const running = row.status !== 'completed' && row.status !== 'failed' + return ( +
+
+ {running + ? + : } + {toolKindIcon(row.toolKind)} + {row.title ?? row.toolKind ?? 'Tool call'} +
+ {row.diffs.length > 0 && ( +
+ {row.diffs.map((d) => ( + + {basename(d)} + + ))} +
+ )} +
+ ) + } + if (row.kind === 'plan') { + return ( +
+ {row.entries.map((entry, idx) => ( +
+ {planMarker(entry.status)} + + {entry.content} + +
+ ))} +
+ ) + } + // resolved permission + const denied = row.decision === 'reject' || row.decision === 'cancelled' + return ( +
+ {denied ? '✕' : '✓'} + {denied ? 'Denied' : 'Allowed'}: {row.title} +
+ ) + })} +
+ ) +} + +// ── In-run permission card ────────────────────────────────────────── + +export function CodeRunPermissionRequest({ + ask, + onDecide, +}: { + ask: PermissionAsk + onDecide: (decision: PermissionDecision) => void +}) { + const [busy, setBusy] = useState(false) + const decide = (d: PermissionDecision) => { + if (busy) return + setBusy(true) + onDecide(d) + } + const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50' + return ( +
+
+ + Permission needed +
+

+ The agent wants to: {ask.title} +

+
+ + + +
+
+ ) +} + +// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ── + +const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } + +export function CodingRunBlock({ + item, + open, + onOpenChange, + onPermissionDecision, +}: { + item: ToolCall + open: boolean + onOpenChange: (open: boolean) => void + onPermissionDecision: (decision: PermissionDecision) => void +}) { + // Prefer the agent the backend actually ran (the chip) once the run returns; fall + // back to the requested input agent while it's still in flight. Never trust only the + // model's input — it can pass a stale agent the backend overrode with the chip. + const agent = + (item.result as { agent?: string } | undefined)?.agent ?? + (item.input as { agent?: string } | undefined)?.agent + const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent' + return ( + <> + + + + + + + {item.pendingCodePermission && ( + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx new file mode 100644 index 00000000..415ae4a0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx @@ -0,0 +1,196 @@ +import { Suspense, lazy, useEffect, useRef, useState } from 'react' +import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' +import type { DocxEditorRef } from '@eigenpal/docx-editor-react' + +// The editor (and its CSS) is heavy and only needed when a .docx is open, so it +// loads in its own chunk the first time a Word document is viewed. +const LazyDocxEditor = lazy(async () => { + const [mod] = await Promise.all([ + import('@eigenpal/docx-editor-react'), + import('@eigenpal/docx-editor-react/styles.css'), + ]) + return { default: mod.DocxEditor } +}) + +interface DocxFileViewerProps { + path: string +} + +type LoadState = 'loading' | 'ready' | 'error' +type SaveState = 'idle' | 'saving' | 'saved' | 'error' + +const SAVE_DEBOUNCE_MS = 800 +// onChange fires for the editor's own load-time normalization. Ignore changes +// until shortly after the document settles so opening a file never rewrites it. +const ARM_DELAY_MS = 500 + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64) + const len = binary.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +function baseName(path: string): string { + const segs = path.split('/') + return segs[segs.length - 1] || path +} + +export function DocxFileViewer({ path }: DocxFileViewerProps) { + const [loadState, setLoadState] = useState('loading') + const [buffer, setBuffer] = useState(null) + const [saveState, setSaveState] = useState('idle') + + const editorRef = useRef(null) + const saveTimerRef = useRef | null>(null) + const armTimerRef = useRef | null>(null) + const armedRef = useRef(false) + const dirtyRef = useRef(false) + const savingRef = useRef(false) + + // Load the .docx bytes whenever the path changes. + useEffect(() => { + let cancelled = false + setLoadState('loading') + setBuffer(null) + setSaveState('idle') + armedRef.current = false + dirtyRef.current = false + savingRef.current = false + + ;(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'base64' }) + if (cancelled) return + setBuffer(base64ToArrayBuffer(result.data)) + setLoadState('ready') + if (armTimerRef.current) clearTimeout(armTimerRef.current) + armTimerRef.current = setTimeout(() => { armedRef.current = true }, ARM_DELAY_MS) + } catch (err) { + console.error('Failed to load docx:', err) + if (!cancelled) setLoadState('error') + } + })() + + return () => { + cancelled = true + if (armTimerRef.current) clearTimeout(armTimerRef.current) + } + }, [path]) + + // Serialize the current document and write it back to disk. + const persist = async () => { + const editor = editorRef.current + if (!editor || savingRef.current) return + savingRef.current = true + dirtyRef.current = false + setSaveState('saving') + try { + const out = await editor.save() + if (out) { + await window.ipc.invoke('workspace:writeFile', { + path, + data: arrayBufferToBase64(out), + opts: { encoding: 'base64' }, + }) + } + setSaveState('saved') + } catch (err) { + console.error('Failed to save docx:', err) + dirtyRef.current = true + setSaveState('error') + } finally { + savingRef.current = false + // A change landed while we were saving — flush it. + if (dirtyRef.current) scheduleSave() + } + } + + const scheduleSave = () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { void persist() }, SAVE_DEBOUNCE_MS) + } + + const handleChange = () => { + if (!armedRef.current) return + dirtyRef.current = true + scheduleSave() + } + + // Flush a pending save when navigating away or unmounting. + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + if (dirtyRef.current) void persist() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) + + if (loadState === 'error') { + return ( +
+ +

Cannot open this document

+

The file may be corrupted or not a valid Word document.

+ +
+ ) + } + + if (loadState === 'loading' || !buffer) { + return ( +
+ +

Loading document…

+
+ ) + } + + return ( +
+ + +

Loading editor…

+
+ } + > + { console.error('docx editor error:', err) }} + className="flex-1 min-h-0" + /> + + {saveState !== 'idle' && ( +
+ {saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index deed545c..dea0561e 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -69,6 +69,31 @@ function snippet(text?: string): string { return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) } +function isReplyQuoteBoundary(lines: string[], index: number): boolean { + const line = lines[index]?.trim() || '' + if (/^On\b.+\bwrote:\s*$/i.test(line)) return true + if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true + if (/^From:\s+\S/i.test(line)) { + const next = lines.slice(index + 1, index + 6).map((value) => value.trim()) + return next.some((value) => /^(Sent|Date):\s+\S/i.test(value)) + && next.some((value) => /^To:\s+\S/i.test(value)) + && next.some((value) => /^Subject:\s+\S/i.test(value)) + } + return false +} + +function stripQuotedReplyText(text: string): string { + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') + const boundary = lines.findIndex((line, index) => { + if (isReplyQuoteBoundary(lines, index)) return true + return index > 0 + && line.trim().startsWith('>') + && (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>')) + }) + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines + return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() +} + function getInitial(from?: string): string { return (extractName(from)[0] || '?').toUpperCase() } @@ -692,7 +717,7 @@ function ComposeBox({ const initialContent = useMemo(() => { if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. - const source = thread.gmail_draft || thread.draft_response + const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '') if (!source) return '' return source .split(/\n{2,}/) @@ -1048,8 +1073,7 @@ function ThreadDetail({ const MAX_KEPT_OPEN = 5 const PAGE_SIZE = 25 -const SECTIONS = ['important', 'other'] as const -type InboxSection = (typeof SECTIONS)[number] +type InboxSection = 'important' | 'other' interface SectionState { threads: GmailThread[] diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 37e0a930..c45ed64e 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react" import { Dialog, @@ -25,6 +25,7 @@ import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" +import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help" @@ -210,7 +211,7 @@ function ThemeOption({ } function AppearanceSettings() { - const { theme, setTheme } = useTheme() + const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme() return (
@@ -240,6 +241,50 @@ function AppearanceSettings() { />
+
+

Chat

+

+ Choose where chat sits when another pane is open +

+
+ setChatPanePlacement("right")} + /> + setChatPanePlacement("middle")} + /> +
+

Chat size

+

+ Choose how much width chat gets when another pane is open +

+
+ setChatPaneSize("chat-smaller")} + /> + setChatPaneSize("chat-equal")} + /> + setChatPaneSize("chat-bigger")} + /> +
+
) } @@ -277,17 +322,27 @@ const defaultBaseURLs: Partial> = { "openai-compatible": "http://localhost:1234/v1", } +type ProviderModelConfig = { + apiKey: string + baseURL: string + models: string[] + knowledgeGraphModel: string + meetingNotesModel: string + liveNoteAgentModel: string + autoPermissionDecisionModel: string +} + function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -313,7 +368,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -388,6 +443,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: e.knowledgeGraphModel || "", meetingNotesModel: e.meetingNotesModel || "", liveNoteAgentModel: e.liveNoteAgentModel || "", + autoPermissionDecisionModel: e.autoPermissionDecisionModel || "", }; } } @@ -406,6 +462,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: parsed.knowledgeGraphModel || "", meetingNotesModel: parsed.meetingNotesModel || "", liveNoteAgentModel: parsed.liveNoteAgentModel || "", + autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "", }; } return next; @@ -481,6 +538,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -515,6 +573,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, meetingNotesModel: config.meetingNotesModel.trim() || undefined, liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -546,6 +605,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined + parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -553,7 +613,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -811,6 +871,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ + {/* Auto-permission model */} +
+ Auto-permission model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { autoPermissionDecisionModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} @@ -1712,6 +1806,7 @@ function AgentStatusRow({ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { const [enabled, setEnabled] = useState(false) + const [approvalPolicy, setApprovalPolicy] = useState('ask') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [status, setStatus] = useState(null) @@ -1736,7 +1831,10 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setLoading(true) try { const result = await window.ipc.invoke("codeMode:getConfig", null) - if (!cancelled) setEnabled(result.enabled) + if (!cancelled) { + setEnabled(result.enabled) + setApprovalPolicy(result.approvalPolicy ?? 'ask') + } } catch { if (!cancelled) setEnabled(false) } finally { @@ -1752,7 +1850,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setSaving(true) setEnabled(next) try { - await window.ipc.invoke("codeMode:setConfig", { enabled: next }) + await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy }) window.dispatchEvent(new Event("code-mode-config-changed")) toast.success(next ? "Code mode enabled" : "Code mode disabled") } catch { @@ -1761,7 +1859,22 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { } finally { setSaving(false) } - }, []) + }, [approvalPolicy]) + + const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => { + const prev = approvalPolicy + setSaving(true) + setApprovalPolicy(next) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next }) + window.dispatchEvent(new Event("code-mode-config-changed")) + } catch { + setApprovalPolicy(prev) + toast.error("Failed to update approval policy") + } finally { + setSaving(false) + } + }, [enabled, approvalPolicy]) const anyReady = status?.claude.installed && status?.claude.signedIn || status?.codex.installed && status?.codex.signedIn @@ -1781,9 +1894,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {

Code mode lets the assistant delegate coding tasks to Claude Code or Codex running - on your machine. Pick the agent inline from the composer; the assistant calls it via - acpx - and streams results back into chat. + on your machine. Pick the agent inline from the composer; the assistant runs it on-device + and streams its work — tool calls, file diffs, and approvals — back into chat.

Requires an active Claude Code subscription or @@ -1833,6 +1945,35 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { />

+ {enabled && ( +
+
Approvals
+
+ How the coding agent checks in before changing files or running commands. You always see + everything it does in the timeline — this only controls the prompts. +
+ +
+ {approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'} + {approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'} + {approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'} +
+
+ )} + {enabled && status && !anyReady && (
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 0368b1da..f5870ec4 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -512,7 +512,7 @@ export function SidebarContentPanel({ const out: TreeNode[] = [] const walk = (nodes: TreeNode[]) => { for (const n of nodes) { - if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue + if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace' || n.path === 'knowledge/Agent Notes') continue if (n.kind === 'file') out.push(n) else if (n.children?.length) walk(n.children) } @@ -521,11 +521,11 @@ export function SidebarContentPanel({ return out .filter((n) => n.stat?.mtimeMs) .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) - .slice(0, 5) + .slice(0, 10) }, [tree]) // Recents: most recently touched notes / agents / chats, interleaved by - // recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 overall. + // recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall. type QuickAccessItem = { key: string label: string @@ -536,7 +536,7 @@ export function SidebarContentPanel({ const quickAccessItems = React.useMemo(() => { const items: QuickAccessItem[] = [] - for (const note of recentNotes.slice(0, 3)) { + for (const note of recentNotes.slice(0, 4)) { items.push({ key: `note:${note.path}`, label: displayNoteName(note), @@ -551,7 +551,7 @@ export function SidebarContentPanel({ const ms = ts ? new Date(ts).getTime() : 0 return Number.isFinite(ms) ? ms : 0 } - for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 2)) { + for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) { items.push({ key: `agent:${t.slug}`, label: t.name, @@ -565,7 +565,7 @@ export function SidebarContentPanel({ const ms = new Date(r.createdAt).getTime() return Number.isFinite(ms) ? ms : 0 } - for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 1)) { + for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) { items.push({ key: `chat:${r.id}`, label: r.title || '(Untitled chat)', @@ -575,7 +575,7 @@ export function SidebarContentPanel({ }) } - return items.sort((a, b) => b.recency - a.recency).slice(0, 5) + return items.sort((a, b) => b.recency - a.recency).slice(0, 12) }, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun]) // Workspace count for the Workspaces sublabel — top-level dir children of diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 6cbd1075..6923ac1c 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, + ExternalLink, File as FileIcon, FilePlus, Folder as FolderIcon, @@ -53,12 +54,18 @@ type WorkspaceActions = { remove: (path: string) => Promise copyPath: (path: string) => void revealInFileManager: (path: string, isDir: boolean) => void + createNote: (parentPath?: string) => void + createFolder: (parentPath?: string) => Promise + onOpenInNewTab?: (path: string) => void } type WorkspaceViewProps = { tree: TreeNode[] initialPath?: string | null actions: WorkspaceActions + // Folder currently being browsed. Controlled by the app so drill-down + // participates in the global back/forward history. + onNavigate: (path: string) => void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise } @@ -71,6 +78,12 @@ function getFileManagerName(): string { return 'File Manager' } +function fileExtensionLabel(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return 'File' + return `${name.slice(dot + 1).toUpperCase()} file` +} + function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null { if (!nodes) return null for (const node of nodes) { @@ -113,8 +126,8 @@ function readFileAsBase64(file: File): Promise { }) } -export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { - const [currentPath, setCurrentPath] = useState(initialPath || WORKSPACE_ROOT) +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { + const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) @@ -127,10 +140,6 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate const filesInputRef = useRef(null) const folderInputRef = useRef(null) - useEffect(() => { - if (initialPath) setCurrentPath(initialPath) - }, [initialPath]) - const isRoot = currentPath === WORKSPACE_ROOT const fileManagerName = getFileManagerName() @@ -160,12 +169,12 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate (item: TreeNode) => { if (renameTarget) return if (item.kind === 'dir') { - setCurrentPath(item.path) + onNavigate(item.path) } else { onOpenNote(item.path) } }, - [onOpenNote, renameTarget], + [onNavigate, onOpenNote, renameTarget], ) const beginRename = useCallback((item: TreeNode) => { @@ -295,7 +304,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
- {isRoot ? ( - - ) : ( - - - - - - filesInputRef.current?.click()}> - - Add files… - - folderInputRef.current?.click()}> - - Add folder… - - - - )} + {isRoot ? ( + + ) : ( + + + + + + filesInputRef.current?.click()}> + + Add files… + + folderInputRef.current?.click()}> + + Add folder… + + + + )} +
{item.name} )} - {item.kind === 'dir' && !isRenaming && ( -
- {childCount} {childCount === 1 ? 'item' : 'items'} + {!isRenaming && ( +
+ {item.kind === 'dir' + ? `${childCount} ${childCount === 1 ? 'item' : 'items'}` + : fileExtensionLabel(item.name)}
)}
) + const isDir = item.kind === 'dir' return ( {card} - - beginRename(item)}> - - Rename - + e.preventDefault()}> + {isDir && ( + <> + actions.createNote(item.path)}> + + New Note + + void actions.createFolder(item.path)}> + + New Folder + + + + )} + {!isDir && actions.onOpenInNewTab && ( + <> + actions.onOpenInNewTab!(item.path)}> + + Open in new tab + + + + )} { actions.copyPath(item.path); toast('Path copied', 'success') }}> Copy Path - actions.revealInFileManager(item.path, item.kind === 'dir')}> + actions.revealInFileManager(item.path, isDir)}> - Show in {fileManagerName} + Open in {fileManagerName} + beginRename(item)}> + + Rename + void handleDelete(item)}> Delete diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx index 1149cb42..04df59e7 100644 --- a/apps/x/apps/renderer/src/contexts/theme-context.tsx +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -3,16 +3,32 @@ import * as React from "react" export type Theme = "light" | "dark" | "system" +export type ChatPanePlacement = "right" | "middle" +export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger" type ThemeContextProps = { theme: Theme resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void + chatPanePlacement: ChatPanePlacement + setChatPanePlacement: (placement: ChatPanePlacement) => void + chatPaneSize: ChatPaneSize + setChatPaneSize: (size: ChatPaneSize) => void } const ThemeContext = React.createContext(null) const STORAGE_KEY = "rowboat-theme" +const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement" +const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size" + +function isChatPanePlacement(value: string | null): value is ChatPanePlacement { + return value === "right" || value === "middle" +} + +function isChatPaneSize(value: string | null): value is ChatPaneSize { + return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger" +} function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light" @@ -39,6 +55,16 @@ export function ThemeProvider({ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null return stored || defaultTheme }) + const [chatPanePlacement, setChatPanePlacementState] = React.useState(() => { + if (typeof window === "undefined") return "right" + const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY) + return isChatPanePlacement(stored) ? stored : "right" + }) + const [chatPaneSize, setChatPaneSizeState] = React.useState(() => { + if (typeof window === "undefined") return "chat-smaller" + const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY) + return isChatPaneSize(stored) ? stored : "chat-smaller" + }) const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { if (theme === "system") return getSystemTheme() @@ -76,13 +102,27 @@ export function ThemeProvider({ setThemeState(newTheme) }, []) + const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => { + localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement) + setChatPanePlacementState(placement) + }, []) + + const setChatPaneSize = React.useCallback((size: ChatPaneSize) => { + localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size) + setChatPaneSizeState(size) + }, []) + const contextValue = React.useMemo( () => ({ theme, resolvedTheme, setTheme, + chatPanePlacement, + setChatPanePlacement, + chatPaneSize, + setChatPaneSize, }), - [theme, resolvedTheme, setTheme] + [theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize] ) return ( diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts index 82220782..5bc5cec0 100644 --- a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import posthog from 'posthog-js' +import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics' /** * Identifies the user in PostHog when signed into Rowboat, @@ -17,7 +18,7 @@ export function useAnalyticsIdentity() { // Identify if Rowboat account is connected const rowboat = config.rowboat if (rowboat?.connected && rowboat?.userId) { - posthog.identify(rowboat.userId) + identifyUser(rowboat.userId) } // Set provider connection flags @@ -69,7 +70,7 @@ export function useAnalyticsIdentity() { // Rowboat sign-in if (event.success) { if (event.userId) { - posthog.identify(event.userId) + identifyUser(event.userId) } posthog.people.set({ signed_in: true, rowboat_connected: true }) posthog.capture('user_signed_in') @@ -80,7 +81,7 @@ export function useAnalyticsIdentity() { // future events on this device don't get attributed to the prior user. posthog.people.set({ signed_in: false, rowboat_connected: false }) posthog.capture('user_signed_out') - posthog.reset() + resetAnalyticsIdentity() }) return cleanup diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 672ea0c3..de837bab 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -1,5 +1,42 @@ import posthog from 'posthog-js' +let appVersion: string | undefined +let apiUrl: string | undefined + +function appVersionProperties(): Record { + return appVersion ? { app_version: appVersion } : {} +} + +export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) { + appVersion = props.appVersion?.trim() || undefined + apiUrl = props.apiUrl?.trim() || undefined + + const eventProperties = appVersionProperties() + if (Object.keys(eventProperties).length > 0) { + posthog.register(eventProperties) + } + + const personProperties = { + ...(apiUrl ? { api_url: apiUrl } : {}), + ...eventProperties, + } + if (Object.keys(personProperties).length > 0) { + posthog.people.set(personProperties) + } +} + +export function identifyUser(userId: string, properties?: Record) { + posthog.identify(userId, { + ...properties, + ...appVersionProperties(), + }) +} + +export function resetAnalyticsIdentity() { + posthog.reset() + configureAnalyticsContext({ appVersion, apiUrl }) +} + export function chatSessionCreated(runId: string) { posthog.capture('chat_session_created', { run_id: runId }) } diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index bbf1cde2..5fb28574 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -1,7 +1,8 @@ import type { ToolUIPart } from 'ai' import z from 'zod' -import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' +import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js' export interface MessageAttachment { path: string @@ -27,6 +28,9 @@ export interface ToolCall { streamingOutput?: string status: 'pending' | 'running' | 'completed' | 'error' timestamp: number + // code_agent_run only: structured ACP stream items + the in-flight permission ask. + codeRunEvents?: CodeRunEvent[] + pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null } export interface ErrorMessage { @@ -46,6 +50,7 @@ export type ChatTabViewState = { pendingAskHumanRequests: Map> allPermissionRequests: Map> permissionResponses: Map + autoPermissionDecisions: Map> } export type ChatViewportAnchorState = { @@ -60,6 +65,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({ pendingAskHumanRequests: new Map(), allPermissionRequests: new Map(), permissionResponses: new Map(), + autoPermissionDecisions: new Map(), }) export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' @@ -517,41 +523,9 @@ const TOOL_DISPLAY_NAMES: Record = { * For builtin tools, returns a static friendly name (e.g., "Reading file"). * Falls back to the raw tool name if no mapping exists. */ -// Phrases shown while a code-mode task is running. They advance over time (5s -// each) to read as progress, then hold on the last one until the task finishes. -const CODE_MODE_RUNNING_LABELS = [ - 'Working on the task…', - 'Inspecting the project…', - 'Digging into the code…', - 'Figuring it out…', - 'Making the changes…', - 'Wiring things up…', - 'Putting it together…', -] -const CODE_MODE_LABEL_INTERVAL_MS = 5000 - -// Detect acpx coding-agent invocations (code mode) and produce a status-aware -// label, e.g. "Working on the task…" → "Completed the task". -export const getCodeModeCommandLabel = (tool: ToolCall): string | null => { - if (tool.name !== 'executeCommand') return null - const input = normalizeToolInput(tool.input) as Record | undefined - const command = typeof input?.command === 'string' ? input.command : '' - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/) - if (!match) return null - if (tool.status === 'error') return `Couldn't complete the task` - if (tool.status === 'completed') return `Completed the task` - // Advance through the phrases from the tool's start, holding on the last. - const elapsed = Math.max(0, Date.now() - tool.timestamp) - const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS) - const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1) - return CODE_MODE_RUNNING_LABELS[idx] -} - export const getToolDisplayName = (tool: ToolCall): string => { const browserLabel = getBrowserControlLabel(tool) if (browserLabel) return browserLabel - const codeModeLabel = getCodeModeCommandLabel(tool) - if (codeModeLabel) return codeModeLabel const composioData = getComposioActionCardData(tool) if (composioData) return composioData.label return TOOL_DISPLAY_NAMES[tool.name] || tool.name @@ -632,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup => const isPlainToolCall = (item: ConversationItem): item is ToolCall => { if (!isToolCall(item)) return false + if (item.name === 'code_agent_run') return false // rich standalone block, never grouped if (getWebSearchCardData(item)) return false if (getComposioConnectCardData(item)) return false if (getAppActionCardData(item)) return false diff --git a/apps/x/apps/renderer/src/lib/file-types.ts b/apps/x/apps/renderer/src/lib/file-types.ts index d4477f7a..c293ac6f 100644 --- a/apps/x/apps/renderer/src/lib/file-types.ts +++ b/apps/x/apps/renderer/src/lib/file-types.ts @@ -6,7 +6,7 @@ * also uses it to decide what to keep mounted. */ -export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' +export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx' const VIEWER_BY_EXT: Record = { html: 'html', @@ -31,6 +31,7 @@ const VIEWER_BY_EXT: Record = { flac: 'audio', aac: 'audio', pdf: 'pdf', + docx: 'docx', } function extensionOf(path: string): string { diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index fedc029c..7999061d 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -2,9 +2,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' +import type { CaptureResult } from 'posthog-js' import { ThemeProvider } from '@/contexts/theme-context' +import { configureAnalyticsContext } from './lib/analytics' // Fetch the stable installation ID from main so renderer + main share one // PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID @@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context' async function bootstrap() { let installationId: string | undefined let apiUrl: string | undefined + let appVersion: string | undefined try { const result = await window.ipc.invoke('analytics:bootstrap', null) installationId = result.installationId apiUrl = result.apiUrl + appVersion = result.appVersion } catch (err) { console.error('[Analytics] Failed to bootstrap from main:', err) } + configureAnalyticsContext({ apiUrl, appVersion }) + const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: '2025-11-30', + defaults: '2025-11-30' as const, ...(installationId ? { bootstrap: { distinctID: installationId } } : {}), - } as const + before_send: (event: CaptureResult | null) => { + if (!event) return event + if (appVersion) { + event.properties = { + ...event.properties, + app_version: appVersion, + } + } + return event + }, + loaded: () => { + configureAnalyticsContext({ apiUrl, appVersion }) + }, + } createRoot(document.getElementById('root')!).render( @@ -36,11 +54,7 @@ async function bootstrap() { , ) - // Tag the active person record with api_url so anonymous users are also - // segmentable by environment. - if (apiUrl) { - posthog.people.set({ api_url: apiUrl }) - } + // The loaded callback applies api_url/app_version once PostHog has initialized. } bootstrap() diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index b552eab7..08c2644d 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -11,6 +11,9 @@ "test:watch": "vitest" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", + "@agentclientprotocol/sdk": "^0.22.1", "@ai-sdk/anthropic": "^2.0.63", "@ai-sdk/google": "^2.0.53", "@ai-sdk/openai": "^2.0.91", diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 84aa4092..6f563a07 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; +import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); @@ -901,6 +902,7 @@ export class AgentState { agentName: string | null = null; runModel: string | null = null; runProvider: string | null = null; + permissionMode: "manual" | "auto" = "manual"; runUseCase: UseCase | null = null; runSubUseCase: string | null = null; messages: z.infer = []; @@ -912,6 +914,8 @@ export class AgentState { pendingAskHumanRequests: Record> = {}; allowedToolCallIds: Record = {}; deniedToolCallIds: Record = {}; + autoAllowedToolCalls: Record = {}; + autoDeniedToolCalls: Record = {}; sessionAllowedCommands: Set = new Set(); sessionAllowedFileAccess: FileAccessGrant[] = []; @@ -1019,6 +1023,7 @@ export class AgentState { this.agentName = event.agentName; this.runModel = event.model; this.runProvider = event.provider; + this.permissionMode = event.permissionMode ?? "manual"; this.runUseCase = event.useCase ?? null; this.runSubUseCase = event.subUseCase ?? null; break; @@ -1031,6 +1036,7 @@ export class AgentState { this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].runModel = this.runModel; this.subflowStates[event.toolCallId].runProvider = this.runProvider; + this.subflowStates[event.toolCallId].permissionMode = this.permissionMode; this.subflowStates[event.toolCallId].runUseCase = this.runUseCase; this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase; break; @@ -1081,10 +1087,22 @@ export class AgentState { break; case "deny": this.deniedToolCallIds[event.toolCallId] = true; + delete this.autoDeniedToolCalls[event.toolCallId]; break; } delete this.pendingToolPermissionRequests[event.toolCallId]; break; + case "tool-permission-auto-decision": + switch (event.decision) { + case "allow": + this.allowedToolCallIds[event.toolCallId] = true; + this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + case "deny": + this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + } + break; case "ask-human-request": this.pendingAskHumanRequests[event.toolCallId] = event; break; @@ -1190,13 +1208,19 @@ export async function* streamAgent({ // if tool has been denied, deny if (state.deniedToolCallIds[toolCallId]) { _logger.log('returning denied tool message, reason: tool has been denied'); + const autoDenied = state.autoDeniedToolCalls[toolCallId]; yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { role: "tool", - content: "Unable to execute this tool: Permission was denied.", + content: autoDenied + ? JSON.stringify({ + success: false, + error: `Auto-permission denied: ${autoDenied.reason}`, + }) + : "Unable to execute this tool: Permission was denied.", toolCallId: toolCallId, toolName: toolCall.toolName, }, @@ -1255,6 +1279,7 @@ export async function* streamAgent({ signal, abortRegistry, publish: (event) => bus.publish(event), + codeMode, }); } } catch (error) { @@ -1402,44 +1427,19 @@ Do not announce the work directory unless it's relevant. Just use it.`; if (codeMode) { loopLogger.log('code mode enabled, injecting coding-agent context', codeMode); const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex'; - const otherAgent = codeMode === 'claude' ? 'codex' : 'claude'; - const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code'; - // Deterministic, per-chat session name so the coding agent keeps - // context across the user's requests within this chat. Reusing the - // same -s resumes the session; the first call creates it. - const sessionName = `rowboat-${runId}`; - instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay} -The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn. + instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay} +The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …"). -**The user can override the agent at any time, two ways:** -1. By toggling the chip in the composer (preferred). -2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness. +The chip is the single source of truth for which agent runs: +- Do NOT carry over a different agent from earlier in this thread — even if a previous run used the other agent, use **${agentDisplay}** now. +- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip. -**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create). +**How to run coding work — call the \`code_agent_run\` tool** with: +- \`agent\`: \`${codeMode}\` (always — match the chip). +- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once). +- \`prompt\`: a clear, self-contained coding instruction. -**1. First coding action in this chat — ensure the session exists:** - -\`\`\` -npx acpx@latest --approve-all --cwd sessions ensure --name ${sessionName} -\`\`\` - -(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s ${sessionName} "" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s ${sessionName} "" -\`\`\` - -Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch. - -Where \`\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets). +The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context — just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools. If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`; } @@ -1493,6 +1493,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated // if there were any ask-human calls, emit those events if (message.content instanceof Array) { + const permissionCandidates: AutoPermissionCandidate[] = []; for (const part of message.content) { if (part.type === "tool-call") { const underlyingTool = agent.tools![part.toolName]; @@ -1518,14 +1519,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated state.sessionAllowedFileAccess, ); if (permission) { - loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); - yield* processEvent({ - runId, - type: "tool-permission-request", - toolCall: part, - permission, - subflow: [], - }); + permissionCandidates.push({ toolCall: part, permission }); } if (underlyingTool.type === "agent" && underlyingTool.name) { loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); @@ -1549,6 +1543,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated } } } + + if (permissionCandidates.length > 0) { + if (state.permissionMode === "auto") { + let decisionsByToolCallId = new Map(); + try { + const decisions = await classifyToolPermissions({ + runId, + agentName: state.agentName, + messages: convertFromMessages(state.messages), + candidates: permissionCandidates, + useCase: state.runUseCase ?? "copilot_chat", + subUseCase: state.runSubUseCase, + }); + decisionsByToolCallId = new Map(decisions.map((decision) => [ + decision.toolCallId, + { decision: decision.decision, reason: decision.reason }, + ])); + } catch (error) { + loopLogger.log( + 'auto-permission classifier failed:', + error instanceof Error ? error.message : String(error), + ); + } + + for (const candidate of permissionCandidates) { + const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId); + if (!decision) { + loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + continue; + } + + loopLogger.log( + 'emitting tool-permission-auto-decision, toolCallId:', + candidate.toolCall.toolCallId, + 'decision:', + decision.decision, + ); + yield* processEvent({ + runId, + type: "tool-permission-auto-decision", + toolCallId: candidate.toolCall.toolCallId, + toolCall: candidate.toolCall, + permission: candidate.permission, + decision: decision.decision, + reason: decision.reason, + subflow: [], + }); + if (decision.decision === "deny") { + loopLogger.log( + 'auto-permission denied, falling back to prompt:', + candidate.toolCall.toolCallId, + ); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } else { + for (const candidate of permissionCandidates) { + loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } } } } diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 156194d9..d3d1e55c 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -6,6 +6,7 @@ import { API_URL } from '../config/env.js'; // In dev/tsc, fall back to process.env so local runs work too. const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''; const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim(); let client: PostHog | null = null; let initAttempted = false; @@ -29,7 +30,7 @@ function getClient(): PostHog | null { // distinguishes prod / staging / custom — meaning is assigned in PostHog). client.identify({ distinctId: getInstallationId(), - properties: { api_url: API_URL }, + properties: { api_url: API_URL, ...appVersionProperties() }, }); } catch (err) { console.error('[Analytics] Failed to init PostHog:', err); @@ -42,6 +43,10 @@ function activeDistinctId(): string { return identifiedUserId ?? getInstallationId(); } +function appVersionProperties(): Record { + return APP_VERSION ? { app_version: APP_VERSION } : {}; +} + export function capture(event: string, properties?: Record): void { const ph = getClient(); if (!ph) return; @@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record): vo ph.capture({ distinctId: activeDistinctId(), event, - properties, + properties: { + ...properties, + ...appVersionProperties(), + }, }); } catch (err) { console.error('[Analytics] capture failed:', err); @@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record): properties: { ...properties, api_url: API_URL, + ...appVersionProperties(), }, }); identifiedUserId = userId; diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts index d8e81a58..d9f15dd8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -5,6 +5,8 @@ Use this skill whenever the user asks you to write code, build a project, create Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself. +All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically. + --- ## STEP 1 — MANDATORY FIRST ACTION @@ -39,96 +41,52 @@ This is non-negotiable. The user gets clickable buttons. Free-text "which agent? --- -## STEP 2 — Resolve workdir, confirm, execute +## STEP 2 — Resolve workdir, then run **Resolve the workdir** (in this priority order): 1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`). 2. The path from a "# User Work Directory" block in your context. 3. Ask once in plain text: "Which folder should I work in?" -**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: +**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead. + +**State your intent in one line, then call the tool immediately — do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: > Using [Claude Code / Codex] to [task description] in \`[folder]\`. -…and then immediately make the \`executeCommand\` call in the same turn. - -**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context. - -Pick \`\` (\`claude\` or \`codex\`) by, in priority order: -- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it. -- The agent chosen in Step 1 / the "# Code Mode (Active)" block. - -Pick \`\` — **stable for this whole chat**: -- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-\`), use that exact name. -- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time. - -**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt: - -**1. First coding action in this chat — ensure the session exists:** +…and then immediately call: \`\`\` -npx acpx@latest --approve-all --cwd sessions ensure --name +code_agent_run({ + agent: "", + cwd: "", + prompt: "" +}) \`\`\` -(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s "" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd -s "" -\`\`\` - -**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins. - -Do NOT use \`exec\` — it is one-shot and forgets everything. - -Concrete example: - -\`\`\` -# First coding message in the chat — ensure the session, then prompt: -npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space." - -# Follow-up in the same chat — reuse the session, no create: -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings." -\`\`\` - -### Critical: flag order - -\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name \` and \`-s \` come AFTER the agent name: - -- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd -s ""\` -- ✗ Wrong: \`npx acpx@latest --approve-all -s "..."\` (will fail) - -### Writing good prompts for the agent - +**Writing good prompts for the agent:** - Be specific: file names, function signatures, expected behavior. - Mention constraints (language, framework, style). -- Expand short user requests into clear, actionable prompts. +- Expand short user requests into clear, actionable instructions. + +**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context. --- ## STEP 3 — Report results -After the command finishes: -- Pass through the coding agent's summary as-is. Do not rewrite. +After \`code_agent_run\` returns: +- Pass through the agent's \`summary\` as-is. Do not rewrite it. - Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.) -- Only add your own explanation if the command failed (non-zero exit): - - Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it). - - Exit code 4 / "No acpx session found" — the \`-s \` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd sessions ensure --name \`, then retry the prompt. (\`-s\` only resumes; it never creates.) - - "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status. +- Only add your own explanation if it failed: + - \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**. + - \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue. --- ## Once delegating: delegate fully -After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. +After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. ## Prerequisites (informational) 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 30ceea95..a06c153b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [ { id: "code-with-agents", title: "Code with Agents", - summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.", + summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.", content: codeWithAgentsSkill, }, { 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 9bfb4250..08e8334f 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,7 +1,6 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { existsSync, readFileSync } from "fs"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; @@ -16,6 +15,10 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; +import type { CodeModeManager } from "../../code-mode/acp/manager.js"; +import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js"; +import { ICodeModeConfigRepo } from "../../code-mode/repo.js"; +import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; // Inputs for the bg-task builtin tools. Reuse the canonical schema field // descriptions; only `triggers` gets a tighter contextual override (the @@ -90,69 +93,6 @@ const LLMPARSE_MIME_TYPES: Record = { '.tiff': 'image/tiff', }; -// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE -// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL). -// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe -// from the npm-shim layout and inject it via env so the bridge can spawn it. -function resolveClaudeExeOnWindows(): string | undefined { - // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global - // bin dirs. Electron's runtime PATH can omit these even when the user's shell - // includes them, which would otherwise leave us unable to find claude.exe and - // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). - const home = process.env.USERPROFILE ?? ''; - const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); - const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - const knownDirs = [ - appData && path.join(appData, 'npm'), - localAppData && path.join(localAppData, 'npm'), - appData && path.join(appData, 'pnpm'), - localAppData && path.join(localAppData, 'pnpm'), - home && path.join(home, '.volta', 'bin'), - path.join(programFiles, 'nodejs'), - ].filter(Boolean) as string[]; - - const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); - const seen = new Set(); - const candidates = [...pathDirs, ...knownDirs].filter((d) => { - const key = d.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - - for (const dir of candidates) { - // Direct npm-shim layout: \node_modules\@anthropic-ai\claude-code\bin\claude.exe - const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); - if (existsSync(exeFromLayout)) return exeFromLayout; - - // Otherwise parse the claude.cmd shim for the real exe path. - const cmdPath = path.join(dir, 'claude.cmd'); - if (!existsSync(cmdPath)) continue; - try { - const content = readFileSync(cmdPath, 'utf-8'); - const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); - if (absMatch && existsSync(absMatch[0])) return absMatch[0]; - const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); - if (relMatch) { - const resolved = path.join(dir, relMatch[1]); - if (existsSync(resolved)) return resolved; - } - } catch { - // ignore shim parse failures - } - } - return undefined; -} - -function envForCommand(command: string): NodeJS.ProcessEnv | undefined { - if (process.platform !== 'win32') return undefined; - if (!/\bacpx\b/.test(command)) return undefined; - if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined; - const exe = resolveClaudeExeOnWindows(); - if (!exe) return undefined; - return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe }; -} export const BuiltinTools: z.infer = { loadSkill: { @@ -814,14 +754,11 @@ export const BuiltinTools: z.infer = { // }; // } - const envOverride = envForCommand(command); - // Use abortable version when we have a signal if (ctx?.signal) { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, - env: envOverride, onData: (chunk: string) => { ctx.publish({ runId: ctx.runId, @@ -851,7 +788,7 @@ export const BuiltinTools: z.infer = { } // Fallback to original for backward compatibility - const result = await executeCommand(command, { cwd: workingDir, env: envOverride }); + const result = await executeCommand(command, { cwd: workingDir }); return { success: result.exitCode === 0, @@ -871,6 +808,104 @@ export const BuiltinTools: z.infer = { }, }, + code_agent_run: { + description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.', + inputSchema: z.object({ + agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'), + cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'), + prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'), + }), + execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => { + if (!ctx) { + return { success: false, message: 'code_agent_run requires run context (runId / streaming).' }; + } + // The composer chip is the source of truth for the agent. The model's `agent` + // argument is only a fallback for the ask-human flow (code mode not active, no + // chip set) — otherwise it can anchor on the thread's earlier agent and ignore a + // chip change. Honor the chip so switching it deterministically switches agents. + const effectiveAgent = ctx.codeMode ?? agent; + const manager = container.resolve('codeModeManager'); + const registry = container.resolve('codePermissionRegistry'); + + // Approval policy from settings; default to asking the user. + let policy: ApprovalPolicy = 'ask'; + try { + const cfg = await container.resolve('codeModeConfigRepo').getConfig(); + if (cfg.approvalPolicy) policy = cfg.approvalPolicy; + } catch { + // fall back to 'ask' + } + + // On stop, unblock any pending approval card so the broker stops waiting for + // an answer that will never come. The ACP cancel + force-kill backstop that + // actually ends the turn is handled inside manager.runPrompt via the signal + // we pass below. + const onAbort = () => registry.cancelRun(ctx.runId); + if (ctx.signal.aborted) onAbort(); + else ctx.signal.addEventListener('abort', onAbort, { once: true }); + + let finalText = ''; + const changedFiles = new Set(); + try { + const result = await manager.runPrompt({ + runId: ctx.runId, + agent: effectiveAgent, + cwd, + prompt, + policy, + signal: ctx.signal, + onEvent: (event) => { + if (event.type === 'message' && event.role === 'agent') finalText += event.text; + if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f); + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-event', + toolCallId: ctx.toolCallId, + event, + subflow: [], + }); + }, + ask: (permAsk) => registry.request(ctx.runId, (requestId) => { + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-permission-request', + toolCallId: ctx.toolCallId, + requestId, + ask: permAsk, + subflow: [], + }); + }), + }); + return { + success: result.stopReason === 'end_turn', + stopReason: result.stopReason, + // The agent that actually ran (the chip), so the UI can label the run + // authoritatively rather than trusting the model's `agent` argument. + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } catch (error) { + // A stop mid-run isn't a failure — report it as a clean cancellation. + if (ctx.signal.aborted) { + return { + success: false, + stopReason: 'cancelled', + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } + return { + success: false, + message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + ctx.signal.removeEventListener('abort', onAbort); + } + }, + }, + // ============================================================================ // Browser Skills (browser-use/browser-harness domain-skills cache) // ============================================================================ 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 6be3c0e6..357ce1a8 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -80,7 +80,7 @@ export async function executeCommand( cwd?: string; timeout?: number; // timeout in milliseconds maxBuffer?: number; // max buffer size in bytes - env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx) + env?: NodeJS.ProcessEnv; // override environment } ): Promise { try { 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 92e87fa6..b34f6ed4 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -14,6 +14,10 @@ export interface ToolContext { signal: AbortSignal; abortRegistry: IAbortRegistry; publish: (event: z.infer) => Promise; + // The composer code-mode chip for the message that triggered this turn. When set, + // it is the authoritative coding agent — code_agent_run uses it rather than the + // agent the model guessed, so switching the chip deterministically switches agents. + codeMode?: 'claude' | 'codex' | null; } async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: Record): Promise { diff --git a/apps/x/packages/core/src/background-tasks/agent.ts b/apps/x/packages/core/src/background-tasks/agent.ts index 3f3a2d47..853c1ef0 100644 --- a/apps/x/packages/core/src/background-tasks/agent.ts +++ b/apps/x/packages/core/src/background-tasks/agent.ts @@ -71,7 +71,9 @@ The workspace lives at \`${WorkDir}\`. export function buildBackgroundTaskAgent(): z.infer { const tools: Record> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts new file mode 100644 index 00000000..da06d8ea --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -0,0 +1,60 @@ +import { createRequire } from 'module'; +import * as path from 'path'; +import type { CodingAgent } from './types.js'; +import { resolveClaudeExecutable } from './claude-exec.js'; + +const require = createRequire(import.meta.url); + +// The ACP adapter npm package that exposes each coding agent as an ACP server. +const ADAPTER_PACKAGE: Record = { + claude: '@agentclientprotocol/claude-agent-acp', + codex: '@agentclientprotocol/codex-acp', +}; + +export interface AgentLaunchSpec { + /** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */ + command: string; + /** Args = [adapter entry script]. */ + args: string[]; + /** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */ + env: NodeJS.ProcessEnv; +} + +// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an +// absolute path so we can spawn it directly with `node `. createRequire lets +// us resolve workspace/pnpm-installed packages from this module's location. +function resolveAdapterEntry(pkg: string): string { + const pkgJsonPath = require.resolve(`${pkg}/package.json`); + const pkgDir = path.dirname(pkgJsonPath); + const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record }; + const bin = pkgJson.bin; + const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined; + if (!rel) { + throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`); + } + return path.join(pkgDir, rel); +} + +export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec { + const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]); + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Point the Claude adapter at the real claude executable. On Windows this is + // mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a + // PATH safety net for GUI launches. Resolver is a no-op when claude isn't found, + // leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire + // an equivalent when we add Codex support.) + if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) { + const exe = resolveClaudeExecutable(); + if (exe) env.CLAUDE_CODE_EXECUTABLE = exe; + } + + // We spawn the adapter with process.execPath. Inside Electron's main process + // that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make + // it behave as a plain Node runtime. (Harmless under a real node process, which + // ignores the var.) Without this the child never runs as node and the ACP stdio + // stream closes immediately ("ACP connection closed"). + env.ELECTRON_RUN_AS_NODE = '1'; + + return { command: process.execPath, args: [entry], env }; +} diff --git a/apps/x/packages/core/src/code-mode/acp/claude-exec.ts b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts new file mode 100644 index 00000000..ae6c9c51 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts @@ -0,0 +1,91 @@ +import { execSync } from 'child_process'; +import * as path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { commonInstallPaths } from '../status.js'; + +// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL), +// and the Claude ACP adapter spawns its executable directly. So we pre-resolve +// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below. +export function resolveClaudeExeOnWindows(): string | undefined { + // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global + // bin dirs. Electron's runtime PATH can omit these even when the user's shell + // includes them, which would otherwise leave us unable to find claude.exe and + // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). + const home = process.env.USERPROFILE ?? ''; + const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); + const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const knownDirs = [ + appData && path.join(appData, 'npm'), + localAppData && path.join(localAppData, 'npm'), + appData && path.join(appData, 'pnpm'), + localAppData && path.join(localAppData, 'pnpm'), + home && path.join(home, '.volta', 'bin'), + path.join(programFiles, 'nodejs'), + ].filter(Boolean) as string[]; + + const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); + const seen = new Set(); + const candidates = [...pathDirs, ...knownDirs].filter((d) => { + const key = d.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + for (const dir of candidates) { + // Direct npm-shim layout: \node_modules\@anthropic-ai\claude-code\bin\claude.exe + const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); + if (existsSync(exeFromLayout)) return exeFromLayout; + + // Otherwise parse the claude.cmd shim for the real exe path. + const cmdPath = path.join(dir, 'claude.cmd'); + if (!existsSync(cmdPath)) continue; + try { + const content = readFileSync(cmdPath, 'utf-8'); + const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); + if (absMatch && existsSync(absMatch[0])) return absMatch[0]; + const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); + if (relMatch) { + const resolved = path.join(dir, relMatch[1]); + if (existsSync(resolved)) return resolved; + } + } catch { + // ignore shim parse failures + } + } + return undefined; +} + +// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn +// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched +// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the +// spawned adapter may fail to find `claude`. We resolve the path here so the adapter +// can be pointed straight at it. +function resolveClaudeBinaryUnix(): string | undefined { + // Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …). + try { + const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim(); + if (out && existsSync(out)) return out; + } catch { + // not found on the login-shell PATH + } + // Fallback: scan well-known install locations directly. + for (const candidate of commonInstallPaths('claude')) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +let cached: string | undefined; + +// Cross-platform: the real `claude` executable to hand the ACP adapter via +// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns +// undefined if it can't be found — callers then fall back to the adapter's own lookup. +// Cached on first success so we don't re-probe the shell on every cold start. +export function resolveClaudeExecutable(): string | undefined { + if (cached) return cached; + const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix(); + if (resolved) cached = resolved; + return resolved; +} diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts new file mode 100644 index 00000000..5c2bd1ba --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -0,0 +1,219 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { Writable, Readable } from 'node:stream'; +import fs from 'fs/promises'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type SessionUpdate, + type PromptResponse, + type ReadTextFileRequest, + type ReadTextFileResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { CodingAgent, CodeRunEvent } from './types.js'; +import type { PermissionBroker } from './permission-broker.js'; +import { getAgentLaunchSpec } from './agents.js'; + +export interface AcpClientOptions { + agent: CodingAgent; + cwd: string; + broker: PermissionBroker; + onEvent: (event: CodeRunEvent) => void; +} + +// Map a raw ACP session/update notification onto our small CodeRunEvent union. +function toEvent(update: SessionUpdate): CodeRunEvent { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'user_message_chunk': { + const c = update.content; + const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent'; + return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` }; + } + case 'agent_thought_chunk': + return { type: 'thought' }; + case 'tool_call': + return { + type: 'tool_call', + id: update.toolCallId, + title: update.title, + kind: update.kind ?? undefined, + status: update.status ?? undefined, + }; + case 'tool_call_update': { + const diffs = (update.content ?? []) + .filter((c): c is Extract => c.type === 'diff') + .map((c) => c.path); + return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs }; + } + case 'plan': + return { + type: 'plan', + entries: (update.entries ?? []).map((e) => ({ + content: e.content, + status: e.status ?? undefined, + priority: e.priority ?? undefined, + })), + }; + default: + return { type: 'other', sessionUpdate: update.sessionUpdate }; + } +} + +// Owns one spawned adapter process + ACP connection. Stateless about sessions — +// the manager decides whether to newSession or loadSession. +// +// The connection is long-lived and reused across follow-up prompts, but each prompt +// may stream to a different message's UI, so broker + onEvent are swappable via +// setHandlers() rather than fixed at construction. +export class AcpClient { + readonly agent: CodingAgent; + readonly cwd: string; + private broker: PermissionBroker; + private onEvent: (event: CodeRunEvent) => void; + private child?: ChildProcess; + private connection?: ClientSideConnection; + private loadSession_ = false; + // Diagnostics: the adapter's stderr/exit are captured so a dropped connection + // reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed". + private stderrTail = ''; + private exitInfo: string | null = null; + + constructor(opts: AcpClientOptions) { + this.agent = opts.agent; + this.cwd = opts.cwd; + this.broker = opts.broker; + this.onEvent = opts.onEvent; + } + + get loadSupported(): boolean { + return this.loadSession_; + } + + // Re-point the live connection at a new prompt's broker / event sink. + setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void { + this.broker = broker; + this.onEvent = onEvent; + } + + // Spawn the adapter and negotiate the protocol. Returns once initialized. + async start(): Promise { + const spec = getAgentLaunchSpec(this.agent); + const child = spawn(spec.command, spec.args, { + cwd: this.cwd, + env: spec.env, + // Capture stderr (not inherit) so we can attribute a dropped connection. + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.child = child; + child.stderr?.on('data', (d: Buffer) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-4000); + }); + child.on('exit', (code, signal) => { + this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`; + }); + child.on('error', (err) => { + this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin!) as WritableStream, + Readable.toWeb(child.stdout!) as ReadableStream, + ); + const client = this.buildClient(); + this.connection = new ClientSideConnection(() => client, stream); + + try { + const init = await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + this.loadSession_ = init.agentCapabilities?.loadSession === true; + } catch (e) { + throw this.enrich(e, 'initialize'); + } + } + + async newSession(): Promise { + try { + const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] }); + return res.sessionId; + } catch (e) { + throw this.enrich(e, 'newSession'); + } + } + + async loadSession(sessionId: string): Promise { + try { + await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }); + } catch (e) { + throw this.enrich(e, 'loadSession'); + } + } + + async prompt(sessionId: string, text: string): Promise { + try { + return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); + } catch (e) { + throw this.enrich(e, 'prompt'); + } + } + + // Wrap a connection error with the adapter's exit/stderr so failures are + // self-explanatory rather than the SDK's opaque "ACP connection closed". + private enrich(err: unknown, phase: string): Error { + const base = err instanceof Error ? err.message : String(err); + const parts = [ + this.exitInfo, + this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '', + ].filter(Boolean); + return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`); + } + + async cancel(sessionId: string): Promise { + await this.conn().cancel({ sessionId }); + } + + dispose(): void { + try { + this.child?.kill(); + } catch { + // already gone + } + this.child = undefined; + this.connection = undefined; + } + + private conn(): ClientSideConnection { + if (!this.connection) throw new Error('AcpClient not started'); + return this.connection; + } + + // The client side of ACP: the agent calls these on us. These read the CURRENT + // handlers off `self` so follow-up prompts can swap them via setHandlers(). + private buildClient(): Client { + const self = this; + return { + async requestPermission(params: RequestPermissionRequest): Promise { + return self.broker.resolve(params); + }, + async sessionUpdate(params: SessionNotification): Promise { + self.onEvent(toEvent(params.update)); + }, + async readTextFile(params: ReadTextFileRequest): Promise { + const content = await fs.readFile(params.path, 'utf8'); + return { content }; + }, + async writeTextFile(params: WriteTextFileRequest): Promise { + await fs.writeFile(params.path, params.content); + return {}; + }, + }; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/manager.ts b/apps/x/packages/core/src/code-mode/acp/manager.ts new file mode 100644 index 00000000..04ccdebd --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -0,0 +1,186 @@ +import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js'; +import { AcpClient } from './client.js'; +import { PermissionBroker } from './permission-broker.js'; +import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js'; + +export interface RunPromptArgs { + runId: string; + agent: CodingAgent; + cwd: string; + prompt: string; + policy: ApprovalPolicy; + /** Called when the policy needs the user to decide (the "ask" path). */ + ask: (ask: PermissionAsk) => Promise; + /** Stream sink for this prompt's run. */ + onEvent: (event: CodeRunEvent) => void; + /** Aborts the turn on stop; the manager cancels then force-kills the adapter. */ + signal?: AbortSignal; +} + +interface ActiveRun { + client: AcpClient; + sessionId: string; + agent: CodingAgent; + cwd: string; + // Prompts currently streaming on this connection. Disposal is deferred while + // this is > 0 so we never tear down a connection mid-turn. + inflight: number; + // Pending grace-window teardown, cleared if the run is reused before it fires. + disposeTimer?: ReturnType; +} + +// How long a connection stays warm after its last turn ends before we tear it down. +// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so +// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user +// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict +// per-turn teardown. Context is never lost either way: the next turn resumes the +// persisted session via session/load. +const DISPOSE_GRACE_MS = 60_000; + +// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before +// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores +// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely. +const CANCEL_GRACE_MS = 2_000; + +// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn +// (one code_agent_run): it is torn down a short grace window after the turn ends, so +// idle chats hold no adapter processes. Turns that land within the grace window reuse +// the warm connection; anything colder (grace elapsed, or after an app restart) +// resumes the persisted session via session/load. +export class CodeModeManager { + private readonly runs = new Map(); + + async runPrompt(args: RunPromptArgs): Promise { + const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args; + + const broker = new PermissionBroker({ + policy, + ask, + onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }), + }); + + const run = await this.ensureRun(runId, agent, cwd, broker, onEvent); + run.inflight++; + + let graceTimer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const promptP = run.client.prompt(run.sessionId, prompt); + // We may stop awaiting this prompt below (force-kill on stop rejects it); + // attach a no-op catch so the orphaned rejection isn't flagged. + promptP.catch(() => {}); + + // Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound + // within the grace, force-kill it and resolve as cancelled. This guarantees + // the turn ends even if the adapter ignores cancel or is wedged — a hung + // prompt would otherwise lock the chat (no run-stopped, composer disabled). + const cancelledP = new Promise<{ stopReason: string }>((resolve) => { + if (!signal) return; + onAbort = () => { + run.client.cancel(run.sessionId).catch(() => {}); + graceTimer = setTimeout(() => { + this.dispose(runId); + resolve({ stopReason: 'cancelled' }); + }, CANCEL_GRACE_MS); + graceTimer.unref?.(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + + const res = await Promise.race([promptP, cancelledP]); + return { stopReason: res.stopReason, sessionId: run.sessionId }; + } catch (e) { + // A kill-induced "connection closed" during a stop is an expected cancel. + if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId }; + throw e; + } finally { + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + if (graceTimer) clearTimeout(graceTimer); + run.inflight--; + this.scheduleDispose(runId); + } + } + + dispose(runId: string): void { + const run = this.runs.get(runId); + if (!run) return; + this.cancelDispose(run); + run.client.dispose(); + this.runs.delete(runId); + } + + // Tear down the connection a grace window after its last turn ends. Skipped while a + // prompt is still streaming, and re-armed when each turn ends so the window measures + // idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn). + private scheduleDispose(runId: string): void { + const run = this.runs.get(runId); + if (!run || run.inflight > 0) return; + this.cancelDispose(run); + if (DISPOSE_GRACE_MS <= 0) { + this.dispose(runId); + return; + } + run.disposeTimer = setTimeout(() => { + const r = this.runs.get(runId); + if (r && r.inflight === 0) this.dispose(runId); + }, DISPOSE_GRACE_MS); + // A pending teardown timer must not keep the process alive at quit. + run.disposeTimer.unref?.(); + } + + private cancelDispose(run: ActiveRun): void { + if (run.disposeTimer) { + clearTimeout(run.disposeTimer); + run.disposeTimer = undefined; + } + } + + disposeAll(): void { + for (const runId of [...this.runs.keys()]) this.dispose(runId); + } + + // Reuse the warm connection if it matches; otherwise (cold start, or the user + // switched agent/cwd for this chat) build a fresh one and create-or-resume its session. + private async ensureRun( + runId: string, + agent: CodingAgent, + cwd: string, + broker: PermissionBroker, + onEvent: (event: CodeRunEvent) => void, + ): Promise { + const existing = this.runs.get(runId); + if (existing && existing.agent === agent && existing.cwd === cwd) { + this.cancelDispose(existing); // reused before its grace window elapsed + existing.client.setHandlers(broker, onEvent); + return existing; + } + if (existing) this.dispose(runId); // agent/cwd changed — start over + + const client = new AcpClient({ agent, cwd, broker, onEvent }); + await client.start(); + + const sessionId = await this.openSession(runId, agent, cwd, client); + const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 }; + this.runs.set(runId, run); + return run; + } + + // Resume the persisted session for this chat when possible; else start a new one + // and persist its id so a later restart can resume it. + private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise { + const stored = await readStoredSession(runId); + if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) { + try { + await client.loadSession(stored.sessionId); + return stored.sessionId; + } catch { + // Stored session is stale/unloadable — fall through to a fresh one. + await clearStoredSession(runId); + } + } + const sessionId = await client.newSession(); + await writeStoredSession({ runId, agent, cwd, sessionId }); + return sessionId; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-broker.ts b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts new file mode 100644 index 00000000..9699dec4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts @@ -0,0 +1,91 @@ +import type { + RequestPermissionRequest, + RequestPermissionResponse, + PermissionOption, + PermissionOptionKind, +} from '@agentclientprotocol/sdk'; +import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js'; + +// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`. +const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']); + +function toAsk(request: RequestPermissionRequest): PermissionAsk { + const tc = request.toolCall; + const kind = tc.kind ?? undefined; + const title = tc.title ?? kind ?? 'Tool call'; + return { + toolCallId: tc.toolCallId ?? undefined, + title, + kind, + isRead: kind ? READ_KINDS.has(kind) : false, + }; +} + +// Map a desired decision to one of the options the agent actually offered. +// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always), +// so we fall back within the same allow/reject family before giving up. +function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined { + const order: Record = { + allow_always: ['allow_always', 'allow_once'], + allow_once: ['allow_once', 'allow_always'], + reject: ['reject_once', 'reject_always'], + }; + for (const kind of order[decision]) { + const found = options.find((o) => o.kind === kind); + if (found) return found; + } + return undefined; +} + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +// A request's identity for "always allow" memory: prefer tool kind, else title. +function memoryKey(ask: PermissionAsk): string { + return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`; +} + +export interface PermissionBrokerOptions { + policy: ApprovalPolicy; + // Called only when the policy can't decide on its own (the "ask" path). + ask: (ask: PermissionAsk) => Promise; + // Notified of every resolved request so the engine can emit a stream event. + onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void; +} + +// Decides how to answer the agent's requestPermission calls. Holds per-session +// "always allow" memory so a one-time approval sticks for the rest of the run. +export class PermissionBroker { + private readonly opts: PermissionBrokerOptions; + private readonly alwaysAllow = new Set(); + + constructor(opts: PermissionBrokerOptions) { + this.opts = opts; + } + + async resolve(request: RequestPermissionRequest): Promise { + const ask = toAsk(request); + const key = memoryKey(ask); + + const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => { + if (decision === 'allow_always') this.alwaysAllow.add(key); + this.opts.onResolved?.(ask, decision, auto); + const opt = pickOption(request.options, decision); + // If the agent offered no matching option we fall back to its first one + // (don't deadlock the turn); decision precedence above keeps this rare. + return selected(opt?.optionId ?? request.options[0]?.optionId ?? ''); + }; + + // 1. Sticky "always allow" from earlier this session. + if (this.alwaysAllow.has(key)) return finish('allow_always', true); + + // 2. Policy-level auto decisions. + if (this.opts.policy === 'yolo') return finish('allow_always', true); + if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true); + + // 3. Ask the user. + const decision = await this.opts.ask(ask); + return finish(decision, false); + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-registry.ts b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts new file mode 100644 index 00000000..862f2de4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts @@ -0,0 +1,43 @@ +import type { PermissionDecision } from './types.js'; + +interface Pending { + runId: string; + resolve: (decision: PermissionDecision) => void; +} + +// Holds in-flight mid-run permission asks. The agent (via the broker) calls +// request() which BLOCKS the coding turn until the user answers; the renderer's +// answer arrives over IPC and calls resolve(). This is separate from the LLM +// tool-loop's pre-call permission gate, which can't model a mid-execution wait. +export class CodePermissionRegistry { + private readonly pending = new Map(); + private counter = 0; + + // Register a pending ask, hand the generated requestId to `emit` (so the caller + // can publish the UI event), and resolve once the user answers. + request(runId: string, emit: (requestId: string) => void): Promise { + const requestId = `cpr-${runId}-${++this.counter}`; + return new Promise((resolve) => { + this.pending.set(requestId, { runId, resolve }); + emit(requestId); + }); + } + + // Called from the IPC handler when the user answers a card. + resolve(requestId: string, decision: PermissionDecision): void { + const entry = this.pending.get(requestId); + if (!entry) return; + this.pending.delete(requestId); + entry.resolve(decision); + } + + // On run stop/cancel: reject anything still waiting so the turn can unwind. + cancelRun(runId: string): void { + for (const [id, entry] of [...this.pending]) { + if (entry.runId === runId) { + this.pending.delete(id); + entry.resolve('reject'); + } + } + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/session-store.ts b/apps/x/packages/core/src/code-mode/acp/session-store.ts new file mode 100644 index 00000000..e5e45666 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/session-store.ts @@ -0,0 +1,48 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../../config/config.js'; +import type { CodingAgent } from './types.js'; + +// One ACP session is pinned per chat run. We persist its sessionId (plus the agent +// and cwd it belongs to) so reopening the chat after an app restart can resume the +// same agent context via session/load instead of starting over. +export interface StoredSession { + runId: string; + agent: CodingAgent; + cwd: string; + sessionId: string; +} + +// Per-run ACP session state lives in its own directory (not WorkDir/config): it's +// runtime state that accumulates one file per chat run, so it's kept separate from +// user/app config to be listed and cleaned up on its own. +const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions'); + +function sessionFile(runId: string): string { + return path.join(SESSIONS_DIR, `${runId}.json`); +} + +export async function readStoredSession(runId: string): Promise { + try { + const raw = await fs.readFile(sessionFile(runId), 'utf8'); + const parsed = JSON.parse(raw) as StoredSession; + if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed; + return null; + } catch { + return null; + } +} + +export async function writeStoredSession(session: StoredSession): Promise { + const file = sessionFile(session.runId); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(session, null, 2)); +} + +export async function clearStoredSession(runId: string): Promise { + try { + await fs.rm(sessionFile(runId), { force: true }); + } catch { + // best effort + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/types.ts b/apps/x/packages/core/src/code-mode/acp/types.ts new file mode 100644 index 00000000..6fafd438 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/types.ts @@ -0,0 +1,11 @@ +// Rowboat-facing types for the ACP code-mode engine. The schemas live in +// @x/shared (so the IPC/renderer layers share them); we re-export the inferred +// types here so the engine modules import from one local barrel. +export type { + CodingAgent, + ApprovalPolicy, + PermissionDecision, + PermissionAsk, + CodeRunEvent, + RunPromptResult, +} from '@x/shared/dist/code-mode.js'; diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts index 3858708b..a78b23f4 100644 --- a/apps/x/packages/core/src/code-mode/status.ts +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -12,7 +12,7 @@ const execAsync = promisify(exec); // We scan these directly because Electron's spawned shell sometimes doesn't // inherit the user's full PATH (especially on macOS GUI launches, and even on // Windows when global npm prefix isn't propagated to system PATH). -function commonInstallPaths(binary: string): string[] { +export function commonInstallPaths(binary: string): string[] { const home = os.homedir(); if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts index 57a3158f..f52ae813 100644 --- a/apps/x/packages/core/src/code-mode/types.ts +++ b/apps/x/packages/core/src/code-mode/types.ts @@ -1,7 +1,11 @@ import z from "zod"; +import { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; export const CodeModeConfig = z.object({ enabled: z.boolean(), + // How the ACP engine answers the coding agent's permission requests. + // Optional for back-compat; the tool defaults to "ask" when unset. + approvalPolicy: ApprovalPolicy.optional(), }); export type CodeModeConfig = z.infer; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index f452105a..d7b17ce7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; +import { CodeModeManager } from "../code-mode/acp/manager.js"; +import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; import type { INotificationService } from "../application/notification/service.js"; @@ -43,6 +45,12 @@ container.register({ agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), slackConfigRepo: asClass(FSSlackConfigRepo).singleton(), + + // ACP code-mode engine: the manager holds a live agent connection per chat only + // around an active turn (torn down after a short idle grace; resumed via + // session/load); the registry brokers mid-run approvals. + codeModeManager: asClass(CodeModeManager).singleton(), + codePermissionRegistry: asClass(CodePermissionRegistry).singleton(), }); export default container; diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index 1a5c2582..db81198d 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -1,7 +1,10 @@ import { BuiltinTools } from '../application/lib/builtin-tools.js'; export function getRaw(): string { + // code_agent_run needs an interactive UI to answer its permission asks; exclude it + // from this headless agent so it can't hang waiting on an approval no one can give. const toolEntries = Object.keys(BuiltinTools) + .filter(name => name !== 'code_agent_run') .map(name => ` ${name}:\n type: builtin\n name: ${name}`) .join('\n'); diff --git a/apps/x/packages/core/src/knowledge/live-note/agent.ts b/apps/x/packages/core/src/knowledge/live-note/agent.ts index 8bba90bc..7638384e 100644 --- a/apps/x/packages/core/src/knowledge/live-note/agent.ts +++ b/apps/x/packages/core/src/knowledge/live-note/agent.ts @@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d export function buildLiveNoteAgent(): z.infer { const tools: Record> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts new file mode 100644 index 00000000..5da55bbc --- /dev/null +++ b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + sanitizeReplyBodyForGmailReply, + stripGmailQuotedReplyHtml, + stripGmailQuotedReplyText, +} from './sync_gmail.js'; + +describe('Gmail reply body sanitization', () => { + it('strips Gmail quote attribution and older quoted text from plain text replies', () => { + const body = [ + 'Sounds good, thanks. I will send it over today.', + '', + 'On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:', + '> Can you share the final file?', + '> Thanks', + ].join('\n'); + + expect(stripGmailQuotedReplyText(body)).toBe('Sounds good, thanks. I will send it over today.'); + }); + + it('strips Gmail quote blocks from html replies', () => { + const html = [ + '

Sounds good, thanks.

', + '
', + '
On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:
', + '
Older thread text
', + '
', + ].join(''); + + expect(stripGmailQuotedReplyHtml(html)).toBe('

Sounds good, thanks.

'); + }); + + it('regenerates html from clean text if only the text boundary is detected', () => { + const result = sanitizeReplyBodyForGmailReply( + '

Sounds good, thanks.

Older thread text

', + 'Sounds good, thanks.\n\nOn Thu, 28 May 2026 at 23:45, PRAKHAR wrote:\nOlder thread text', + ); + + expect(result.bodyText).toBe('Sounds good, thanks.'); + expect(result.bodyHtml).toBe('

Sounds good, thanks.

'); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 6b131a5d..77055f37 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -35,7 +35,7 @@ const nhm = new NodeHtmlMarkdown(); // previously cached snapshots (e.g. attachment / recipient parsing fixes). The // short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches, // so stale entries are transparently rebuilt on the next sync. -const SNAPSHOT_PARSER_VERSION = 2; +const SNAPSHOT_PARSER_VERSION = 3; interface SnapshotCacheEntry { historyId: string; @@ -405,6 +405,112 @@ function normalizeBody(body: string): string { return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } +function isGmailQuoteAttribution(line: string): boolean { + const trimmed = line.trim(); + return /^On\b.+\bwrote:\s*$/i.test(trimmed); +} + +function isOriginalMessageBoundary(line: string): boolean { + return /^-{2,}\s*Original Message\s*-{2,}$/i.test(line.trim()); +} + +function isForwardedMessageBoundary(line: string): boolean { + return /^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim()); +} + +function isOutlookHeaderBoundary(lines: string[], index: number): boolean { + if (!/^From:\s+\S/i.test(lines[index]?.trim() || '')) return false; + const next = lines.slice(index + 1, index + 6).map((line) => line.trim()); + return next.some((line) => /^(Sent|Date):\s+\S/i.test(line)) + && next.some((line) => /^To:\s+\S/i.test(line)) + && next.some((line) => /^Subject:\s+\S/i.test(line)); +} + +function findQuotedReplyBoundary(lines: string[]): number { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] || ''; + if ( + isGmailQuoteAttribution(line) + || isOriginalMessageBoundary(line) + || isForwardedMessageBoundary(line) + || isOutlookHeaderBoundary(lines, i) + ) { + return i; + } + + // Gmail plain text drafts often carry older messages as a quoted block. + // Treat a trailing blockquote as history, but avoid stripping an inline + // quote the user is actively writing at the top of the reply. + if (i > 0 && line.trim().startsWith('>') && (lines[i - 1]?.trim() === '' || lines[i - 1]?.trim().startsWith('>'))) { + return i; + } + } + return -1; +} + +export function stripGmailQuotedReplyText(text: string): string { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const boundary = findQuotedReplyBoundary(lines); + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines; + return visible + .join('\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function htmlQuoteBoundaryIndex(html: string): number { + const candidates: number[] = []; + const patterns = [ + /<[^>]+\bclass\s*=\s*["'][^"']*\bgmail_(?:quote|attr)\b[^"']*["'][^>]*>/i, + /]*(?:type\s*=\s*["']cite["']|class\s*=\s*["'][^"']*\bgmail_quote\b[^"']*["'])[^>]*>/i, + /<(p|div|li)\b[^>]*>\s*(?:<(?:span|b|strong|i|em)\b[^>]*>\s*)*On\b[\s\S]{0,800}?\bwrote:\s*(?:\s*)?(?:<\/(?:span|b|strong|i|em)>\s*)*<\/\1>/i, + /<(p|div|li)\b[^>]*>\s*-{2,}\s*(?:Original Message|Forwarded message)\s*-{2,}\s*<\/\1>/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(html); + if (match?.index !== undefined) candidates.push(match.index); + } + + return candidates.length > 0 ? Math.min(...candidates) : -1; +} + +export function stripGmailQuotedReplyHtml(html: string): string { + const boundary = htmlQuoteBoundaryIndex(html); + const visible = boundary >= 0 ? html.slice(0, boundary) : html; + return visible.trim(); +} + +function textToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para).replace(/\n/g, '
')}

`) + .join(''); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function sanitizeReplyBodyForGmailReply(bodyHtml: string, bodyText: string): { bodyHtml: string; bodyText: string } { + const cleanText = stripGmailQuotedReplyText(bodyText); + const cleanHtml = stripGmailQuotedReplyHtml(bodyHtml); + const textWasStripped = cleanText !== bodyText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const htmlWasStripped = cleanHtml !== bodyHtml.trim(); + + return { + bodyText: cleanText, + bodyHtml: textWasStripped && !htmlWasStripped ? textToHtml(cleanText) : cleanHtml, + }; +} + function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined { return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined; } @@ -636,9 +742,13 @@ async function buildAndCacheSnapshot( const sentMessages = parsed.filter((m) => !m.isDraft); const draftMessages = parsed.filter((m) => m.isDraft); - const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest); + const visibleMessages = sentMessages.map((msg) => { + const rest: Partial = { ...msg }; + delete rest.isDraft; + return rest as Omit; + }); const latestDraftBody = draftMessages.length > 0 - ? draftMessages[draftMessages.length - 1]!.body.trim() + ? stripGmailQuotedReplyText(draftMessages[draftMessages.length - 1]!.body) : ''; if (visibleMessages.length === 0) return null; @@ -674,7 +784,10 @@ async function buildAndCacheSnapshot( const classification = await classifyThread(snapshot, userEmail, { skipDraft }); snapshot.importance = classification.importance; if (classification.summary) snapshot.summary = classification.summary; - if (classification.draftResponse) snapshot.draft_response = classification.draftResponse; + if (classification.draftResponse) { + const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); + if (draftResponse) snapshot.draft_response = draftResponse; + } } catch (err) { console.warn(`[Gmail] classify failed for ${threadId}:`, err); } @@ -947,16 +1060,20 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str // If the state file holds a last_sync timestamp (e.g. left over from a // prior Composio sync, or from a previous successful native sync that // we're falling back to after a history.list 404), use that as the - // floor instead of the default lookback. Carries forward Composio's - // last_sync on first migration so we don't refetch the last 7 days. + // floor — but never reach back further than lookbackDays. This caps the + // window at "1 week at most": if last_sync is within the lookback window + // we resume from it (a smaller window), otherwise we clamp to lookbackDays + // ago. Mail older than the cap that arrived during a long offline gap is + // intentionally skipped rather than backfilled. const state = loadState(stateFile); + const lookbackFloor = new Date(); + lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays); let pastDate: Date; - if (state.last_sync) { + if (state.last_sync && new Date(state.last_sync) > lookbackFloor) { pastDate = new Date(state.last_sync); console.log(`Performing full sync from last_sync=${state.last_sync}...`); } else { - pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); + pastDate = lookbackFloor; console.log(`Performing full sync of last ${lookbackDays} days...`); } @@ -1222,12 +1339,22 @@ async function performSync() { // this runs once, the cache directory is populated and we fall back to // partial-sync on subsequent calls. const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0; + // partialSync replays *every* messageAdded since the stored historyId, + // regardless of date — so after a long offline gap a still-valid + // historyId would pull the entire gap (e.g. 3 weeks). To honor the + // "1 week at most" cap, bypass it when last_sync is older than the + // lookback window and run a (date-clamped) fullSync instead. + const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0; + const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000; if (!state.historyId) { console.log("No history ID found, starting full sync..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else if (cacheMissing) { console.log("History ID present but inbox cache empty — running full sync to backfill snapshots..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); + } else if (gapTooLarge) { + console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`); + await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else { console.log("History ID found, starting partial sync..."); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); @@ -1330,6 +1457,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise { return cfg.liveNoteAgentModel ?? cfg.model; } +/** + * Model used by the auto-permission classifier. + * Signed-in: curated default. BYOK: user override + * (`autoPermissionDecisionModel`) or assistant model. + */ +export async function getAutoPermissionDecisionModel(): Promise { + if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.autoPermissionDecisionModel ?? cfg.model; +} + /** * Model used by the meeting-notes summarizer. No special signed-in default — * historically meetings used the assistant model. BYOK: user override diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3f21e675..29febf4b 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { knowledgeGraphModel: config.knowledgeGraphModel, meetingNotesModel: config.meetingNotesModel, liveNoteAgentModel: config.liveNoteAgentModel, + autoPermissionDecisionModel: config.autoPermissionDecisionModel, }; const toWrite = { ...config, providers: existingProviders }; diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index addb4f35..8a1d3e85 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -35,6 +35,7 @@ export type CreateRunRepoOptions = { agentId: string; model: string; provider: string; + permissionMode: "manual" | "auto"; useCase: z.infer; subUseCase?: string; }; @@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo { agentName: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), subflow: [], @@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), log: [start], @@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: start.agentName, model: start.model, provider: start.provider, + permissionMode: start.permissionMode ?? "manual", ...(start.useCase ? { useCase: start.useCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), log: events, @@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo { async delete(id: string): Promise { await fsp.unlink(runLogPath(id)); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index c316cd3b..f832c00d 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -32,6 +32,7 @@ export async function createRun(opts: z.infer): Promise agentId: opts.agentId, model, provider, + permissionMode: opts.permissionMode ?? "manual", useCase, ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), }); diff --git a/apps/x/packages/core/src/security/auto-permission-classifier.ts b/apps/x/packages/core/src/security/auto-permission-classifier.ts new file mode 100644 index 00000000..352512be --- /dev/null +++ b/apps/x/packages/core/src/security/auto-permission-classifier.ts @@ -0,0 +1,112 @@ +import { generateObject, type ModelMessage } from "ai"; +import z from "zod"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { captureLlmUsage } from "../analytics/usage.js"; +import { withUseCase, type UseCase } from "../analytics/use_case.js"; +import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js"; +import { createProvider } from "../models/models.js"; + +const DecisionSchema = z.object({ + decisions: z.array(z.object({ + toolCallId: z.string(), + decision: z.enum(["allow", "deny"]), + reason: z.string().min(1), + })), +}); + +export type AutoPermissionCandidate = { + toolCall: z.infer; + permission: z.infer; +}; + +export type AutoPermissionDecision = { + toolCallId: string; + decision: "allow" | "deny"; + reason: string; +}; + +const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user. + +You only receive tool calls that already require permission under deterministic rules. + +Allow a tool call only when it is clearly consistent with the user's request and low risk. +Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested. + +Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace. +File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories. + +Return one decision for every toolCallId. Use the exact toolCallId values provided.`; + +function compact(value: unknown, max = 8_000): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + if (text.length <= max) return text; + return `${text.slice(0, max)}\n...`; +} + +function recentContext(messages: ModelMessage[]): unknown[] { + return messages.slice(-8).map((message) => { + if (typeof message.content === "string") { + return { role: message.role, content: compact(message.content, 2_000) }; + } + return { role: message.role, content: compact(message.content, 3_000) }; + }); +} + +function buildPrompt(input: { + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; +}) { + return compact({ + agentName: input.agentName, + recentConversation: recentContext(input.messages), + toolCalls: input.candidates.map(({ toolCall, permission }) => ({ + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + arguments: toolCall.arguments, + permission, + })), + }, 24_000); +} + +export async function classifyToolPermissions(input: { + runId: string; + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; + useCase: UseCase; + subUseCase?: string | null; +}): Promise { + if (input.candidates.length === 0) return []; + + const modelId = await getAutoPermissionDecisionModel(); + const { provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); + + const result = await withUseCase( + { + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + ...(input.agentName ? { agentName: input.agentName } : {}), + }, + () => generateObject({ + model, + system: SYSTEM_PROMPT, + prompt: buildPrompt(input), + schema: DecisionSchema, + }), + ); + + captureLlmUsage({ + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + model: modelId, + provider: providerName, + usage: result.usage, + }); + + const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId)); + return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId)); +} diff --git a/apps/x/packages/shared/src/code-mode.ts b/apps/x/packages/shared/src/code-mode.ts new file mode 100644 index 00000000..a3bd46a7 --- /dev/null +++ b/apps/x/packages/shared/src/code-mode.ts @@ -0,0 +1,70 @@ +import z from "zod"; + +// Shared zod schemas for the ACP code-mode engine. Single source of truth: the +// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent +// variants that carry these to the renderer. + +export const CodingAgent = z.enum(["claude", "codex"]); +export type CodingAgent = z.infer; + +// How the permission broker answers the agent's requests before any per-tool +// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of +// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag). +export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]); +export type ApprovalPolicy = z.infer; + +export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]); +export type PermissionDecision = z.infer; + +// What the UI needs to render a permission card. +export const PermissionAsk = z.object({ + toolCallId: z.string().optional(), + title: z.string(), + kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read" + isRead: z.boolean(), +}); +export type PermissionAsk = z.infer; + +// Normalized per-run stream items. The engine maps raw ACP session/update +// notifications onto this union; the renderer renders them. +export const CodeRunEvent = z.discriminatedUnion("type", [ + // role distinguishes the agent's own output from replayed user turns + // (loadSession streams the whole prior conversation back on resume). + z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }), + z.object({ type: z.literal("thought") }), + z.object({ + type: z.literal("tool_call"), + id: z.string().optional(), + title: z.string().optional(), + kind: z.string().optional(), + status: z.string().optional(), + }), + z.object({ + type: z.literal("tool_call_update"), + id: z.string().optional(), + status: z.string().optional(), + diffs: z.array(z.string()), + }), + z.object({ + type: z.literal("plan"), + entries: z.array(z.object({ + content: z.string(), + status: z.string().optional(), + priority: z.string().optional(), + })), + }), + z.object({ + type: z.literal("permission"), + ask: PermissionAsk, + decision: z.union([PermissionDecision, z.literal("cancelled")]), + auto: z.boolean(), + }), + z.object({ type: z.literal("other"), sessionUpdate: z.string() }), +]); +export type CodeRunEvent = z.infer; + +export const RunPromptResult = z.object({ + stopReason: z.string(), + sessionId: z.string(), +}); +export type RunPromptResult = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d42e9935..f3acffa1 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -19,6 +19,7 @@ import { ZListToolkitsResponse } from './composio.js'; import { BrowserStateSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; +import { PermissionDecision, ApprovalPolicy } from './code-mode.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -38,6 +39,7 @@ const ipcSchemas = { res: z.object({ installationId: z.string(), apiUrl: z.string(), + appVersion: z.string(), }), }, 'workspace:getRoot': { @@ -429,11 +431,23 @@ const ipcSchemas = { req: z.null(), res: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), }), }, 'codeMode:setConfig': { req: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Answer a mid-run permission request from a code_agent_run coding turn. + 'codeRun:resolvePermission': { + req: z.object({ + requestId: z.string(), + decision: PermissionDecision, }), res: z.object({ success: z.literal(true), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 3a24c217..4571de2c 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({ headers: z.record(z.string(), z.string()).optional(), model: z.string().optional(), models: z.array(z.string()).optional(), + knowledgeGraphModel: z.string().optional(), + meetingNotesModel: z.string().optional(), + liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), })).optional(), // Per-category model overrides (BYOK only — signed-in users always get // the curated gateway defaults). Read by helpers in core/models/defaults.ts. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a977db0b..a5043cde 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -1,5 +1,6 @@ import { LlmStepStreamEvent } from "./llm-step-events.js"; import { Message, ToolCallPart } from "./message.js"; +import { CodeRunEvent as CodeRunEventSchema, PermissionAsk } from "./code-mode.js"; import z from "zod"; const BaseRunEvent = z.object({ @@ -21,6 +22,7 @@ export const StartEvent = BaseRunEvent.extend({ agentName: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), // useCase/subUseCase tag the run for analytics. Optional on read so legacy // run files written before these fields existed still parse cleanly. useCase: z.enum([ @@ -110,6 +112,32 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ scope: z.enum(["once", "session", "always"]).optional(), }); +// A structured item from a code_agent_run coding turn (tool call, diff, plan, +// message chunk, resolved permission). Fire-and-forget — rendered live. +export const CodeRunStreamEvent = BaseRunEvent.extend({ + type: z.literal("code-run-event"), + toolCallId: z.string(), + event: CodeRunEventSchema, +}); + +// The coding agent is asking for permission mid-turn and the run is BLOCKED until +// the user answers via `codeRun:resolvePermission` (keyed by requestId). +export const CodeRunPermissionRequestEvent = BaseRunEvent.extend({ + type: z.literal("code-run-permission-request"), + toolCallId: z.string(), + requestId: z.string(), + ask: PermissionAsk, +}); + +export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({ + type: z.literal("tool-permission-auto-decision"), + toolCallId: z.string(), + toolCall: ToolCallPart, + permission: ToolPermissionMetadata.optional(), + decision: z.enum(["allow", "deny"]), + reason: z.string(), +}); + export const RunErrorEvent = BaseRunEvent.extend({ type: z.literal("error"), error: z.string(), @@ -134,6 +162,9 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + CodeRunStreamEvent, + CodeRunPermissionRequestEvent, + ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, ]); @@ -166,6 +197,7 @@ export const Run = z.object({ agentId: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), log: z.array(RunEvent), @@ -185,6 +217,7 @@ export const CreateRunOptions = z.object({ agentId: z.string(), model: z.string().optional(), provider: z.string().optional(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), }); diff --git a/apps/x/patches/@openai__codex@0.128.0.patch b/apps/x/patches/@openai__codex@0.128.0.patch new file mode 100644 index 00000000..73b2e0c3 --- /dev/null +++ b/apps/x/patches/@openai__codex@0.128.0.patch @@ -0,0 +1,15 @@ +diff --git a/bin/codex.js b/bin/codex.js +index 67ab3e2d95dfac1c91882578b5403916c3121484..f8030b6e1459e05161af99e152b2e7f65ea6c41d 100644 +--- a/bin/codex.js ++++ b/bin/codex.js +@@ -175,6 +175,10 @@ env[packageManagerEnvVar] = "1"; + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: "inherit", + env, ++ // Native console-subsystem binary: without this Windows pops a visible console ++ // window when launched from a console-less (Electron GUI) parent. Closing that ++ // window wedges the agent. CREATE_NO_WINDOW keeps the console hidden. ++ windowsHide: true, + }); + + child.on("error", (err) => { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 0605adaf..c4e5a8d5 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -4,6 +4,17 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vitest: + specifier: 4.1.7 + version: 4.1.7 + +patchedDependencies: + '@openai/codex@0.128.0': + hash: 9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86 + path: patches/@openai__codex@0.128.0.patch + importers: .: @@ -41,6 +52,12 @@ importers: apps/main: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) + '@agentclientprotocol/codex-acp': + specifier: ^0.0.44 + version: 0.0.44(zod@4.2.1) '@x/core': specifier: workspace:* version: link:../../packages/core @@ -127,6 +144,9 @@ importers: apps/renderer: dependencies: + '@eigenpal/docx-editor-react': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@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) @@ -186,16 +206,16 @@ importers: version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-placeholder': specifier: 3.22.4 - version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-table': specifier: 3.22.4 version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-task-item': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-task-list': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/pm': specifier: 3.22.4 version: 3.22.4 @@ -238,6 +258,33 @@ importers: posthog-js: specifier: ^1.332.0 version: 1.332.0 + prosemirror-commands: + specifier: ^1.7.1 + version: 1.7.1 + prosemirror-dropcursor: + specifier: ^1.8.2 + version: 1.8.2 + prosemirror-history: + specifier: ^1.5.0 + version: 1.5.0 + prosemirror-keymap: + specifier: ^1.2.3 + version: 1.2.3 + prosemirror-model: + specifier: ^1.25.7 + version: 1.25.7 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-tables: + specifier: ^1.8.5 + version: 1.8.5 + prosemirror-transform: + specifier: ^1.12.0 + version: 1.12.0 + prosemirror-view: + specifier: ^1.41.8 + version: 1.41.8 radix-ui: specifier: ^1.4.3 version: 1.4.3(@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) @@ -326,6 +373,15 @@ importers: packages/core: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) + '@agentclientprotocol/codex-acp': + specifier: ^0.0.44 + version: 0.0.44(zod@4.2.1) + '@agentclientprotocol/sdk': + specifier: ^0.22.1 + version: 0.22.1(zod@4.2.1) '@ai-sdk/anthropic': specifier: ^2.0.63 version: 2.0.70(zod@4.2.1) @@ -453,6 +509,24 @@ importers: packages: + '@agentclientprotocol/claude-agent-acp@0.39.0': + resolution: {integrity: sha512-+tCm5v32L0R3zE4qjZQowfO1L/zqvQ5FapmsMSIf4gawXfTf26CG5hgz99wARdo0zn20/1eP80gzx7PbZlSX9A==} + hasBin: true + + '@agentclientprotocol/codex-acp@0.0.44': + resolution: {integrity: sha512-iHzFWKzJ0Z8I6yJCkuLZ+nb9mF2WYmfTcHFFvc7sU/awBsQmVBmpSOXOpZ+IK2Dy9cR3iRoML/B2/Wq2/zKBCA==} + hasBin: true + + '@agentclientprotocol/sdk@0.21.1': + resolution: {integrity: sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/anthropic@2.0.70': resolution: {integrity: sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q==} engines: {node: '>=18'} @@ -508,6 +582,67 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + resolution: {integrity: sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + resolution: {integrity: sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.156': + resolution: {integrity: sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.100.1': + resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -795,6 +930,60 @@ packages: peerDependencies: zod: '>=3.25.76 <5' + '@eigenpal/docx-editor-agents@1.0.3': + resolution: {integrity: sha512-Bk/J9/PBnMCOxb6w4cHQiCTuN/1C4FtZM9evC9EXXcLP13yFMdqoEqsYs+Lh3HyaRRAaCZTrkfgOZyTqqyjtwQ==} + peerDependencies: + '@ai-sdk/vue': ^2.0.0 + ai: ^5.0.0 || ^6.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@ai-sdk/vue': + optional: true + ai: + optional: true + react: + optional: true + vue: + optional: true + + '@eigenpal/docx-editor-core@1.0.3': + resolution: {integrity: sha512-etpupuln9ZlHLW4DgS7877WBdMEChsAG0D1bEZLjF70isYbyxrd2ARWax745P7XMm4GqqkAfByzxE2GGWQmJaA==} + hasBin: true + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.32.7 + + '@eigenpal/docx-editor-i18n@1.0.3': + resolution: {integrity: sha512-zwz/S+duPOnzg/kh4bs28T3UqI8mKMzHdmFgbWgMxwtTfUkAxaUAnAVbuZgrysl1aD2scv4Hfy4EgOZcFy9NnA==} + + '@eigenpal/docx-editor-react@1.0.3': + resolution: {integrity: sha512-KupDVHo6KC4KUs48bM1pMYFFbDJqkW8XyIhgsnLx+BWk2yOPU4bx2HfWB6H+JEVROA1h1AmhTAyE39gk75wg5w==} + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.41.6 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -1488,30 +1677,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -1648,6 +1842,47 @@ packages: resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==} engines: {node: '>=8.0'} + '@openai/codex@0.128.0': + resolution: {integrity: sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.128.0-darwin-arm64': + resolution: {integrity: sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.128.0-darwin-x64': + resolution: {integrity: sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.128.0-linux-arm64': + resolution: {integrity: sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.128.0-linux-x64': + resolution: {integrity: sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.128.0-win32-arm64': + resolution: {integrity: sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.128.0-win32-x64': + resolution: {integrity: sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -2634,56 +2869,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -2951,6 +3197,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3002,24 +3251,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3155,6 +3408,12 @@ packages: peerDependencies: '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list@3.22.4': + resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5': resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} peerDependencies: @@ -3207,6 +3466,12 @@ packages: peerDependencies: '@tiptap/core': 3.22.5 + '@tiptap/extensions@3.22.4': + resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5': resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} peerDependencies: @@ -3663,6 +3928,10 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3934,6 +4203,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -4414,6 +4687,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -4425,6 +4706,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4466,12 +4751,20 @@ packages: diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + docxtemplater@3.68.7: + resolution: {integrity: sha512-FwgeAKqY2vc9eVm2V2XGg8bq25B0OQjtSDITGi9zNnvu5GbtR4WvGjM5QNld/ALB6ZbsSuHskBPK9SvPpKhsbA==} + engines: {node: '>=0.10'} + dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} @@ -4812,6 +5105,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5411,6 +5707,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5430,6 +5731,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -5487,6 +5797,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5547,6 +5861,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5655,24 +5973,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6297,6 +6619,10 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -6386,6 +6712,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -6499,6 +6828,9 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pizzip@3.2.0: + resolution: {integrity: sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6539,6 +6871,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.28.2: resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} @@ -6605,8 +6941,8 @@ packages: prosemirror-markdown@1.13.2: resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -6617,11 +6953,11 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-transform@1.10.5: - resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.4: - resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} @@ -6957,6 +7293,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -6979,6 +7319,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} @@ -7161,6 +7505,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7379,6 +7726,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -7683,6 +8033,10 @@ packages: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + vscode-languageserver-protocol@3.17.5: resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} @@ -7789,6 +8143,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + x-is-array@0.1.0: resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==} @@ -7800,6 +8158,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xmlbuilder2@2.1.2: resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} engines: {node: '>=8.0'} @@ -7880,6 +8242,33 @@ packages: snapshots: + '@agentclientprotocol/claude-agent-acp@0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))': + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.2.1) + '@anthropic-ai/claude-agent-sdk': 0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1) + zod: 4.2.1 + transitivePeerDependencies: + - '@anthropic-ai/sdk' + - '@modelcontextprotocol/sdk' + + '@agentclientprotocol/codex-acp@0.0.44(zod@4.2.1)': + dependencies: + '@agentclientprotocol/sdk': 0.21.1(zod@4.2.1) + '@openai/codex': 0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86) + diff: 8.0.4 + open: 11.0.0 + vscode-jsonrpc: 8.2.1 + transitivePeerDependencies: + - zod + + '@agentclientprotocol/sdk@0.21.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + + '@agentclientprotocol/sdk@0.22.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + '@ai-sdk/anthropic@2.0.70(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -7941,6 +8330,52 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1)': + dependencies: + '@anthropic-ai/sdk': 0.100.1(zod@4.2.1) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1) + zod: 4.2.1 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 + + '@anthropic-ai/sdk@0.100.1(zod@4.2.1)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.2.1 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -8588,6 +9023,61 @@ snapshots: dependencies: zod: 4.2.1 + '@eigenpal/docx-editor-agents@1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + xml-js: 1.6.11 + optionalDependencies: + ai: 5.0.117(zod@4.2.1) + react: 19.2.3 + + '@eigenpal/docx-editor-core@1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + xml-js: 1.6.11 + + '@eigenpal/docx-editor-i18n@1.0.3': {} + + '@eigenpal/docx-editor-react@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@eigenpal/docx-editor-agents': 1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3) + '@eigenpal/docx-editor-core': 1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + '@eigenpal/docx-editor-i18n': 1.0.3 + '@radix-ui/react-select': 2.2.6(@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) + clsx: 2.1.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + sonner: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@ai-sdk/vue' + - '@types/react' + - '@types/react-dom' + - ai + - vue + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)': dependencies: '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2) @@ -9616,6 +10106,33 @@ snapshots: '@oozcitak/util@8.3.4': {} + '@openai/codex@0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86)': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.128.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.128.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.128.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.128.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.128.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.128.0-win32-x64' + + '@openai/codex@0.128.0-darwin-arm64': + optional: true + + '@openai/codex@0.128.0-darwin-x64': + optional: true + + '@openai/codex@0.128.0-linux-arm64': + optional: true + + '@openai/codex@0.128.0-linux-x64': + optional: true + + '@openai/codex@0.128.0-win32-arm64': + optional: true + + '@openai/codex@0.128.0-win32-x64': + optional: true + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 @@ -11098,6 +11615,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -11264,6 +11783,11 @@ snapshots: dependencies: '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11277,9 +11801,9 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11290,13 +11814,13 @@ snapshots: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/pm': 3.22.4 - '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11306,6 +11830,11 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11319,12 +11848,12 @@ snapshots: prosemirror-gapcursor: 1.4.0 prosemirror-history: 1.5.0 prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 '@tiptap/react@3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@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)': dependencies: @@ -11939,6 +12468,8 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -12216,6 +12747,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cacache@16.1.3: @@ -12710,6 +13245,13 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -12722,6 +13264,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -12756,6 +13300,8 @@ snapshots: diff3@0.0.3: {} + diff@8.0.4: {} + dingbat-to-unicode@1.0.1: {} dir-compare@4.2.0: @@ -12763,6 +13309,10 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 + docxtemplater@3.68.7: + dependencies: + '@xmldom/xmldom': 0.9.10 + dom-serializer@0.2.2: dependencies: domelementtype: 2.3.0 @@ -13254,6 +13804,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fast-xml-parser@5.2.5: @@ -14029,6 +14581,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -14041,6 +14595,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-lambda@1.0.1: {} @@ -14091,6 +14651,10 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -14159,6 +14723,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -15148,6 +15717,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -15227,6 +15805,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + papaparse@5.5.3: {} parent-module@1.0.1: @@ -15324,6 +15904,10 @@ snapshots: pify@4.0.1: {} + pizzip@3.2.0: + dependencies: + pako: 2.1.0 + pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -15381,6 +15965,8 @@ snapshots: dependencies: commander: 9.5.0 + powershell-utils@0.1.0: {} + preact@10.28.2: {} prelude-ls@1.2.1: {} @@ -15412,32 +15998,32 @@ snapshots: prosemirror-changeset@2.3.1: dependencies: - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-commands@1.7.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.8 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 rope-sequence: 1.3.4 prosemirror-keymap@1.2.3: @@ -15449,41 +16035,41 @@ snapshots: dependencies: '@types/markdown-it': 14.1.2 markdown-it: 14.1.0 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-model@1.25.4: + prosemirror-model@1.25.7: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.25.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - prosemirror-transform@1.10.5: + prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-view@1.41.4: + prosemirror-view@1.41.8: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 protobufjs@7.5.4: dependencies: @@ -15964,6 +16550,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -15986,6 +16574,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + scheduler@0.25.0-rc-603e6108-20241029: {} scheduler@0.27.0: {} @@ -16197,6 +16787,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -16437,6 +17032,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -16738,6 +17335,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 @@ -16870,6 +17469,11 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + x-is-array@0.1.0: {} x-is-string@0.1.0: {} @@ -16884,6 +17488,10 @@ snapshots: wmf: 1.0.2 word: 0.3.0 + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xmlbuilder2@2.1.2: dependencies: '@oozcitak/dom': 1.15.5 diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index 19bafad8..f5cdd141 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -13,3 +13,5 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - protobufjs +patchedDependencies: + '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch