From 1b2001944c08b48ce0d8191c9456419debd43e4b Mon Sep 17 00:00:00 2001 From: Mia Date: Sat, 19 Sep 2026 04:52:03 -0400 Subject: [PATCH] feat(tasks): let skip-permissions be changed on an existing task Whether a task's agent launches with its skip-permissions flag is decided when the task is created and fixed for the life of the task. Changing it meant closing the task and starting again, losing its worktree and conversation, and the title bar did not show which tasks were running with the flag. - `setTaskSkipPermissions(taskId, enabled)` changes it and saves. It takes effect the next time the agent starts: a terminal restart or resume, or a new or reconnected chat. A running agent keeps the permissions it was launched with. - The title bar shows the setting as a clickable badge after the verification badge, styled like the landing badge: "skip confirms" in the warning colour when on, "confirms on" muted when off. It appears when one of the task's agents takes such a flag, resolved by command so a definition restored without its flags still qualifies, and not on landed tasks. - Tasks that start other tasks do not get the badge: coordinators and delegation parents. Their children take the separate `propagateSkipPermissions` setting (fixed when a coordinator's MCP server registers), so turning the flag off there would stop only the parent's own agent from skipping prompts while its children went on skipping them. This matches the title bar's existing `!coordinatorMode && !delegationParent` checks. - The chat view's permission-mode selector now follows the running session's own mode instead of the task flag. The flag can now change while a chat runs, and the session refuses a mode change based on how it was started, so reading the task flag would enable the selector for a session that rejects it. Claude reports its mode only in each turn's init message, so a session launched skipping permissions now sets `bypassPermissions` itself when it connects; without that the selector would be enabled until the first prompt. A Codex chat resumed after turning the flag off is sent no approval policy on `thread/resume`, as before, so it may keep the policy the thread started with. Whether Codex keeps it across a resume was not verified here; a new chat, or a terminal restart, applies the change. 7 tests: on, off, no save when already off or absent, an unknown task, and the Claude session reporting its mode on connect (fails without the change) or not, when it does not bypass. --- electron/chat/claude.test.ts | 14 +++++++++++++ electron/chat/claude.ts | 4 ++++ src/components/AgentChatView.tsx | 4 +++- src/components/TaskTitleBar.tsx | 36 ++++++++++++++++++++++++++++++++ src/store/store.ts | 1 + src/store/tasks.test.ts | 32 ++++++++++++++++++++++++++++ src/store/tasks.ts | 11 ++++++++++ 7 files changed, 101 insertions(+), 1 deletion(-) 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 450ec404..7c8884bf 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,25 @@ 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 tasks that start other tasks: their children take the separate + // propagateSkipPermissions setting (fixed at registration for a coordinator), + // so turning this off there would stop only the parent's own agent skipping. + const offersSkipPermissionsToggle = () => + !props.task.coordinatorMode && + !props.task.delegationParent && + !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 +260,21 @@ export function TaskTitleBar(props: TaskTitleBarProps) { )} + + +
diff --git a/src/store/store.ts b/src/store/store.ts index 2249e387..8cf0e9f4 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -62,6 +62,7 @@ export { setInitialPrompt, clearPrefillPrompt, clearTaskLandingReview, + setTaskSkipPermissions, setPrefillPrompt, reorderTask, reorderTaskVisually, diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 845f7ac1..e66f8081 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -170,6 +170,7 @@ import { markTaskMcpError, retryTaskMcpStartup, clearTaskLandingReview, + setTaskSkipPermissions, toggleAITerminalLayout, reorderTaskVisually, createAgentRecord, @@ -2022,3 +2023,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 8474a7f1..438ff460 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -800,6 +800,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;