diff --git a/electron/chat/claude.test.ts b/electron/chat/claude.test.ts index 39bd4543..130cf233 100644 --- a/electron/chat/claude.test.ts +++ b/electron/chat/claude.test.ts @@ -276,6 +276,20 @@ describe('Claude chat adapter', () => { }); }); + // The mode is otherwise known only from a turn's init message, and the chat + // view reads it to decide whether a mode can be picked. + it('reports the bypass mode as soon as it connects, before any turn', async () => { + const h = harness([], undefined, { skipPermissions: true }); + await h.chat.start(); + expect(h.chat.state.permissionMode).toBe('bypassPermissions'); + }); + + it('reports no mode before a turn when it does not bypass', async () => { + const h = harness([], undefined, { permissionMode: 'acceptEdits' }); + await h.chat.start(); + expect(h.chat.state.permissionMode).toBeUndefined(); + }); + it('runs the mode the user picked for this chat, and only then overrides their settings', async () => { const h = harness([], undefined, { permissionMode: 'acceptEdits' }); await h.chat.start(); diff --git a/electron/chat/claude.ts b/electron/chat/claude.ts index 4b10064b..c0b4d248 100644 --- a/electron/chat/claude.ts +++ b/electron/chat/claude.ts @@ -164,6 +164,10 @@ export class ClaudeChat implements AgentChat { if (this.isClosed()) throw new Error(this.state.error ?? 'Claude disconnected while connecting.'); this.noteUnadoptedSettingsMode(settingsMode); + // Claude reports its permission mode only in each turn's init message, so + // until the first prompt the state would not say this session bypasses + // permissions. It is known from how the session was launched. + if (this.opts.skipPermissions) this.state.permissionMode = 'bypassPermissions'; this.state.status = 'ready'; this.publish(); void this.refreshContextUsage(); diff --git a/src/components/AgentChatView.tsx b/src/components/AgentChatView.tsx index 7dde9b48..5f0b651d 100644 --- a/src/components/AgentChatView.tsx +++ b/src/components/AgentChatView.tsx @@ -297,7 +297,9 @@ export function AgentChatView(props: { ...callbacks, onReview: props.onReview ? reviewFile : undefined, permissionMode: state()?.permissionMode ?? props.task.chatPermissionMode, - permissionsDisabled: props.task.skipPermissions, + // The session's own mode, not the task flag: the flag can now change while a + // chat runs, and the session keeps the permissions it was started with. + permissionsDisabled: state()?.permissionMode === 'bypassPermissions', onPermissionMode: provider() === 'claude' ? selectPermissionMode : undefined, agentName: agentName(), connection: connected, diff --git a/src/components/TaskTitleBar.tsx b/src/components/TaskTitleBar.tsx index 97535c93..edc6da00 100644 --- a/src/components/TaskTitleBar.tsx +++ b/src/components/TaskTitleBar.tsx @@ -9,6 +9,7 @@ import { getTaskAttentionState, toggleTaskFocusMode, clearTaskLandingReview, + setTaskSkipPermissions, getPrChecks, getVerifyCommand, isTaskCanvasVisible, @@ -31,6 +32,7 @@ import { getTaskDockerBadgeLabel } from '../lib/docker'; import { displayTaskNameFromPrompt, shouldUsePromptDerivedTaskName } from '../lib/clean-task-name'; import type { Task } from '../store/types'; import { isLandedTaskState } from '../store/landing'; +import { resolveSkipPermissionsArgs } from '../../electron/shared/skip-permissions'; // Kinds without an entry stay silent: a configured-but-never-run command on // every task would be noise, and cancelled runs carry no signal. @@ -56,6 +58,23 @@ interface TaskTitleBarProps { export function TaskTitleBar(props: TaskTitleBarProps) { const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerSource); const isLandedTask = () => isLandedTaskState(props.task.landingState); + // Offered when any of the task's agents takes a skip-permissions flag, resolved + // by command so a definition restored without its flags still qualifies. + // Not for coordinators: sub-task propagation is fixed when the coordinator + // registers, so a change there would only partly apply until the next launch. + const offersSkipPermissionsToggle = () => + !props.task.coordinatorMode && + !isLandedTask() && + props.task.agentIds.some((id) => { + const def = store.agents[id]?.def; + return !!def && resolveSkipPermissionsArgs(def).length > 0; + }); + const skipPermissionsOn = () => props.task.skipPermissions === true; + const skipPermissionsTitle = () => + (skipPermissionsOn() + ? "Skips the agent's permission prompts. Click to turn off." + : "Uses the agent's own permission settings. Click to skip its prompts.") + + ' Takes effect the next time the agent starts.'; const landingBadge = () => { switch (props.task.landingState) { case 'landed_pending_review': @@ -239,6 +258,21 @@ export function TaskTitleBar(props: TaskTitleBarProps) { )} + + +
diff --git a/src/store/store.ts b/src/store/store.ts index 595c7c7e..fe098bbe 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -61,6 +61,7 @@ export { setInitialPrompt, clearPrefillPrompt, clearTaskLandingReview, + setTaskSkipPermissions, setPrefillPrompt, reorderTask, reorderTaskVisually, diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 5e93b35a..beb66fb6 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -171,6 +171,7 @@ import { markTaskMcpError, retryTaskMcpStartup, clearTaskLandingReview, + setTaskSkipPermissions, toggleAITerminalLayout, reorderTaskVisually, createAgentRecord, @@ -1930,3 +1931,34 @@ describe('mergeTask / pushTask preconditions', () => { expect(mockInvoke).not.toHaveBeenCalled(); }); }); + +describe('setTaskSkipPermissions', () => { + beforeEach(() => { + mockTasks['skip-task'] = { agentIds: [], shellAgentIds: [], skipPermissions: false }; + }); + + it('turns it on for an existing task and saves', () => { + setTaskSkipPermissions('skip-task', true); + expect(mockTasks['skip-task'].skipPermissions).toBe(true); + expect(mockSaveState).toHaveBeenCalledTimes(1); + }); + + it('turns it back off and saves', () => { + mockTasks['skip-task'].skipPermissions = true; + setTaskSkipPermissions('skip-task', false); + expect(mockTasks['skip-task'].skipPermissions).toBe(false); + expect(mockSaveState).toHaveBeenCalledTimes(1); + }); + + // An absent flag, from a task created before it existed, reads as off. + it.each([false, undefined])('does not save when it is already off (%s)', (current) => { + mockTasks['skip-task'].skipPermissions = current; + setTaskSkipPermissions('skip-task', false); + expect(mockSaveState).not.toHaveBeenCalled(); + }); + + it('ignores an unknown task', () => { + setTaskSkipPermissions('nope', true); + expect(mockSaveState).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 5cf77843..00106dd6 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -872,6 +872,17 @@ export function clearStagedNotification(taskId: string): void { setStore('tasks', taskId, 'stagedNotification', undefined); } +/** Change whether an existing task's agent launches with its skip-permissions + * flag. Takes effect the next time the agent starts; a running agent keeps the + * permissions it was launched with. */ +export function setTaskSkipPermissions(taskId: string, enabled: boolean): void { + const task = store.tasks[taskId]; + if (!task) return; + if ((task.skipPermissions ?? false) === enabled) return; + setStore('tasks', taskId, 'skipPermissions', enabled); + void saveState(); +} + export function clearTaskLandingReview(taskId: string): void { const task = store.tasks[taskId]; if (!task) return;