- {results.length > 0 && (
-
- {results.length} result{results.length !== 1 ? "s" : ""}
+ const domains = useMemo(() => uniqueDomains(results), [results]);
+
+ // Drive the one-shot rolling reveal. Results arrive all at once, so we
+ // simulate "fetching one site at a time" by stepping through them with the
+ // same slide animation the tool group uses, then settle on a summary.
+ // `settled` is seeded from the initial status so a card loaded already-
+ // complete from history skips straight to the summary (no roll).
+ const [settled, setSettled] = useState(() => !isRunning);
+ const [rollIndex, setRollIndex] = useState(0);
+
+ // Phase is fully derived: searching while the tool runs, rolling once
+ // results land, then settled. No setState-in-effect needed for transitions.
+ const phase: RollPhase = isRunning
+ ? "searching"
+ : !settled && results.length > 0
+ ? "rolling"
+ : "settled";
+
+ // Warm the browser cache for every favicon the moment results arrive, so
+ // each icon is already loaded by the time its row rolls in (~700ms each).
+ // Without this the network fetch lags the text and rows flash icon-less.
+ useEffect(() => {
+ for (const result of results) {
+ const img = new Image();
+ img.src = faviconUrl(getDomain(result.url));
+ }
+ }, [results]);
+
+ // Advance the roll, then settle after the last site has had its moment.
+ // setState only fires inside the timeout callback, never synchronously.
+ useEffect(() => {
+ if (phase !== "rolling") return;
+ const isLast = rollIndex >= results.length - 1;
+ const timer = setTimeout(
+ () => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)),
+ ROLL_INTERVAL_MS,
+ );
+ return () => clearTimeout(timer);
+ }, [phase, rollIndex, results.length]);
+
+ // Build the content for the compact (collapsed) header line. Each distinct
+ // value gets a unique key so AnimatePresence runs the slide transition.
+ let headerKey: string;
+ let headerContent: React.ReactNode;
+ if (phase === "searching") {
+ headerKey = "searching";
+ headerContent = (
+
+
+ Searching the web…
+
+ );
+ } else if (phase === "rolling") {
+ const result = results[rollIndex];
+ const domain = getDomain(result.url);
+ headerKey = `roll-${rollIndex}`;
+ headerContent = (
+
+
+
+ {domain}
+ ·
+ {result.title}
+
+
+ );
+ } else {
+ headerKey = "settled";
+ const stack = domains.slice(0, MAX_STACK);
+ // Chip count matches the "and N others" in the text (total minus the 2
+ // named domains), shown only when there are sites beyond the stack.
+ const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0;
+ headerContent = (
+
+ {domains.length > 0 ? (
+
+ {stack.map((domain, i) => (
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
)}
+
+ ) : (
+
+ )}
+
+ {domains.length > 0 ? buildSearchedSummary(domains) : title}
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Rolling header: clipped, fixed height so sliding lines stay contained */}
+
+
+
+
+
+
+ {workDir ? 'Add files or change work directory' : 'Add files or 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 && (
+
+ 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 runs it on-device
+ and streams its work — tool calls, file diffs, and approvals — back into chat.
+
+
+ Requires an active Claude Code subscription or
+ a ChatGPT/Codex subscription. You can have one or both.
+
+
+
+
+
+ Agent status
+
+
+
+
+
+
+
+
+
+
+
Enable code mode
+
+ Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
+
+
+
+
+
+ {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 && (
+
+
+
+ Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
+ account, then click Re-check.
+
)
+ 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 576997ad..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'
@@ -600,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
@@ -653,6 +660,63 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => {
return names.join(' · ')
}
+// Past-tense action phrases for summarizing a finished tool group, e.g.
+// "read 3 files, listed directory". Keyed by builtin tool name.
+const TOOL_ACTION_VERBS: Record = {
+ 'file-readText': { verb: 'read', one: 'file', many: 'files' },
+ 'file-writeText': { verb: 'wrote', one: 'file', many: 'files' },
+ 'file-editText': { verb: 'edited', one: 'file', many: 'files' },
+ 'file-list': { verb: 'listed', one: 'directory', many: 'directories' },
+ 'file-exists': { verb: 'checked', one: 'path', many: 'paths' },
+ 'file-stat': { verb: 'inspected', one: 'file', many: 'files' },
+ 'file-glob': { verb: 'searched for', one: 'file', many: 'files' },
+ 'file-grep': { verb: 'searched', one: 'file', many: 'files' },
+ 'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' },
+ 'file-rename': { verb: 'renamed', one: 'file', many: 'files' },
+ 'file-copy': { verb: 'copied', one: 'file', many: 'files' },
+ 'file-remove': { verb: 'removed', one: 'file', many: 'files' },
+ 'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' },
+ 'executeCommand': { verb: 'ran', one: 'command', many: 'commands' },
+ 'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' },
+ 'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' },
+ 'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' },
+ 'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' },
+ 'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' },
+ 'parseFile': { verb: 'parsed', one: 'file', many: 'files' },
+}
+
+// Summarize what a group of tools actually did, grouping identical actions
+// and counting them: "read 3 files, listed directory". Unmapped tools fall
+// back to their lowercased display name.
+export const getToolActionsSummary = (tools: ToolCall[]): string => {
+ const order: string[] = []
+ const grouped = new Map()
+ for (const tool of tools) {
+ const phrase = TOOL_ACTION_VERBS[tool.name] ?? null
+ const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name
+ const existing = grouped.get(key)
+ if (existing) {
+ existing.count++
+ } else {
+ grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) })
+ order.push(key)
+ }
+ }
+ const phrases = order.map((key) => {
+ const { phrase, count, fallback } = grouped.get(key)!
+ if (!phrase) return fallback.toLowerCase()
+ if (count > 1) return `${phrase.verb} ${count} ${phrase.many}`
+ const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a'
+ return `${phrase.verb} ${article} ${phrase.one}`
+ })
+ // Show at most two operations; collapse the rest into "more...".
+ const MAX_ACTIONS = 2
+ if (phrases.length > MAX_ACTIONS) {
+ return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...`
+ }
+ return phrases.join(', ')
+}
+
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()
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 f0d867bd..6f563a07 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -3,7 +3,7 @@ import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
-import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
+import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
import { z } from "zod";
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
@@ -23,7 +23,7 @@ import { resolveProviderConfig } from "../models/defaults.js";
import { IAgentsRepo } from "./repo.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { IBus } from "../application/lib/bus.js";
-import { IMessageQueue } from "../application/lib/message-queue.js";
+import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js";
import { IRunsRepo } from "../runs/repo.js";
import { IRunsLock } from "../runs/lock.js";
import { IAbortRegistry } from "../runs/abort-registry.js";
@@ -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');
@@ -235,6 +236,96 @@ function loadAgentNotesContext(): string | null {
return `# Agent Memory\n\n${sections.join('\n\n')}`;
}
+function isCopilotLikeAgent(agentName: string | null | undefined): boolean {
+ return agentName === 'copilot' || agentName === 'rowboatx';
+}
+
+function formatCurrentDateTime(now: Date): string {
+ return now.toLocaleString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'short',
+ });
+}
+
+function toUserMessageContextMiddlePane(middlePaneContext: MiddlePaneContext | null): z.infer['middlePane'] {
+ if (!middlePaneContext) {
+ return { kind: 'empty' };
+ }
+ if (middlePaneContext.kind === 'note') {
+ return {
+ kind: 'note',
+ path: middlePaneContext.path,
+ content: middlePaneContext.content,
+ };
+ }
+ return {
+ kind: 'browser',
+ url: middlePaneContext.url,
+ title: middlePaneContext.title,
+ };
+}
+
+function buildUserMessageContext({
+ agentName,
+ middlePaneContext,
+}: {
+ agentName: string | null | undefined;
+ middlePaneContext: MiddlePaneContext | null;
+}): z.infer {
+ return {
+ currentDateTime: formatCurrentDateTime(new Date()),
+ ...(isCopilotLikeAgent(agentName)
+ ? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) }
+ : {}),
+ };
+}
+
+function formatUserMessageContextForLlm(userMessageContext: z.infer): string {
+ const sections: string[] = [];
+
+ if (userMessageContext.currentDateTime) {
+ sections.push(`Current date and time: ${userMessageContext.currentDateTime}`);
+ }
+
+ if (userMessageContext.middlePane) {
+ if (userMessageContext.middlePane.kind === 'empty') {
+ sections.push(`Middle pane:\nState: empty`);
+ } else if (userMessageContext.middlePane.kind === 'note') {
+ sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``);
+ } else {
+ sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`);
+ }
+ }
+
+ if (sections.length === 0) {
+ return '';
+ }
+
+ return `# User Context
+${sections.join('\n\n')}
+
+# User Message
+`;
+}
+
+const USER_CONTEXT_SYSTEM_INSTRUCTIONS = `# Hidden User Context
+User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message".
+
+Use "Current date and time" for temporal reasoning.
+
+If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current.
+
+If Middle pane state is empty, the user was not looking at any relevant note or web page at that point. Answer the user's message on its own merits.
+
+If Middle pane state is note, the supplied path and content are available so you can reference the note when relevant. The user may or may not be talking about this note. Do NOT assume every message is about it. Only reference or act on this note when the user's message clearly relates to it, such as "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly the note's content. For unrelated questions, ignore this note entirely and answer normally. Do not mention that you can see this note unless it is relevant to the answer.
+
+If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`;
+
export interface IAgentRuntime {
trigger(runId: string): Promise;
}
@@ -392,9 +483,10 @@ export async function mapAgentTool(t: z.infer): Promise[]): ModelM
providerOptions,
});
break;
- case "user":
+ case "user": {
+ const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
if (typeof msg.content === 'string') {
// Legacy string — pass through unchanged
result.push({
role: "user",
- content: msg.content,
+ content: `${userMessageContextPrefix}${msg.content}`,
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
- const textSegments: string[] = [];
+ const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
const attachmentLines: string[] = [];
for (const part of msg.content) {
@@ -745,7 +838,11 @@ export function convertFromMessages(messages: z.infer[]): ModelM
}
if (attachmentLines.length > 0) {
- textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
+ if (userMessageContextPrefix) {
+ textSegments.push("User has attached the following files:", ...attachmentLines, "");
+ } else {
+ textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
+ }
}
result.push({
@@ -755,6 +852,7 @@ export function convertFromMessages(messages: z.infer[]): ModelM
});
}
break;
+ }
case "tool":
result.push({
role: "tool",
@@ -804,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 = [];
@@ -815,6 +914,8 @@ export class AgentState {
pendingAskHumanRequests: Record> = {};
allowedToolCallIds: Record = {};
deniedToolCallIds: Record = {};
+ autoAllowedToolCalls: Record = {};
+ autoDeniedToolCalls: Record = {};
sessionAllowedCommands: Set = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = [];
@@ -922,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;
@@ -934,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;
@@ -984,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;
@@ -1065,6 +1180,7 @@ export async function* streamAgent({
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
+ let codeMode: 'claude' | 'codex' | null = null;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
@@ -1092,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,
},
@@ -1157,6 +1279,7 @@ export async function* streamAgent({
signal,
abortRegistry,
publish: (event) => bus.publish(event),
+ codeMode,
});
}
} catch (error) {
@@ -1213,6 +1336,9 @@ export async function* streamAgent({
if (msg.searchEnabled) {
searchEnabled = true;
}
+ // Code mode is per-message: latest message decides whether the assistant
+ // should route coding work through the code-with-agents skill / chosen agent.
+ codeMode = msg.codeMode ?? null;
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
@@ -1220,6 +1346,10 @@ export async function* streamAgent({
// latest user message. If the user closed the pane between messages, clear it.
middlePaneContext = msg.middlePaneContext ?? null;
loopLogger.log('dequeued user message', msg.messageId);
+ const userMessageContext = buildUserMessageContext({
+ agentName: state.agentName,
+ middlePaneContext,
+ });
yield* processEvent({
runId,
type: "message",
@@ -1227,6 +1357,7 @@ export async function* streamAgent({
message: {
role: "user",
content: msg.message,
+ userMessageContext,
},
subflow: [],
});
@@ -1248,17 +1379,7 @@ export async function* streamAgent({
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
- const now = new Date();
- const currentDateTime = now.toLocaleString('en-US', {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- timeZoneName: 'short'
- });
- let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
+ let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
// Inject Agent Notes context for copilot
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
const agentNotesContext = loadAgentNotesContext();
@@ -1287,19 +1408,6 @@ Use absolute paths rooted at this directory with the \`file-*\` tools. For examp
Do not announce the work directory unless it's relevant. Just use it.`;
}
- // Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
- // that supersedes any earlier middle-pane mention in the conversation history.
- const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
- if (!middlePaneContext) {
- loopLogger.log('injecting middle pane context (empty)');
- instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
- } else if (middlePaneContext.kind === 'note') {
- loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
- instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
- } else if (middlePaneContext.kind === 'browser') {
- loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
- instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
- }
}
if (voiceInput) {
loopLogger.log('voice input enabled, injecting voice input prompt');
@@ -1316,6 +1424,25 @@ Do not announce the work directory unless it's relevant. Just use it.`;
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
+ if (codeMode) {
+ loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
+ const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
+ 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 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.
+
+**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.
+
+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.`;
+ }
let streamError: string | null = null;
for await (const event of streamLlm(
model,
@@ -1366,16 +1493,22 @@ Do not announce the work directory unless it's relevant. Just use it.`;
// 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];
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
+ const rawOptions = (part.arguments as { options?: unknown }).options;
+ const options = Array.isArray(rawOptions)
+ ? rawOptions.filter((o): o is string => typeof o === 'string' && o.trim().length > 0)
+ : undefined;
yield* processEvent({
runId,
type: "ask-human-request",
toolCallId: part.toolCallId,
query: part.arguments.question,
+ ...(options && options.length > 0 ? { options } : {}),
subflow: [],
});
}
@@ -1386,14 +1519,7 @@ Do not announce the work directory unless it's relevant. Just use it.`;
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);
@@ -1417,6 +1543,87 @@ Do not announce the work directory unless it's relevant. Just use it.`;
}
}
}
+
+ 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/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index b0d1bcbb..b3611fe4 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
+import container from "../../di/container.js";
+import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
`;
}
-function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
+function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
@@ -80,7 +82,9 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
-**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
+${codeModeEnabled
+ ? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
+ : `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
@@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
/** Keep backward-compatible export for any external consumers */
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
-/**
- * Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
- */
let cachedInstructions: string | null = null;
-/**
- * Invalidate the cached instructions so the next buildCopilotInstructions() call
- * regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
- */
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;
}
-/**
- * Build full copilot instructions with dynamic Composio tools section.
- * Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
- */
export async function buildCopilotInstructions(): Promise {
if (cachedInstructions !== null) return cachedInstructions;
const composioEnabled = await isComposioConfigured();
- const catalog = composioEnabled
- ? skillCatalog
- : buildSkillCatalog({ excludeIds: ['composio-integration'] });
- const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
+ let codeModeEnabled = false;
+ try {
+ const repo = container.resolve('codeModeConfigRepo');
+ codeModeEnabled = (await repo.getConfig()).enabled;
+ } catch {
+ // repo unavailable — default to disabled
+ }
+ const excludeIds: string[] = [];
+ if (!composioEnabled) excludeIds.push('composio-integration');
+ if (!codeModeEnabled) excludeIds.push('code-with-agents');
+ const catalog = excludeIds.length > 0
+ ? buildSkillCatalog({ excludeIds })
+ : skillCatalog;
+ const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
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 c2879228..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
@@ -1,90 +1,98 @@
export const skill = String.raw`
# Code with Agents Skill
-Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
+Use this skill whenever the user asks you to write code, build a project, create scripts, fix bugs, read/explain code, or do any software development task — even simple file creations like "make a .c file".
-## Important: delegate ALL coding work
+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.
-Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
-- Writing, editing, or refactoring code
-- Reading, summarizing, or explaining code
-- Debugging and fixing bugs
-- Running tests or build commands
-- Exploring project structure
-- Any other task that involves interacting with a codebase
+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.
-Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
+---
-## Prerequisites
+## STEP 1 — MANDATORY FIRST ACTION
-The user must have one of the following installed on their machine:
-- **Claude Code** — https://claude.ai/code
-- **Codex** — https://codex.openai.com
+Look in your **system context** for a section titled **"# Code Mode (Active)"**.
-These are external tools that you cannot install for the user.
+### Case A — "# Code Mode (Active)" IS present
-## Workflow
+Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human.
-### Step 1: Gather requirements
+### Case B — "# Code Mode (Active)" is NOT present
-Before running anything, confirm the following with the user:
+Your **very next tool call MUST be \`ask-human\`** with options. Do not write any explanation text first. Do not describe a plan. Do not check the workspace boundary. Just call:
-1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
-2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
+\`\`\`
+ask-human({
+ question: "How should I handle this coding request?",
+ options: [
+ "Use code mode (Claude Code)",
+ "Use code mode (Codex)",
+ "Continue with default Rowboat"
+ ]
+})
+\`\`\`
-### Step 2: Confirm execution plan
+This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here.
-Once you know the folder and agent, tell the user:
+**Branch on the response:**
+- "Use code mode (Claude Code)" → proceed to Step 2 with agent = \`claude\`.
+- "Use code mode (Codex)" → proceed to Step 2 with agent = \`codex\`.
+- "Continue with default Rowboat" → ABANDON this skill. Handle the request yourself using your own tools (workspace file tools, \`executeCommand\` shell, etc.). The rest of this skill does not apply for this turn.
-> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
+---
-### Step 3: Execute with acpx
+## STEP 2 — Resolve workdir, then run
-Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
+**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?"
-**For Claude Code:**
-` + "`" + `
-npx acpx@latest --approve-all --cwd claude exec ""
-` + "`" + `
+**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.
-**For Codex:**
-` + "`" + `
-npx acpx@latest --approve-all --cwd codex exec ""
-` + "`" + `
+**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:
-### Critical: flag order
+> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
-The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
+…and then immediately call:
-` + "`" + `
-npx acpx@latest [global flags] exec ""
-` + "`" + `
+\`\`\`
+code_agent_run({
+ agent: "",
+ cwd: "",
+ prompt: ""
+})
+\`\`\`
-**Correct:**
-` + "`" + `
-npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
-` + "`" + `
+**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 instructions.
-**Wrong (will fail):**
-` + "`" + `
-npx acpx@latest claude --approve-all exec "fix the bug"
-` + "`" + `
+**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.
-### Writing good prompts
+---
-When constructing the prompt for the coding agent:
-- Be specific and detailed about what to build or fix
-- Include file names, function signatures, and expected behavior
-- Mention any constraints (language, framework, style)
-- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
+## STEP 3 — Report results
-### Step 4: Report results
+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 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.
-After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
+---
-Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
+## Once delegating: delegate fully
-- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
+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)
+
+The user must have one of these installed locally — these are external tools you cannot install:
+- Claude Code — https://claude.ai/code
+- Codex — https://codex.openai.com
`;
export default skill;
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 e3622b01..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,43 +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 {
- const pathDirs = (process.env.PATH ?? '').split(';');
- for (const dir of pathDirs) {
- const trimmed = dir.trim();
- if (!trimmed) continue;
- const cmdPath = path.join(trimmed, 'claude.cmd');
- if (!existsSync(cmdPath)) continue;
- const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
- if (existsSync(exeFromLayout)) return exeFromLayout;
- 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(trimmed, 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: {
@@ -788,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,
@@ -845,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 150c8f72..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,6 +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
}
): Promise {
try {
@@ -89,6 +90,7 @@ export async function executeCommand(
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell,
+ env: options?.env,
});
return {
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/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts
index b3d2affa..a513b3ac 100644
--- a/apps/x/packages/core/src/application/lib/message-queue.ts
+++ b/apps/x/packages/core/src/application/lib/message-queue.ts
@@ -8,17 +8,20 @@ export type MiddlePaneContext =
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string };
+export type CodeMode = 'claude' | 'codex';
+
type EnqueuedMessage = {
messageId: string;
message: UserMessageContentType;
voiceInput?: boolean;
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
+ codeMode?: CodeMode;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
- enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise;
+ enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise;
dequeue(runId: string): Promise;
}
@@ -34,7 +37,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
- async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise {
+ async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise {
if (!this.store[runId]) {
this.store[runId] = [];
}
@@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceInput,
voiceOutput,
searchEnabled,
+ codeMode,
middlePaneContext,
});
return id;
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/index.ts b/apps/x/packages/core/src/code-mode/index.ts
new file mode 100644
index 00000000..bdf2eecb
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/index.ts
@@ -0,0 +1,3 @@
+export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
+export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
+export { checkCodeModeAgentStatus } from './status.js';
diff --git a/apps/x/packages/core/src/code-mode/repo.ts b/apps/x/packages/core/src/code-mode/repo.ts
new file mode 100644
index 00000000..78092db8
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/repo.ts
@@ -0,0 +1,47 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { WorkDir } from '../config/config.js';
+import { CodeModeConfig } from './types.js';
+import { checkCodeModeAgentStatus } from './status.js';
+
+export interface ICodeModeConfigRepo {
+ getConfig(): Promise;
+ setConfig(config: CodeModeConfig): Promise;
+}
+
+export class FSCodeModeConfigRepo implements ICodeModeConfigRepo {
+ private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json');
+ private agentReadyPromise: Promise | null = null;
+
+ // Reuse the existing agent check (Claude Code / Codex installed + signed in),
+ // cached for the process lifetime so we probe (shell + keychain) at most once
+ // per session rather than on every getConfig call.
+ private agentReady(): Promise {
+ if (!this.agentReadyPromise) {
+ this.agentReadyPromise = checkCodeModeAgentStatus()
+ .then((s) =>
+ (s.claude.installed && s.claude.signedIn)
+ || (s.codex.installed && s.codex.signedIn))
+ .catch(() => false);
+ }
+ return this.agentReadyPromise;
+ }
+
+ async getConfig(): Promise {
+ try {
+ // The file only exists once the user has explicitly toggled code mode
+ // in settings — always honor that choice.
+ const content = await fs.readFile(this.configPath, 'utf8');
+ return CodeModeConfig.parse(JSON.parse(content));
+ } catch {
+ // No explicit choice yet: enable automatically when a coding agent is ready.
+ return { enabled: await this.agentReady() };
+ }
+ }
+
+ async setConfig(config: CodeModeConfig): Promise {
+ const validated = CodeModeConfig.parse(config);
+ await fs.mkdir(path.dirname(this.configPath), { recursive: true });
+ await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
+ }
+}
diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts
new file mode 100644
index 00000000..a78b23f4
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/status.ts
@@ -0,0 +1,199 @@
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import os from 'os';
+import path from 'path';
+import fs from 'fs/promises';
+import { existsSync } from 'fs';
+import { CodeModeAgentStatus } from './types.js';
+
+const execAsync = promisify(exec);
+
+// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn.
+// 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).
+export function commonInstallPaths(binary: string): string[] {
+ const home = os.homedir();
+ if (process.platform === 'win32') {
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
+ return [
+ path.join(appData, 'npm', `${binary}.cmd`),
+ path.join(appData, 'npm', `${binary}.exe`),
+ path.join(localAppData, 'npm', `${binary}.cmd`),
+ path.join(localAppData, 'pnpm', `${binary}.cmd`),
+ path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`),
+ path.join(programFiles, 'nodejs', `${binary}.cmd`),
+ path.join(home, '.volta', 'bin', `${binary}.cmd`),
+ ];
+ }
+ return [
+ '/usr/local/bin',
+ '/opt/homebrew/bin', // Apple Silicon Homebrew
+ '/usr/bin',
+ path.join(home, '.npm-global', 'bin'),
+ path.join(home, '.local', 'bin'),
+ path.join(home, '.volta', 'bin'),
+ path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
+ path.join(home, 'bin'),
+ ].map(dir => path.join(dir, binary));
+}
+
+async function probeShell(binary: string): Promise {
+ try {
+ if (process.platform === 'win32') {
+ const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
+ return stdout.trim().length > 0;
+ }
+ // Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
+ // essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
+ const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
+ return stdout.trim().length > 0;
+ } catch {
+ return false;
+ }
+}
+
+async function isInstalled(binary: string): Promise {
+ if (await probeShell(binary)) return true;
+ // Fallback: scan well-known install locations directly.
+ for (const candidate of commonInstallPaths(binary)) {
+ if (existsSync(candidate)) return true;
+ }
+ return false;
+}
+
+function decodeJwtPayload(token: string): Record | null {
+ try {
+ const parts = token.split('.');
+ if (parts.length < 2) return null;
+ const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
+ const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
+ const parsed = JSON.parse(json);
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record : null;
+ } catch {
+ return null;
+ }
+}
+
+// Given the raw credentials JSON (from a file or the macOS Keychain), decide
+// whether it represents a usable signed-in state: a valid API key, an unexpired
+// access token, or a refresh token (which can mint a new access token).
+function isClaudeCredentialSignedIn(raw: string): boolean {
+ try {
+ const parsed = JSON.parse(raw) as Record;
+
+ const oauth = parsed.claudeAiOauth as Record | undefined;
+ if (oauth) {
+ const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
+ const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : '';
+ if (refresh.length > 0) return true;
+ if (access.length > 0) {
+ if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) {
+ return false;
+ }
+ return true;
+ }
+ }
+
+ if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true;
+ if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true;
+ } catch {
+ // malformed JSON
+ }
+ return false;
+}
+
+// Reads Claude Code's credentials from the macOS login Keychain, where the
+// CLI stores them on macOS (service "Claude Code-credentials"). On Linux/Windows
+// it uses the ~/.claude/.credentials.json file instead, so this is a no-op there.
+//
+// Caveats:
+// - The first read by this app (a different binary than the `claude` CLI that
+// created the item) triggers a one-time macOS authorization dialog; the user
+// must "Always Allow". Headless/SSH sessions can't show it and will fail.
+// - If CLAUDE_CONFIG_DIR is set, Claude appends a SHA-256 suffix to the service
+// name, which this lookup won't match — such setups usually keep the file too.
+async function readClaudeKeychainCredential(): Promise {
+ if (process.platform !== 'darwin') return null;
+ try {
+ const { stdout } = await execAsync(
+ `security find-generic-password -s "Claude Code-credentials" -w`,
+ { timeout: 5000 },
+ );
+ const out = stdout.trim();
+ return out.length > 0 ? out : null;
+ } catch {
+ // not present in keychain
+ return null;
+ }
+}
+
+// Validates Claude Code auth. On macOS the credentials live in the login
+// Keychain; on Linux/Windows in ~/.claude/.credentials.json (or ~/.config
+// fallback). We check both so detection works across platforms.
+async function checkClaudeSignedIn(): Promise {
+ const home = os.homedir();
+ const candidates = [
+ path.join(home, '.claude', '.credentials.json'),
+ path.join(home, '.config', 'claude', '.credentials.json'),
+ ];
+ for (const full of candidates) {
+ try {
+ const raw = await fs.readFile(full, 'utf-8');
+ if (isClaudeCredentialSignedIn(raw)) return true;
+ } catch {
+ // try next candidate
+ }
+ }
+
+ // macOS: credentials are stored in the Keychain rather than on disk.
+ const keychainRaw = await readClaudeKeychainCredential();
+ if (keychainRaw && isClaudeCredentialSignedIn(keychainRaw)) return true;
+
+ return false;
+}
+
+// Validates Codex auth at ~/.codex/auth.json on all platforms.
+// Considered signed in if API key set, or a refresh_token / access_token
+// exists. id_token expiry is intentionally NOT used as a rejection signal —
+// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks.
+async function checkCodexSignedIn(): Promise {
+ const home = os.homedir();
+ const full = path.join(home, '.codex', 'auth.json');
+ try {
+ const raw = await fs.readFile(full, 'utf-8');
+ const parsed = JSON.parse(raw) as Record;
+
+ if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
+
+ const tokens = parsed.tokens as Record | undefined;
+ if (tokens) {
+ const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '';
+ const access = typeof tokens.access_token === 'string' ? tokens.access_token : '';
+ const id = typeof tokens.id_token === 'string' ? tokens.id_token : '';
+ if (refresh.length > 0 || access.length > 0 || id.length > 0) return true;
+ }
+ } catch {
+ // file missing or unreadable
+ }
+ return false;
+}
+
+// Exported for diagnostics — silenced unused-var warning by re-export only.
+export { decodeJwtPayload };
+
+export async function checkCodeModeAgentStatus(): Promise {
+ const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
+ isInstalled('claude'),
+ isInstalled('codex'),
+ checkClaudeSignedIn(),
+ checkCodexSignedIn(),
+ ]);
+ return {
+ claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
+ codex: { installed: codexInstalled, signedIn: codexSignedIn },
+ };
+}
diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts
new file mode 100644
index 00000000..f52ae813
--- /dev/null
+++ b/apps/x/packages/core/src/code-mode/types.ts
@@ -0,0 +1,22 @@
+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;
+
+export const AgentStatus = z.object({
+ installed: z.boolean(),
+ signedIn: z.boolean(),
+});
+export type AgentStatus = z.infer;
+
+export const CodeModeAgentStatus = z.object({
+ claude: AgentStatus,
+ codex: AgentStatus,
+});
+export type CodeModeAgentStatus = z.infer;
diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts
index 9382de8b..d7b17ce7 100644
--- a/apps/x/packages/core/src/di/container.ts
+++ b/apps/x/packages/core/src/di/container.ts
@@ -11,10 +11,13 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
+import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
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";
@@ -38,9 +41,16 @@ container.register({
oauthRepo: asClass(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(),
+ codeModeConfigRepo: asClass(FSCodeModeConfigRepo).singleton(),
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 = [
+ '
');
+ });
+
+ it('regenerates html from clean text if only the text boundary is detected', () => {
+ const result = sanitizeReplyBodyForGmailReply(
+ '