From 28e4bf8749f9416970a8ffd9a1a34a629531a2ac Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:57:23 -0700 Subject: [PATCH 1/4] feat: use Claude Code auto mode instead of bypass permissions for autonomous tasks (#532) --- src/core/agent-catalog.ts | 2 +- src/terminal/agent-session-adapters.ts | 10 ++-- .../terminal/agent-session-adapters.test.ts | 59 ++++++++++++++++++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/core/agent-catalog.ts b/src/core/agent-catalog.ts index 8f92b7114a..0ddbd44905 100644 --- a/src/core/agent-catalog.ts +++ b/src/core/agent-catalog.ts @@ -15,7 +15,7 @@ export const RUNTIME_AGENT_CATALOG: RuntimeAgentCatalogEntry[] = [ label: "Claude Code", binary: "claude", baseArgs: [], - autonomousArgs: ["--dangerously-skip-permissions"], + autonomousArgs: ["--permission-mode", "auto"], installUrl: "https://docs.anthropic.com/en/docs/claude-code/quickstart", }, { diff --git a/src/terminal/agent-session-adapters.ts b/src/terminal/agent-session-adapters.ts index 75a0d856a4..fce1889b6f 100644 --- a/src/terminal/agent-session-adapters.ts +++ b/src/terminal/agent-session-adapters.ts @@ -610,12 +610,17 @@ const claudeAdapter: AgentSessionAdapter = { FORCE_HYPERLINK: "1", }; const appendedSystemPrompt = resolveHomeAgentAppendSystemPrompt(input.taskId); + if (input.autonomousModeEnabled) { + // Auto mode is gated behind this env var on Bedrock/Vertex/Foundry; the Anthropic API ignores it. + env.CLAUDE_CODE_ENABLE_AUTO_MODE = "1"; + } if ( input.autonomousModeEnabled && !input.startInPlanMode && + !hasCliOption(args, "--permission-mode") && !hasCliOption(args, "--dangerously-skip-permissions") ) { - args.push("--dangerously-skip-permissions"); + args.push("--permission-mode", "auto"); } if (input.resumeFromTrash && !hasCliOption(args, "--continue")) { args.push("--continue"); @@ -624,9 +629,6 @@ const claudeAdapter: AgentSessionAdapter = { const withoutImmediateBypass = args.filter((arg) => arg !== "--dangerously-skip-permissions"); args.length = 0; args.push(...withoutImmediateBypass); - if (!hasCliOption(args, "--allow-dangerously-skip-permissions")) { - args.push("--allow-dangerously-skip-permissions"); - } args.push("--permission-mode", "plan"); } diff --git a/test/runtime/terminal/agent-session-adapters.test.ts b/test/runtime/terminal/agent-session-adapters.test.ts index 864a69ae2c..9178deed6d 100644 --- a/test/runtime/terminal/agent-session-adapters.test.ts +++ b/test/runtime/terminal/agent-session-adapters.test.ts @@ -648,7 +648,11 @@ describe("prepareAgentLaunch hook strategies", () => { cwd: "/tmp", prompt: "", }); - expect(claudeLaunch.args).toContain("--dangerously-skip-permissions"); + const permissionModeIndex = claudeLaunch.args.indexOf("--permission-mode"); + expect(permissionModeIndex).toBeGreaterThan(-1); + expect(claudeLaunch.args[permissionModeIndex + 1]).toBe("auto"); + expect(claudeLaunch.args).not.toContain("--dangerously-skip-permissions"); + expect(claudeLaunch.env.CLAUDE_CODE_ENABLE_AUTO_MODE).toBe("1"); const codexLaunch = await prepareAgentLaunch({ taskId: "task-codex-auto", @@ -695,6 +699,59 @@ describe("prepareAgentLaunch hook strategies", () => { expect(clineLaunch.args).toContain("--auto-approve-all"); }); + it("does not add a Claude permission mode when args already set one", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-claude-explicit-mode", + agentId: "claude", + binary: "claude", + args: ["--permission-mode", "acceptEdits"], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "", + }); + expect(launch.args.filter((arg) => arg === "--permission-mode")).toHaveLength(1); + expect(launch.args).not.toContain("auto"); + }); + + it("starts Claude plan mode without bypass flags and keeps auto mode reachable", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-claude-plan", + agentId: "claude", + binary: "claude", + args: [], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "", + startInPlanMode: true, + }); + const permissionModeIndex = launch.args.indexOf("--permission-mode"); + expect(permissionModeIndex).toBeGreaterThan(-1); + expect(launch.args[permissionModeIndex + 1]).toBe("plan"); + expect(launch.args).not.toContain("--dangerously-skip-permissions"); + expect(launch.args).not.toContain("--allow-dangerously-skip-permissions"); + expect(launch.env.CLAUDE_CODE_ENABLE_AUTO_MODE).toBe("1"); + }); + + it("strips an explicit Claude bypass arg in plan mode", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-claude-plan-bypass", + agentId: "claude", + binary: "claude", + args: ["--dangerously-skip-permissions"], + autonomousModeEnabled: false, + cwd: "/tmp", + prompt: "", + startInPlanMode: true, + }); + expect(launch.args).not.toContain("--dangerously-skip-permissions"); + expect(launch.args).not.toContain("--allow-dangerously-skip-permissions"); + const permissionModeIndex = launch.args.indexOf("--permission-mode"); + expect(launch.args[permissionModeIndex + 1]).toBe("plan"); + }); + it("preserves explicit autonomous args when autonomous mode is disabled", async () => { setupTempHome(); From 651a9b1b8d20c9092cd85effed930e67049ff559 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:04:53 -0700 Subject: [PATCH 2/4] v0.1.69 release notes --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f183350fcd..a8de3f0fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [0.1.69] + +- Claude Code autonomous tasks now run in Claude Code's built-in "auto" permission mode instead of fully bypassing permissions, for safer unattended runs +- Updated protobufjs to 7.5.8 to clear known security vulnerabilities + ## [0.1.68] - Codex hooks are now pre-trusted, eliminating permission prompts when Kanban manages Codex sessions diff --git a/package-lock.json b/package-lock.json index 44d2564fcc..2b2214af6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kanban", - "version": "0.1.68", + "version": "0.1.69", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kanban", - "version": "0.1.68", + "version": "0.1.69", "license": "Apache-2.0", "dependencies": { "@clinebot/core": "^0.0.38", diff --git a/package.json b/package.json index 9ecf003f9a..d3231c5c8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kanban", - "version": "0.1.68", + "version": "0.1.69", "description": "A kanban foundation for coding agents", "publishConfig": { "access": "public", From cf853c049c2c618db009d6ecfc5f8c2ca19a60ae Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:35:59 -0700 Subject: [PATCH 3/4] fix: bound clear-done cleanup concurrency to avoid runtime freeze on large boards (#564) --- web-ui/package-lock.json | 28 +++++ web-ui/package.json | 1 + .../src/hooks/use-board-interactions.test.tsx | 102 +++++++++++++++++- web-ui/src/hooks/use-board-interactions.ts | 19 +++- 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 41e0db7615..ac4ceae6b5 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -34,6 +34,7 @@ "fzf": "^0.5.2", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "p-limit": "^7.3.0", "posthog-js": "^1.357.1", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -5601,6 +5602,21 @@ ], "license": "MIT" }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -7099,6 +7115,18 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index e7aeec3584..c947bf0382 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -40,6 +40,7 @@ "fzf": "^0.5.2", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "p-limit": "^7.3.0", "posthog-js": "^1.357.1", "prismjs": "^1.30.0", "react": "^18.3.1", diff --git a/web-ui/src/hooks/use-board-interactions.test.tsx b/web-ui/src/hooks/use-board-interactions.test.tsx index 931c5e60d4..4767d02496 100644 --- a/web-ui/src/hooks/use-board-interactions.test.tsx +++ b/web-ui/src/hooks/use-board-interactions.test.tsx @@ -69,6 +69,7 @@ interface HookSnapshot { handleRestoreTaskFromTrash: (taskId: string) => void; handleStartTask: (taskId: string) => void; handleCardSelect: (taskId: string) => void; + handleConfirmClearTrash: () => void; } function createRect(width: number, height: number): DOMRect { @@ -90,6 +91,8 @@ function HookHarness({ setBoard, ensureTaskWorkspace, startTaskSession, + stopTaskSession = NOOP_STOP_SESSION, + cleanupTaskWorkspace = NOOP_CLEANUP_WORKSPACE, selectedCard = null, setSelectedTaskIdOverride, onSnapshot, @@ -98,6 +101,8 @@ function HookHarness({ setBoard: Dispatch>; ensureTaskWorkspace: UseTaskSessionsResult["ensureTaskWorkspace"]; startTaskSession: UseTaskSessionsResult["startTaskSession"]; + stopTaskSession?: (taskId: string) => Promise; + cleanupTaskWorkspace?: (taskId: string) => Promise; selectedCard?: { card: BoardCard; column: { id: "backlog" | "in_progress" | "review" | "trash" } } | null; setSelectedTaskIdOverride?: Dispatch>; onSnapshot?: (snapshot: HookSnapshot) => void; @@ -118,8 +123,8 @@ function HookHarness({ setSelectedTaskId: setSelectedTaskIdOverride ?? setSelectedTaskId, setIsClearTrashDialogOpen, setIsGitHistoryOpen, - stopTaskSession: NOOP_STOP_SESSION, - cleanupTaskWorkspace: NOOP_CLEANUP_WORKSPACE, + stopTaskSession, + cleanupTaskWorkspace, ensureTaskWorkspace, startTaskSession, fetchTaskWorkspaceInfo: NOOP_FETCH_WORKSPACE_INFO, @@ -134,8 +139,15 @@ function HookHarness({ handleRestoreTaskFromTrash: actions.handleRestoreTaskFromTrash, handleStartTask: actions.handleStartTask, handleCardSelect: actions.handleCardSelect, + handleConfirmClearTrash: actions.handleConfirmClearTrash, }); - }, [actions.handleCardSelect, actions.handleRestoreTaskFromTrash, actions.handleStartTask, onSnapshot]); + }, [ + actions.handleCardSelect, + actions.handleConfirmClearTrash, + actions.handleRestoreTaskFromTrash, + actions.handleStartTask, + onSnapshot, + ]); return null; } @@ -677,4 +689,88 @@ describe("useBoardInteractions", () => { expect(setSelectedTaskId).not.toHaveBeenCalled(); }); + + it("bounds clear-trash cleanup concurrency while still cleaning up every task", async () => { + let latestSnapshot: HookSnapshot | null = null; + + useProgrammaticCardMovesMock.mockReturnValue({ + handleProgrammaticCardMoveReady: () => {}, + setRequestMoveTaskToTrashHandler: () => {}, + tryProgrammaticCardMove: () => "unavailable", + consumeProgrammaticCardMove: () => ({}), + resolvePendingProgrammaticTrashMove: () => {}, + waitForProgrammaticCardMoveAvailability: async () => {}, + resetProgrammaticCardMoves: () => {}, + requestMoveTaskToTrashWithAnimation: async () => {}, + programmaticCardMoveCycle: 0, + }); + + useLinkedBacklogTaskActionsMock.mockReturnValue({ + handleCreateDependency: () => {}, + handleDeleteDependency: () => {}, + confirmMoveTaskToTrash: async () => {}, + requestMoveTaskToTrash: async () => {}, + }); + + const trashTaskCount = 25; + const trashTasks = Array.from({ length: trashTaskCount }, (_, index) => + createTask(`task-trash-${index}`, `Trash task ${index}`, index + 1), + ); + const board: BoardData = { + columns: [ + { id: "backlog", title: "Backlog", cards: [] }, + { id: "in_progress", title: "In Progress", cards: [] }, + { id: "review", title: "Review", cards: [] }, + { id: "trash", title: "Done", cards: trashTasks }, + ], + dependencies: [], + }; + + // Track how many per-task cleanup chains (stop -> cleanup) run at once. + let inFlight = 0; + let maxInFlight = 0; + const stopTaskSession = vi.fn(async (_taskId: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + }); + const cleanupTaskWorkspace = vi.fn(async (_taskId: string) => { + await Promise.resolve(); + inFlight -= 1; + return null; + }); + + await act(async () => { + root.render( + board} + ensureTaskWorkspace={async () => ({ ok: true as const })} + startTaskSession={async () => ({ ok: true as const })} + stopTaskSession={stopTaskSession} + cleanupTaskWorkspace={cleanupTaskWorkspace} + onSnapshot={(snapshot) => { + latestSnapshot = snapshot; + }} + />, + ); + }); + + if (!latestSnapshot) { + throw new Error("Expected a hook snapshot."); + } + + await act(async () => { + latestSnapshot!.handleConfirmClearTrash(); + }); + + expect(stopTaskSession).toHaveBeenCalledTimes(trashTaskCount); + expect(cleanupTaskWorkspace).toHaveBeenCalledTimes(trashTaskCount); + expect(maxInFlight).toBeGreaterThan(0); + expect(maxInFlight).toBeLessThanOrEqual(4); + for (const task of trashTasks) { + expect(stopTaskSession).toHaveBeenCalledWith(task.id); + expect(cleanupTaskWorkspace).toHaveBeenCalledWith(task.id); + } + }); }); diff --git a/web-ui/src/hooks/use-board-interactions.ts b/web-ui/src/hooks/use-board-interactions.ts index 562f8b31db..b871704615 100644 --- a/web-ui/src/hooks/use-board-interactions.ts +++ b/web-ui/src/hooks/use-board-interactions.ts @@ -1,4 +1,5 @@ import type { DropResult } from "@hello-pangea/dnd"; +import pLimit from "p-limit"; import type { Dispatch, SetStateAction } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { notifyError, showAppToast } from "@/components/app-toaster"; @@ -28,6 +29,13 @@ import { requestBrowserNotificationPermission, } from "@/utils/notification-permission"; +// Clearing the Done column fires stopTaskSession + cleanupTaskWorkspace per task. +// The tRPC client batches same-tick calls into one request, so an unbounded +// Promise.all makes the server run every stop/worktree-delete concurrently — +// with a large column that means 100+ simultaneous git operations against the +// shared repo, which can freeze or crash the runtime. Bound the fan-out instead. +const CLEAR_TRASH_CLEANUP_CONCURRENCY = 4; + interface TaskGitActionLoadingStateLike { commitSource: string | null; prSource: string | null; @@ -847,12 +855,15 @@ export function useBoardInteractions({ clearTaskWorkspaceInfo(selectedTaskId); } + const limitCleanup = pLimit(CLEAR_TRASH_CLEANUP_CONCURRENCY); void (async () => { await Promise.all( - taskIds.map(async (taskId) => { - await stopTaskSession(taskId); - await cleanupTaskWorkspace(taskId); - }), + taskIds.map((taskId) => + limitCleanup(async () => { + await stopTaskSession(taskId); + await cleanupTaskWorkspace(taskId); + }), + ), ); })(); }, [ From 87cfd6420344d188ae2251adf705acfd16d46be2 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:39:15 -0700 Subject: [PATCH 4/4] v0.1.70 release notes --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8de3f0fd8..e70c2aa641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [0.1.70] + +- Fixed a freeze that could occur when clearing completed tasks on large boards by limiting cleanup concurrency + ## [0.1.69] - Claude Code autonomous tasks now run in Claude Code's built-in "auto" permission mode instead of fully bypassing permissions, for safer unattended runs diff --git a/package-lock.json b/package-lock.json index 2b2214af6d..1a7748d866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kanban", - "version": "0.1.69", + "version": "0.1.70", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kanban", - "version": "0.1.69", + "version": "0.1.70", "license": "Apache-2.0", "dependencies": { "@clinebot/core": "^0.0.38", diff --git a/package.json b/package.json index d3231c5c8e..20c61150e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kanban", - "version": "0.1.69", + "version": "0.1.70", "description": "A kanban foundation for coding agents", "publishConfig": { "access": "public",