Skip to content

Commit 6a09b4f

Browse files
authored
feat: Add "bypass permissions" mode + execution mode selector (#645)
### What changed? Adding "bypassPermissions" execution mode to allow Claude to skip permission prompts. Can't live without it. Addresses #645. ![Screenshot 2026-01-27 at 13.55.14.png](https://app.graphite.com/user-attachments/assets/99693973-2dac-44c8-aa53-683c35e1ee67.png) ----- I hijacked your PRs @Twixes, love this 100%! 🥇 I disabled it by default and put it behind a setting, when they enable the setting I also give them a talking to about safe AI usage 😆 Similar flow to claude code w/ ``claude --dangerously-skip-permissions``, will be a once-on type thing for power users. We are starting to onboarding early adopters and Jonathan working on revamping the permissions persistence and prompt, so I wanna make sure we have good happy paths here. Was able to fix merge conflicts, tests + lint, we should be able to get this in soon! Let me know any thoughts/feedback <img width="595" height="164" alt="Screenshot 2026-01-27 at 2 00 57 PM" src="https://github.com/user-attachments/assets/8f7d2a7d-63ca-4807-8acd-d34e286d52b6" /> <img width="544" height="378" alt="Screenshot 2026-01-27 at 1 59 54 PM" src="https://github.com/user-attachments/assets/2612b96e-7cea-453c-b8fc-af31158faddc" />
1 parent 5ebea01 commit 6a09b4f

File tree

17 files changed

+537
-104
lines changed

17 files changed

+537
-104
lines changed

PROBLEM.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Agent Process Lifecycle Bug
2+
3+
## Problem Summary
4+
5+
Multiple interrelated bugs causing:
6+
1. **Duplicate agents** - Multiple agent processes run for same task, causing interleaved responses
7+
2. **App hangs on shutdown** - Cleanup waits indefinitely for unresponsive agent processes
8+
3. **Orphaned processes** - Agent subprocesses not properly terminated
9+
10+
## Root Causes
11+
12+
### Race Condition #1: Renderer (sessionStore.ts:955-1012)
13+
```typescript
14+
if (connectAttempts.has(taskId)) return; // Line 956 - check
15+
// ... async work ...
16+
connectAttempts.add(taskId); // Line 1012 - add (too late!)
17+
```
18+
Two rapid calls both pass the check before either adds to the set.
19+
20+
### Race Condition #2: Main Process (service.ts:388-497)
21+
```typescript
22+
const existing = this.sessions.get(taskRunId); // Line 389 - check
23+
if (existing) return existing;
24+
// ... 100+ lines of async work ...
25+
this.sessions.set(taskRunId, session); // Line 497 - set (too late!)
26+
```
27+
Also: Sessions keyed by `taskRunId`, not `taskId` - two runs for same task both create agents.
28+
29+
### No Cleanup Timeout (app-lifecycle/service.ts:22-40)
30+
```typescript
31+
await container.unbindAll(); // Line 26 - can hang forever
32+
```
33+
If agent subprocess doesn't respond, cleanup never completes, app never quits.
34+
35+
---
36+
37+
## Key Files
38+
39+
| File | Role |
40+
|------|------|
41+
| `apps/twig/src/main/services/agent/service.ts` | Main process agent management - sessions Map, getOrCreateSession, cleanupSession |
42+
| `apps/twig/src/renderer/features/sessions/stores/sessionStore.ts` | Renderer session management - connectAttempts Set, connectToTask |
43+
| `apps/twig/src/main/services/app-lifecycle/service.ts` | App shutdown - calls container.unbindAll() |
44+
| `packages/agent/src/agent.ts` | Agent wrapper - cleanup() method |
45+
| `packages/agent/src/adapters/acp-connection.ts` | ACP connection - actual cleanup logic |
46+
47+
---
48+
49+
## Fix Plan
50+
51+
### Phase 1: Main Process Mutex + Kill-Before-Create
52+
- Add `sessionsByTaskId` Map to track by taskId (not just taskRunId)
53+
- Add `pendingCreations` Map to prevent race conditions
54+
- Before creating new session, clean up any existing session for same taskId
55+
56+
### Phase 2: Cleanup Timeout
57+
- Race `agent.cleanup()` against 5-second timeout
58+
- Call `forceCleanup()` if timeout
59+
60+
### Phase 3: Force Cleanup Method
61+
- Add `forceCleanup()` to Agent class that aborts the session controller
62+
63+
### Phase 4: App Shutdown Timeout
64+
- Add 10-second overall timeout to `shutdown()`
65+
66+
### Phase 5: Renderer Mutex
67+
- Replace `connectAttempts` Set with Promise-based locking
68+
- Check "connecting" status, not just "connected"
69+
70+
---
71+
72+
## Debug Log Location
73+
74+
When the app hangs on shutdown, check:
75+
```
76+
~/Library/Logs/twig/main.log
77+
```
78+
79+
Or tail it live:
80+
```bash
81+
tail -f ~/Library/Logs/twig/main.log | grep -E "(AGENT_DEBUG|cleanupSession|getOrCreateSession|shutdown)"
82+
```

apps/twig/src/main/services/agent/schemas.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
RequestPermissionRequest,
33
PermissionOption as SdkPermissionOption,
44
} from "@agentclientprotocol/sdk";
5+
import { executionModeSchema } from "@shared/types";
56
import { z } from "zod";
67

78
// Session credentials schema
@@ -13,10 +14,6 @@ export const credentialsSchema = z.object({
1314

1415
export type Credentials = z.infer<typeof credentialsSchema>;
1516

16-
// Execution mode schema
17-
export const executionModeSchema = z.enum(["plan", "acceptEdits", "default"]);
18-
export type ExecutionMode = z.infer<typeof executionModeSchema>;
19-
2017
// Session config schema
2118
export const sessionConfigSchema = z.object({
2219
taskId: z.string(),
@@ -45,7 +42,9 @@ export const startSessionInput = z.object({
4542
permissionMode: z.string().optional(),
4643
autoProgress: z.boolean().optional(),
4744
model: z.string().optional(),
48-
executionMode: z.enum(["plan", "acceptEdits", "default"]).optional(),
45+
executionMode: z
46+
.enum(["default", "acceptEdits", "plan", "bypassPermissions"])
47+
.optional(),
4948
runMode: z.enum(["local", "cloud"]).optional(),
5049
/** Additional directories Claude can access beyond cwd (for worktree support) */
5150
additionalDirectories: z.array(z.string()).optional(),
@@ -146,7 +145,7 @@ export const setModelInput = z.object({
146145
// Set mode input
147146
export const setModeInput = z.object({
148147
sessionId: z.string(),
149-
modeId: z.enum(["plan", "default", "acceptEdits"]),
148+
modeId: executionModeSchema,
150149
});
151150

152151
// Subscribe to session events input

apps/twig/src/main/services/agent/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
2020
import type { OnLogCallback } from "@posthog/agent/types";
2121
import { app } from "electron";
2222
import { injectable, preDestroy } from "inversify";
23+
import type { ExecutionMode } from "@/shared/types.js";
2324
import type { AcpMessage } from "../../../shared/types/session-events.js";
2425
import { logger } from "../../lib/logger.js";
2526
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
@@ -166,7 +167,7 @@ interface SessionConfig {
166167
logUrl?: string;
167168
sdkSessionId?: string;
168169
model?: string;
169-
executionMode?: "plan" | "acceptEdits" | "default";
170+
executionMode?: ExecutionMode;
170171
/** Additional directories Claude can access beyond cwd (for worktree support) */
171172
additionalDirectories?: string[];
172173
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Circle } from "@phosphor-icons/react";
2+
import { Flex, Text } from "@radix-ui/themes";
3+
import { trpcVanilla } from "@renderer/trpc";
4+
import { useQuery } from "@tanstack/react-query";
5+
6+
interface DiffStatsIndicatorProps {
7+
repoPath: string | null | undefined;
8+
}
9+
10+
export function DiffStatsIndicator({ repoPath }: DiffStatsIndicatorProps) {
11+
const { data: diffStats } = useQuery({
12+
queryKey: ["diff-stats", repoPath],
13+
queryFn: () =>
14+
trpcVanilla.git.getDiffStats.query({
15+
directoryPath: repoPath as string,
16+
}),
17+
enabled: !!repoPath,
18+
staleTime: 5000,
19+
refetchInterval: 5000,
20+
placeholderData: (prev) => prev,
21+
});
22+
23+
if (!diffStats || diffStats.filesChanged === 0) {
24+
return null;
25+
}
26+
27+
return (
28+
<Flex align="center" gap="2">
29+
<Circle size={4} weight="fill" color="var(--gray-9)" />
30+
<Text
31+
size="1"
32+
style={{
33+
color: "var(--gray-11)",
34+
fontFamily: "monospace",
35+
}}
36+
>
37+
{diffStats.filesChanged}{" "}
38+
{diffStats.filesChanged === 1 ? "file" : "files"}
39+
</Text>
40+
<Text
41+
size="1"
42+
style={{
43+
color: "var(--green-9)",
44+
fontFamily: "monospace",
45+
}}
46+
>
47+
+{diffStats.linesAdded}
48+
</Text>
49+
<Text
50+
size="1"
51+
style={{
52+
color: "var(--red-9)",
53+
fontFamily: "monospace",
54+
}}
55+
>
56+
-{diffStats.linesRemoved}
57+
</Text>
58+
</Flex>
59+
);
60+
}

apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useDraftStore } from "../stores/draftStore";
1010
import { useTiptapEditor } from "../tiptap/useTiptapEditor";
1111
import type { EditorHandle } from "../types";
1212
import type { EditorContent as EditorContentType } from "../utils/content";
13+
import { DiffStatsIndicator } from "./DiffStatsIndicator";
1314
import { EditorToolbar } from "./EditorToolbar";
1415
import { ModeIndicatorInput } from "./ModeIndicatorInput";
1516

@@ -26,7 +27,7 @@ interface MessageEditorProps {
2627
onAttachFiles?: (files: File[]) => void;
2728
autoFocus?: boolean;
2829
currentMode?: ExecutionMode;
29-
onModeChange?: () => void;
30+
onModeChange?: (mode: ExecutionMode) => void;
3031
}
3132

3233
export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
@@ -206,7 +207,13 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
206207
</Flex>
207208
</Flex>
208209
{onModeChange && currentMode && (
209-
<ModeIndicatorInput mode={currentMode} taskId={taskId} />
210+
<Flex align="center" gap="2">
211+
<ModeIndicatorInput
212+
mode={currentMode}
213+
onModeChange={onModeChange}
214+
/>
215+
<DiffStatsIndicator repoPath={repoPath} />
216+
</Flex>
210217
)}
211218
</Flex>
212219
);

0 commit comments

Comments
 (0)