diff --git a/adapters/whatsapp/__tests__/session.test.ts b/adapters/whatsapp/__tests__/session.test.ts
new file mode 100644
index 00000000..a93d9d4a
--- /dev/null
+++ b/adapters/whatsapp/__tests__/session.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'bun:test'
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import {
+ clearWhatsAppAuth,
+ closeWhatsAppSocket,
+ getWhatsAppDisconnectStatus,
+ hasWhatsAppAuth,
+ isWhatsAppLoggedOut,
+ waitForWhatsAppCredsSave,
+} from '../session.js'
+import type { WhatsAppSocket } from '../session.js'
+
+function makeTempAuthDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'wa-session-test-'))
+}
+
+describe('whatsapp session helpers', () => {
+ it('detects and clears persisted auth credentials', () => {
+ const authDir = makeTempAuthDir()
+
+ expect(hasWhatsAppAuth(authDir)).toBe(false)
+
+ fs.writeFileSync(path.join(authDir, 'creds.json'), '{}')
+ expect(hasWhatsAppAuth(authDir)).toBe(true)
+
+ clearWhatsAppAuth(authDir)
+ expect(fs.existsSync(authDir)).toBe(false)
+ })
+
+ it('extracts disconnect status from Baileys and direct error shapes', () => {
+ expect(getWhatsAppDisconnectStatus({ output: { statusCode: 401 } })).toBe(401)
+ expect(getWhatsAppDisconnectStatus({ statusCode: 515 })).toBe(515)
+ expect(getWhatsAppDisconnectStatus({ output: { statusCode: '401' } })).toBeUndefined()
+ expect(getWhatsAppDisconnectStatus(null)).toBeUndefined()
+ expect(isWhatsAppLoggedOut({ output: { statusCode: 401 } })).toBe(true)
+ expect(isWhatsAppLoggedOut({ output: { statusCode: 515 } })).toBe(false)
+ })
+
+ it('closes sockets best-effort with a reason', () => {
+ let received: Error | undefined
+ const sock = {
+ end: (error?: Error) => {
+ received = error
+ },
+ } as unknown as WhatsAppSocket
+
+ closeWhatsAppSocket(sock, 'test close')
+ expect(received?.message).toBe('test close')
+
+ expect(() => closeWhatsAppSocket({} as WhatsAppSocket)).not.toThrow()
+ expect(() => closeWhatsAppSocket({
+ end: () => {
+ throw new Error('already closed')
+ },
+ } as unknown as WhatsAppSocket)).not.toThrow()
+ })
+
+ it('returns immediately when no credential save is queued', async () => {
+ await expect(waitForWhatsAppCredsSave(makeTempAuthDir())).resolves.toBeUndefined()
+ })
+})
diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx
index 1297b596..703cf8de 100644
--- a/desktop/src/components/chat/PermissionDialog.tsx
+++ b/desktop/src/components/chat/PermissionDialog.tsx
@@ -5,6 +5,12 @@ import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { Button } from '../shared/Button'
import { DiffViewer } from './DiffViewer'
+import {
+ PlanPreviewCard,
+ buildPromptPermissionUpdates,
+ extractPlanPreview,
+ isExitPlanModeTool,
+} from './PlanModePreview'
type Props = {
sessionId?: string | null
@@ -121,6 +127,18 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
const isPending = pendingPermission?.requestId === requestId
const [showRaw, setShowRaw] = useState(false)
+ if (isExitPlanModeTool(toolName)) {
+ return (
+
+ )
+ }
+
const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' }
const details = extractToolDetails(toolName, input, t)
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
@@ -262,3 +280,104 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
)
}
+
+function ExitPlanModePermissionDialog({
+ sessionId,
+ requestId,
+ input,
+ description,
+ isPending,
+}: {
+ sessionId?: string | null
+ requestId: string
+ input: unknown
+ description?: string
+ isPending: boolean
+}) {
+ const { respondToPermission } = useChatStore()
+ const t = useTranslation()
+ const [feedback, setFeedback] = useState('')
+ const preview = extractPlanPreview(input)
+ const permissionUpdates = buildPromptPermissionUpdates(preview.allowedPrompts)
+ const trimmedFeedback = feedback.trim()
+
+ return (
+
+
+
+ architecture
+
+
+
+
+ {t('permission.planReadyTitle')}
+
+ {isPending ? (
+
+
+ {t('permission.awaitingApproval')}
+
+ ) : (
+
+ {t('permission.responded')}
+
+ )}
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+
+ {isPending ? (
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx
new file mode 100644
index 00000000..c76908fc
--- /dev/null
+++ b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx
@@ -0,0 +1,190 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+
+const { sendMock } = vi.hoisted(() => ({
+ sendMock: vi.fn(),
+}))
+
+vi.mock('../../api/websocket', () => ({
+ wsManager: {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ onMessage: vi.fn(() => () => {}),
+ clearHandlers: vi.fn(),
+ send: sendMock,
+ },
+}))
+
+import { useChatStore } from '../../stores/chatStore'
+import { useSettingsStore } from '../../stores/settingsStore'
+import { useTabStore } from '../../stores/tabStore'
+import { PermissionDialog } from './PermissionDialog'
+import { ToolCallBlock } from './ToolCallBlock'
+
+const PLAN = [
+ '# Release checklist',
+ '',
+ '1. Update the desktop plan modal.',
+ '2. Run `bun run check:desktop`.',
+ '',
+ '```bash',
+ 'bun test desktop/src/components/chat/PlanModePermissionDialog.test.tsx',
+ '```',
+].join('\n')
+
+function seedPendingPlanPermission() {
+ useChatStore.setState({
+ sessions: {
+ 'session-1': {
+ messages: [],
+ chatState: 'permission_pending',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: {
+ requestId: 'perm-plan',
+ toolName: 'ExitPlanMode',
+ toolUseId: 'toolu-plan',
+ input: {
+ plan: PLAN,
+ planFilePath: '/tmp/claude-plan.md',
+ allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }],
+ },
+ description: 'Exit plan mode?',
+ },
+ pendingComputerUsePermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ agentTaskNotifications: {},
+ backgroundAgentTasks: {},
+ elapsedTimer: null,
+ composerPrefill: null,
+ composerInsertion: null,
+ composerDraft: null,
+ },
+ },
+ })
+}
+
+describe('plan mode permission UI', () => {
+ beforeEach(() => {
+ sendMock.mockReset()
+ useSettingsStore.setState({ locale: 'en' })
+ useTabStore.setState({
+ activeTabId: 'session-1',
+ tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session' as const, status: 'idle' }],
+ })
+ seedPendingPlanPermission()
+ })
+
+ it('renders ExitPlanMode as a plan preview instead of raw tool input', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container.textContent).toContain('Ready to code?')
+ expect(container.textContent).toContain('Release checklist')
+ expect(container.textContent).toContain('Update the desktop plan modal.')
+ expect(container.textContent).toContain('/tmp/claude-plan.md')
+ expect(container.textContent).toContain('Requested permissions')
+ expect(container.textContent).toContain('Bash')
+ expect(container.textContent).toContain('run tests')
+ expect(screen.getByRole('button', { name: 'Approve plan' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Keep planning' })).toBeTruthy()
+ expect(container.textContent).not.toContain('"allowedPrompts"')
+ })
+
+ it('sends typed feedback when the user keeps planning', () => {
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByPlaceholderText('Tell Claude what to change'), {
+ target: { value: 'Add a rollback step before implementation.' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Keep planning' }))
+
+ expect(sendMock).toHaveBeenCalledWith('session-1', {
+ type: 'permission_response',
+ requestId: 'perm-plan',
+ allowed: false,
+ denyMessage: 'Add a rollback step before implementation.',
+ })
+ })
+
+ it('includes requested prompt permissions when approving the plan', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Approve plan' }))
+
+ expect(sendMock).toHaveBeenCalledWith('session-1', {
+ type: 'permission_response',
+ requestId: 'perm-plan',
+ allowed: true,
+ permissionUpdates: [
+ {
+ type: 'addRules',
+ rules: [{ toolName: 'Bash', ruleContent: 'prompt: run tests' }],
+ behavior: 'allow',
+ destination: 'session',
+ },
+ ],
+ })
+ })
+
+ it('renders approved ExitPlanMode results as a markdown plan card', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container.textContent).toContain('Plan approved')
+ expect(container.textContent).toContain('Release checklist')
+ expect(container.textContent).toContain('Update the desktop plan modal.')
+ expect(container.textContent).toContain('/tmp/claude-plan.md')
+ expect(container.textContent).not.toContain('Tool Output')
+ })
+})
diff --git a/desktop/src/components/chat/PlanModePreview.tsx b/desktop/src/components/chat/PlanModePreview.tsx
new file mode 100644
index 00000000..4b1b9299
--- /dev/null
+++ b/desktop/src/components/chat/PlanModePreview.tsx
@@ -0,0 +1,173 @@
+import { FileText, ShieldCheck } from 'lucide-react'
+import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
+import type { PermissionUpdate } from '../../types/chat'
+
+export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
+
+export type AllowedPrompt = {
+ tool: string
+ prompt: string
+}
+
+export type PlanPreviewModel = {
+ plan: string
+ filePath: string
+ allowedPrompts: AllowedPrompt[]
+}
+
+type Props = {
+ title: string
+ plan: string
+ filePath?: string
+ allowedPrompts?: AllowedPrompt[]
+ requestedPermissionsTitle?: string
+ emptyLabel?: string
+}
+
+export function isExitPlanModeTool(toolName: string): boolean {
+ return toolName === EXIT_PLAN_MODE_TOOL_NAME
+}
+
+export function PlanPreviewCard({
+ title,
+ plan,
+ filePath,
+ allowedPrompts = [],
+ requestedPermissionsTitle,
+ emptyLabel = 'No plan content available.',
+}: Props) {
+ const trimmedPlan = plan.trim()
+
+ return (
+
+
+
+
+
+ {title}
+
+ {filePath ? (
+
+ {filePath}
+
+ ) : null}
+
+
+
+
+ {trimmedPlan ? (
+
+ ) : (
+
{emptyLabel}
+ )}
+
+
+ {allowedPrompts.length > 0 && requestedPermissionsTitle ? (
+
+
+
+ {requestedPermissionsTitle}
+
+
+ {allowedPrompts.map((prompt, index) => (
+
+
+ {prompt.tool}
+
+ ·
+ {prompt.prompt}
+
+ ))}
+
+
+ ) : null}
+
+ )
+}
+
+export function extractPlanPreview(input: unknown, resultContent?: unknown): PlanPreviewModel {
+ const inputRecord = asRecord(input)
+ const resultText = extractTextContent(resultContent)
+ const approvedPlan = resultText ? extractApprovedPlan(resultText) : ''
+
+ return {
+ plan:
+ getString(inputRecord, 'plan') ||
+ getString(inputRecord, 'planContent') ||
+ approvedPlan,
+ filePath:
+ getString(inputRecord, 'planFilePath') ||
+ getString(inputRecord, 'filePath') ||
+ (resultText ? extractPlanFilePath(resultText) : ''),
+ allowedPrompts: extractAllowedPrompts(inputRecord.allowedPrompts),
+ }
+}
+
+export function buildPromptPermissionUpdates(allowedPrompts: AllowedPrompt[]): PermissionUpdate[] {
+ if (allowedPrompts.length === 0) return []
+
+ return [
+ {
+ type: 'addRules',
+ rules: allowedPrompts.map((prompt) => ({
+ toolName: prompt.tool,
+ ruleContent: `prompt: ${prompt.prompt.trim()}`,
+ })),
+ behavior: 'allow',
+ destination: 'session',
+ },
+ ]
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : {}
+}
+
+function getString(record: Record, key: string): string {
+ const value = record[key]
+ return typeof value === 'string' ? value : ''
+}
+
+function extractAllowedPrompts(value: unknown): AllowedPrompt[] {
+ if (!Array.isArray(value)) return []
+
+ return value.flatMap((item) => {
+ const record = asRecord(item)
+ const tool = getString(record, 'tool').trim()
+ const prompt = getString(record, 'prompt').trim()
+ return tool && prompt ? [{ tool, prompt }] : []
+ })
+}
+
+function extractApprovedPlan(text: string): string {
+ const match = /## Approved Plan(?: \(edited by user\))?:\s*\n([\s\S]*)$/i.exec(text)
+ return match?.[1]?.trim() ?? ''
+}
+
+function extractPlanFilePath(text: string): string {
+ const match = /^Your plan has been saved to:\s*(.+)$/m.exec(text)
+ return match?.[1]?.trim() ?? ''
+}
+
+function extractTextContent(content: unknown): string {
+ if (typeof content === 'string') return content
+ if (Array.isArray(content)) {
+ return content
+ .map((chunk) => {
+ if (typeof chunk === 'string') return chunk
+ if (chunk && typeof chunk === 'object' && 'text' in chunk) {
+ const text = (chunk as { text?: unknown }).text
+ return typeof text === 'string' ? text : ''
+ }
+ return ''
+ })
+ .filter(Boolean)
+ .join('\n')
+ }
+ return ''
+}
diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx
index 9f17da6d..81b2d7d7 100644
--- a/desktop/src/components/chat/ToolCallBlock.tsx
+++ b/desktop/src/components/chat/ToolCallBlock.tsx
@@ -8,6 +8,7 @@ import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { InlineImageGallery } from './InlineImageGallery'
import type { AgentTaskNotification } from '../../types/chat'
+import { PlanPreviewCard, extractPlanPreview, isExitPlanModeTool } from './PlanModePreview'
type Props = {
toolName: string
@@ -37,7 +38,8 @@ const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
- const [expanded, setExpanded] = useState(false)
+ const isPlanTool = isExitPlanModeTool(toolName)
+ const [expanded, setExpanded] = useState(isPlanTool)
const t = useTranslation()
const obj = input && typeof input === 'object' ? (input as Record) : {}
const icon = TOOL_ICONS[toolName] || 'build'
@@ -60,6 +62,19 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput)
+ if (isPlanTool) {
+ return (
+ setExpanded((value) => !value)}
+ />
+ )
+ }
+
return (
void
+}) {
+ const t = useTranslation()
+ const preview = extractPlanPreview(input, result?.content)
+ const title = result?.isError
+ ? t('permission.planRejected')
+ : result
+ ? t('permission.planApproved')
+ : t('permission.planReadyTitle')
+ const hasRawResult = Boolean(result && extractTextContent(result.content))
+
+ return (
+
+
+
+ {expanded ? (
+
+
+ {result?.isError && hasRawResult ? (
+ renderResultOutput(result, extractTextContent(result.content) ?? '', t)
+ ) : null}
+
+ ) : null}
+
+ )
+}
+
function renderPreview(
toolName: string,
obj: Record
,
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 4e1ea3d2..8722011a 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -1344,6 +1344,15 @@ export const en = {
'permission.hideDetails': 'Hide details',
'permission.showFullInput': 'Show full input',
'permission.replacingContent': 'Replacing content in file',
+ 'permission.planReadyTitle': 'Ready to code?',
+ 'permission.planPreviewTitle': "Claude's plan",
+ 'permission.planRequestedPermissions': 'Requested permissions',
+ 'permission.planApprove': 'Approve plan',
+ 'permission.planKeepPlanning': 'Keep planning',
+ 'permission.planFeedbackPlaceholder': 'Tell Claude what to change',
+ 'permission.planEmpty': 'No plan content available.',
+ 'permission.planApproved': 'Plan approved',
+ 'permission.planRejected': 'Plan rejected',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use wants to control these apps',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 9a383993..00f696af 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1346,6 +1346,15 @@ export const jp: Record = {
'permission.hideDetails': '詳細を非表示',
'permission.showFullInput': '入力全体を表示',
'permission.replacingContent': 'ファイル内のコンテンツを置換中',
+ 'permission.planReadyTitle': 'コーディングを開始しますか?',
+ 'permission.planPreviewTitle': 'Claude の計画',
+ 'permission.planRequestedPermissions': '要求された権限',
+ 'permission.planApprove': '計画を承認',
+ 'permission.planKeepPlanning': '計画を続ける',
+ 'permission.planFeedbackPlaceholder': 'Claude に変更内容を伝える',
+ 'permission.planEmpty': '計画内容はありません。',
+ 'permission.planApproved': '計画が承認されました',
+ 'permission.planRejected': '計画が拒否されました',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'コンピューター操作がこれらのアプリを操作しようとしています',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index e7e97abc..c401d458 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1346,6 +1346,15 @@ export const kr: Record = {
'permission.hideDetails': '세부 정보 숨기기',
'permission.showFullInput': '전체 입력 표시',
'permission.replacingContent': '파일 내용 교체 중',
+ 'permission.planReadyTitle': '코딩을 시작할까요?',
+ 'permission.planPreviewTitle': 'Claude의 계획',
+ 'permission.planRequestedPermissions': '요청된 권한',
+ 'permission.planApprove': '계획 승인',
+ 'permission.planKeepPlanning': '계속 계획하기',
+ 'permission.planFeedbackPlaceholder': 'Claude에게 변경할 내용을 알려주세요',
+ 'permission.planEmpty': '계획 내용이 없습니다.',
+ 'permission.planApproved': '계획 승인됨',
+ 'permission.planRejected': '계획 거부됨',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': '컴퓨터 사용이 이 앱들을 제어하려고 합니다',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index cad674a9..7a218ddd 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1346,6 +1346,15 @@ export const zh: Record = {
'permission.hideDetails': '隱藏詳情',
'permission.showFullInput': '顯示完整輸入',
'permission.replacingContent': '替換檔案內容',
+ 'permission.planReadyTitle': '準備開始編碼?',
+ 'permission.planPreviewTitle': 'Claude 的計劃',
+ 'permission.planRequestedPermissions': '請求的權限',
+ 'permission.planApprove': '批准計劃',
+ 'permission.planKeepPlanning': '繼續規劃',
+ 'permission.planFeedbackPlaceholder': '告訴 Claude 需要修改什麼',
+ 'permission.planEmpty': '暫無計劃內容。',
+ 'permission.planApproved': '計劃已批准',
+ 'permission.planRejected': '計劃已拒絕',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use 想控制這些應用',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 9533ab82..d0e6822f 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1346,6 +1346,15 @@ export const zh: Record = {
'permission.hideDetails': '隐藏详情',
'permission.showFullInput': '显示完整输入',
'permission.replacingContent': '替换文件内容',
+ 'permission.planReadyTitle': '准备开始编码?',
+ 'permission.planPreviewTitle': 'Claude 的计划',
+ 'permission.planRequestedPermissions': '请求的权限',
+ 'permission.planApprove': '批准计划',
+ 'permission.planKeepPlanning': '继续规划',
+ 'permission.planFeedbackPlaceholder': '告诉 Claude 需要修改什么',
+ 'permission.planEmpty': '暂无计划内容。',
+ 'permission.planApproved': '计划已批准',
+ 'permission.planRejected': '计划已拒绝',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use 想控制这些应用',
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 0803ce73..fc1dd402 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -30,6 +30,7 @@ import type {
UIMessage,
ServerMessage,
TokenUsage,
+ PermissionUpdate,
} from '../types/chat'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
@@ -146,6 +147,8 @@ type ChatStore = {
options?: {
rule?: string
updatedInput?: Record
+ denyMessage?: string
+ permissionUpdates?: PermissionUpdate[]
},
) => void
respondToComputerUsePermission: (
@@ -1004,6 +1007,8 @@ export const useChatStore = create((set, get) => ({
allowed,
...(options?.rule ? { rule: options.rule } : {}),
...(options?.updatedInput ? { updatedInput: options.updatedInput } : {}),
+ ...(options?.denyMessage ? { denyMessage: options.denyMessage } : {}),
+ ...(options?.permissionUpdates?.length ? { permissionUpdates: options.permissionUpdates } : {}),
})
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
},
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index 3288431b..8ee3a3f2 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -14,6 +14,8 @@ export type ClientMessage =
allowed: boolean
rule?: string
updatedInput?: Record
+ denyMessage?: string
+ permissionUpdates?: PermissionUpdate[]
}
| {
type: 'computer_use_permission_response'
@@ -38,6 +40,24 @@ export type AttachmentRef = {
quote?: string
}
+export type PermissionUpdate =
+ | {
+ type: 'addRules' | 'replaceRules' | 'removeRules'
+ rules: Array<{ toolName: string; ruleContent?: string }>
+ behavior: 'allow' | 'deny' | 'ask'
+ destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
+ }
+ | {
+ type: 'setMode'
+ mode: PermissionMode
+ destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
+ }
+ | {
+ type: 'addDirectories' | 'removeDirectories'
+ directories: string[]
+ destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
+ }
+
export type UIAttachment = {
type: 'file' | 'image'
name: string
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index 22df7232..de5d0e22 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -185,6 +185,97 @@ describe('ConversationService', () => {
})
})
+ it('should forward explicit permission updates from desktop plan approval', () => {
+ const svc = new ConversationService()
+ const sent: unknown[] = []
+ const permissionUpdates = [
+ {
+ type: 'addRules',
+ rules: [{ toolName: 'Bash', ruleContent: 'prompt: run tests' }],
+ behavior: 'allow',
+ destination: 'session',
+ },
+ ]
+
+ ;(svc as any).sessions.set('session-1', {
+ proc: null,
+ outputCallbacks: [],
+ workDir: process.cwd(),
+ sdkToken: 'token',
+ sdkSocket: {
+ send(data: string) {
+ sent.push(JSON.parse(data))
+ },
+ },
+ pendingOutbound: [],
+ stderrLines: [],
+ sdkMessages: [],
+ pendingPermissionRequests: new Map(),
+ })
+
+ const result = svc.respondToPermission(
+ 'session-1',
+ 'req-1',
+ true,
+ undefined,
+ undefined,
+ undefined,
+ permissionUpdates,
+ )
+
+ expect(result).toBe(true)
+ expect(sent[0]).toMatchObject({
+ type: 'control_response',
+ response: {
+ response: {
+ behavior: 'allow',
+ updatedPermissions: permissionUpdates,
+ },
+ },
+ })
+ })
+
+ it('should forward explicit denial feedback from desktop plan rejection', () => {
+ const svc = new ConversationService()
+ const sent: unknown[] = []
+
+ ;(svc as any).sessions.set('session-1', {
+ proc: null,
+ outputCallbacks: [],
+ workDir: process.cwd(),
+ sdkToken: 'token',
+ sdkSocket: {
+ send(data: string) {
+ sent.push(JSON.parse(data))
+ },
+ },
+ pendingOutbound: [],
+ stderrLines: [],
+ sdkMessages: [],
+ pendingPermissionRequests: new Map(),
+ })
+
+ const result = svc.respondToPermission(
+ 'session-1',
+ 'req-1',
+ false,
+ undefined,
+ undefined,
+ 'Add rollback steps before implementation.',
+ )
+
+ expect(result).toBe(true)
+ expect(sent[0]).toMatchObject({
+ type: 'control_response',
+ response: {
+ response: {
+ behavior: 'deny',
+ message: 'Add rollback steps before implementation.',
+ },
+ },
+ })
+ })
+
it('should send set_permission_mode requests to active sessions', () => {
const svc = new ConversationService()
const sent: unknown[] = []
diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts
index b26b6ffa..b1c848dd 100644
--- a/src/server/__tests__/e2e/business-flow.test.ts
+++ b/src/server/__tests__/e2e/business-flow.test.ts
@@ -712,13 +712,13 @@ describe('Business Flow: WebSocket Chat', () => {
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'user_message', content: 'test message' }))
}
- if (msg.type === 'status' && msg.state === 'idle' && messages.length > 3) {
+ if (msg.type === 'message_complete') {
ws.close()
resolve()
}
}
ws.onerror = () => { ws.close(); resolve() }
- setTimeout(() => { ws.close(); resolve() }, 5000)
+ setTimeout(() => { ws.close(); resolve() }, 15000)
})
const types = messages.map((m) => m.type)
@@ -731,7 +731,7 @@ describe('Business Flow: WebSocket Chat', () => {
// Should have thinking state first
const statusMsgs = messages.filter((m) => m.type === 'status')
expect(statusMsgs[0].state).toBe('thinking')
- })
+ }, 20000)
it('should handle ping/pong', async () => {
const messages: any[] = []
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts
index 8792309d..0945f2bf 100644
--- a/src/server/services/conversationService.ts
+++ b/src/server/services/conversationService.ts
@@ -465,6 +465,8 @@ export class ConversationService {
allowed: boolean,
rule?: string,
updatedInput?: Record,
+ denyMessage?: string,
+ permissionUpdates?: unknown[],
): boolean {
const session = this.sessions.get(sessionId)
const pendingRequest = session?.pendingPermissionRequests.get(requestId)
@@ -481,7 +483,9 @@ export class ConversationService {
? {
behavior: 'allow',
updatedInput: updatedInput ?? {},
- ...(rule === 'always' && pendingRequest
+ ...(Array.isArray(permissionUpdates) && permissionUpdates.length > 0
+ ? { updatedPermissions: permissionUpdates }
+ : rule === 'always' && pendingRequest
? {
updatedPermissions: [
...normalizeSessionPermissionUpdates(
@@ -492,7 +496,7 @@ export class ConversationService {
}
: {}),
}
- : { behavior: 'deny', message: 'User denied via UI' },
+ : { behavior: 'deny', message: denyMessage || 'User denied via UI' },
},
})
}
diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts
index 160262a6..e27dfe63 100644
--- a/src/server/ws/events.ts
+++ b/src/server/ws/events.ts
@@ -17,6 +17,8 @@ export type ClientMessage =
allowed: boolean
rule?: string
updatedInput?: Record
+ denyMessage?: string
+ permissionUpdates?: unknown[]
}
| {
type: 'computer_use_permission_response'
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index fff1c744..9ef7b7e6 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -570,6 +570,8 @@ function handlePermissionResponse(
message.allowed,
message.rule,
message.updatedInput,
+ message.denyMessage,
+ message.permissionUpdates,
)
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
}