Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# 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
- 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kanban",
"version": "0.1.69",
"version": "0.1.70",
"description": "A kanban foundation for coding agents",
"publishConfig": {
"access": "public",
Expand Down
2 changes: 1 addition & 1 deletion src/core/agent-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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",
modelFlag: "--model",
modelOptions: [
Expand Down
10 changes: 6 additions & 4 deletions src/terminal/agent-session-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,12 +613,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");
Expand All @@ -627,9 +632,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");
}

Expand Down
59 changes: 58 additions & 1 deletion test/runtime/terminal/agent-session-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,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",
Expand Down Expand Up @@ -703,6 +707,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();

Expand Down
28 changes: 28 additions & 0 deletions web-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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",
Expand Down
102 changes: 99 additions & 3 deletions web-ui/src/hooks/use-board-interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -90,6 +91,8 @@ function HookHarness({
setBoard,
ensureTaskWorkspace,
startTaskSession,
stopTaskSession = NOOP_STOP_SESSION,
cleanupTaskWorkspace = NOOP_CLEANUP_WORKSPACE,
selectedCard = null,
setSelectedTaskIdOverride,
onSnapshot,
Expand All @@ -98,6 +101,8 @@ function HookHarness({
setBoard: Dispatch<SetStateAction<BoardData>>;
ensureTaskWorkspace: UseTaskSessionsResult["ensureTaskWorkspace"];
startTaskSession: UseTaskSessionsResult["startTaskSession"];
stopTaskSession?: (taskId: string) => Promise<void>;
cleanupTaskWorkspace?: (taskId: string) => Promise<unknown>;
selectedCard?: { card: BoardCard; column: { id: "backlog" | "in_progress" | "review" | "trash" } } | null;
setSelectedTaskIdOverride?: Dispatch<SetStateAction<string | null>>;
onSnapshot?: (snapshot: HookSnapshot) => void;
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(
<HookHarness
board={board}
setBoard={() => 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);
}
});
});
19 changes: 15 additions & 4 deletions web-ui/src/hooks/use-board-interactions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -848,12 +856,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);
}),
),
);
})();
}, [
Expand Down