Skip to content

Commit f7a372c

Browse files
committed
Add a setting to prevent sleep while the agent is working
1 parent 7765788 commit f7a372c

9 files changed

Lines changed: 163 additions & 15 deletions

File tree

apps/twig/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { NotificationService } from "../services/notification/service.js";
1717
import { OAuthService } from "../services/oauth/service.js";
1818
import { ProcessTrackingService } from "../services/process-tracking/service.js";
1919
import { ShellService } from "../services/shell/service.js";
20+
import { SleepService } from "../services/sleep/service.js";
2021
import { TaskLinkService } from "../services/task-link/service.js";
2122
import { UIService } from "../services/ui/service.js";
2223
import { UpdatesService } from "../services/updates/service.js";
@@ -43,6 +44,7 @@ container.bind(MAIN_TOKENS.GitService).to(GitService);
4344
container.bind(MAIN_TOKENS.NotificationService).to(NotificationService);
4445
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
4546
container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService);
47+
container.bind(MAIN_TOKENS.SleepService).to(SleepService);
4648
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
4749
container.bind(MAIN_TOKENS.UIService).to(UIService);
4850
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);

apps/twig/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const MAIN_TOKENS = Object.freeze({
2222
NotificationService: Symbol.for("Main.NotificationService"),
2323
OAuthService: Symbol.for("Main.OAuthService"),
2424
ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"),
25+
SleepService: Symbol.for("Main.SleepService"),
2526
ShellService: Symbol.for("Main.ShellService"),
2627
UIService: Symbol.for("Main.UIService"),
2728
UpdatesService: Symbol.for("Main.UpdatesService"),

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

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import { app } from "electron";
2222
import { inject, injectable, preDestroy } from "inversify";
2323
import type { ExecutionMode } from "@/shared/types.js";
2424
import type { AcpMessage } from "../../../shared/types/session-events.js";
25+
import { container } from "../../di/container.js";
2526
import { MAIN_TOKENS } from "../../di/tokens.js";
2627
import { logger } from "../../lib/logger.js";
2728
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
2829
import type { ProcessTrackingService } from "../process-tracking/service.js";
30+
import type { SleepService } from "../sleep/service.js";
2931
import {
3032
AgentServiceEvent,
3133
type AgentServiceEvents,
@@ -227,6 +229,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
227229
this.processTracking = processTracking;
228230
}
229231

232+
private get sleepService() {
233+
return container.get<SleepService>(MAIN_TOKENS.SleepService);
234+
}
235+
230236
public updateToken(newToken: string): void {
231237
this.currentToken = newToken;
232238

@@ -637,6 +643,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
637643

638644
session.lastActivityAt = Date.now();
639645
session.promptPending = true;
646+
this.sleepService.acquire(sessionId);
640647

641648
try {
642649
const result = await session.clientSideConnection.prompt({
@@ -663,6 +670,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
663670
throw err;
664671
} finally {
665672
session.promptPending = false;
673+
this.sleepService.release(sessionId);
666674
}
667675
}
668676

@@ -917,6 +925,7 @@ For git operations while detached:
917925
private async cleanupSession(taskRunId: string): Promise<void> {
918926
const session = this.sessions.get(taskRunId);
919927
if (session) {
928+
this.sleepService.release(taskRunId);
920929
try {
921930
await session.agent.cleanup();
922931
} catch {
@@ -986,21 +995,28 @@ For git operations while detached:
986995
// The claude.ts adapter only calls requestPermission when user input is needed.
987996
// (It handles auto-approve internally for acceptEdits/bypassPermissions modes)
988997
if (toolCallId) {
989-
return new Promise((resolve, reject) => {
990-
const key = `${taskRunId}:${toolCallId}`;
991-
service.pendingPermissions.set(key, {
992-
resolve,
993-
reject,
994-
sessionId: taskRunId,
995-
toolCallId,
996-
});
997-
998-
log.info("Emitting permission request to renderer", {
999-
sessionId: taskRunId,
1000-
toolCallId,
1001-
});
1002-
service.emit(AgentServiceEvent.PermissionRequest, params);
1003-
});
998+
service.sleepService.release(taskRunId);
999+
try {
1000+
return await new Promise<RequestPermissionResponse>(
1001+
(resolve, reject) => {
1002+
const key = `${taskRunId}:${toolCallId}`;
1003+
service.pendingPermissions.set(key, {
1004+
resolve,
1005+
reject,
1006+
sessionId: taskRunId,
1007+
toolCallId,
1008+
});
1009+
1010+
log.info("Emitting permission request to renderer", {
1011+
sessionId: taskRunId,
1012+
toolCallId,
1013+
});
1014+
service.emit(AgentServiceEvent.PermissionRequest, params);
1015+
},
1016+
);
1017+
} finally {
1018+
service.sleepService.acquire(taskRunId);
1019+
}
10041020
}
10051021

10061022
// Fallback: no toolCallId means we can't track the response, auto-approve

apps/twig/src/main/services/settingsStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Store from "electron-store";
77

88
interface SettingsSchema {
99
worktreeLocation: string;
10+
preventSleepWhileRunning: boolean;
1011
}
1112

1213
function getDefaultWorktreeLocation(): string {
@@ -50,6 +51,10 @@ const schema = {
5051
type: "string" as const,
5152
default: getDefaultWorktreeLocation(),
5253
},
54+
preventSleepWhileRunning: {
55+
type: "boolean" as const,
56+
default: false,
57+
},
5358
};
5459

5560
export const settingsStore = new Store<SettingsSchema>({
@@ -58,6 +63,7 @@ export const settingsStore = new Store<SettingsSchema>({
5863
cwd: app.getPath("userData"),
5964
defaults: {
6065
worktreeLocation: getDefaultWorktreeLocation(),
66+
preventSleepWhileRunning: false,
6167
},
6268
});
6369

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { powerSaveBlocker } from "electron";
2+
import { injectable, preDestroy } from "inversify";
3+
import { logger } from "../../lib/logger.js";
4+
import { settingsStore } from "../settingsStore.js";
5+
6+
const log = logger.scope("sleep");
7+
8+
@injectable()
9+
export class SleepService {
10+
private enabled: boolean;
11+
private blockerId: number | null = null;
12+
private activeActivities = new Set<string>();
13+
14+
constructor() {
15+
this.enabled = settingsStore.get("preventSleepWhileRunning", false);
16+
}
17+
18+
setEnabled(enabled: boolean): void {
19+
log.info("setEnabled", { enabled });
20+
this.enabled = enabled;
21+
settingsStore.set("preventSleepWhileRunning", enabled);
22+
this.updateBlocker();
23+
}
24+
25+
getEnabled(): boolean {
26+
return this.enabled;
27+
}
28+
29+
acquire(activityId: string): void {
30+
this.activeActivities.add(activityId);
31+
this.updateBlocker();
32+
}
33+
34+
release(activityId: string): void {
35+
this.activeActivities.delete(activityId);
36+
this.updateBlocker();
37+
}
38+
39+
@preDestroy()
40+
cleanup(): void {
41+
this.stopBlocker();
42+
}
43+
44+
private updateBlocker(): void {
45+
if (this.enabled && this.activeActivities.size > 0) {
46+
this.startBlocker();
47+
} else {
48+
this.stopBlocker();
49+
}
50+
}
51+
52+
private startBlocker(): void {
53+
if (this.blockerId !== null) return;
54+
this.blockerId = powerSaveBlocker.start("prevent-app-suspension");
55+
log.info("Started power save blocker", { blockerId: this.blockerId });
56+
}
57+
58+
private stopBlocker(): void {
59+
if (this.blockerId === null) return;
60+
log.info("Stopping power save blocker", { blockerId: this.blockerId });
61+
powerSaveBlocker.stop(this.blockerId);
62+
this.blockerId = null;
63+
}
64+
}

apps/twig/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { osRouter } from "./routers/os.js";
1818
import { processTrackingRouter } from "./routers/process-tracking.js";
1919
import { secureStoreRouter } from "./routers/secure-store.js";
2020
import { shellRouter } from "./routers/shell.js";
21+
import { sleepRouter } from "./routers/sleep.js";
2122
import { uiRouter } from "./routers/ui.js";
2223
import { updatesRouter } from "./routers/updates.js";
2324
import { workspaceRouter } from "./routers/workspace.js";
@@ -41,6 +42,7 @@ export const trpcRouter = router({
4142
logs: logsRouter,
4243
os: osRouter,
4344
processTracking: processTrackingRouter,
45+
sleep: sleepRouter,
4446
secureStore: secureStoreRouter,
4547
shell: shellRouter,
4648
ui: uiRouter,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from "zod";
2+
import { container } from "../../di/container.js";
3+
import { MAIN_TOKENS } from "../../di/tokens.js";
4+
import type { SleepService } from "../../services/sleep/service.js";
5+
import { publicProcedure, router } from "../trpc.js";
6+
7+
const getService = () => container.get<SleepService>(MAIN_TOKENS.SleepService);
8+
9+
export const sleepRouter = router({
10+
getEnabled: publicProcedure
11+
.output(z.boolean())
12+
.query(() => getService().getEnabled()),
13+
14+
setEnabled: publicProcedure
15+
.input(z.object({ enabled: z.boolean() }))
16+
.mutation(({ input }) => {
17+
getService().setEnabled(input.enabled);
18+
}),
19+
});

apps/twig/src/renderer/features/settings/components/SettingsView.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function SettingsView() {
8686
autoConvertLongText,
8787
sendMessagesWith,
8888
allowBypassPermissions,
89+
preventSleepWhileRunning,
8990
setCursorGlow,
9091
setDesktopNotifications,
9192
setDockBadgeNotifications,
@@ -94,6 +95,7 @@ export function SettingsView() {
9495
setAutoConvertLongText,
9596
setSendMessagesWith,
9697
setAllowBypassPermissions,
98+
setPreventSleepWhileRunning,
9799
} = useSettingsStore();
98100
const terminalFontFamily = useTerminalSettingsStore(
99101
(state) => state.terminalFontFamily,
@@ -124,6 +126,8 @@ export function SettingsView() {
124126

125127
const { data: appVersion } = trpcReact.os.getAppVersion.useQuery();
126128

129+
const preventSleepMutation = trpcReact.sleep.setEnabled.useMutation();
130+
127131
const [localWorktreeLocation, setLocalWorktreeLocation] =
128132
useState<string>("");
129133
const [checkingForUpdates, setCheckingForUpdates] = useState(false);
@@ -318,6 +322,19 @@ export function SettingsView() {
318322
[sendMessagesWith, setSendMessagesWith],
319323
);
320324

325+
const handlePreventSleepChange = useCallback(
326+
(checked: boolean) => {
327+
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
328+
setting_name: "prevent_sleep_while_running",
329+
new_value: checked,
330+
old_value: !checked,
331+
});
332+
setPreventSleepWhileRunning(checked);
333+
preventSleepMutation.mutate({ enabled: checked });
334+
},
335+
[setPreventSleepWhileRunning, preventSleepMutation],
336+
);
337+
321338
const handleBypassPermissionsChange = useCallback(
322339
(checked: boolean) => {
323340
if (checked) {
@@ -704,6 +721,22 @@ export function SettingsView() {
704721
<Heading size="3">Task Execution</Heading>
705722
<Card>
706723
<Flex direction="column" gap="4">
724+
<Flex align="center" justify="between" gap="4">
725+
<Flex direction="column" gap="1">
726+
<Text size="1" weight="medium">
727+
Prevent sleep while running
728+
</Text>
729+
<Text size="1" color="gray">
730+
Keep your computer awake while the agent is running a
731+
task.
732+
</Text>
733+
</Flex>
734+
<Switch
735+
checked={preventSleepWhileRunning}
736+
onCheckedChange={handlePreventSleepChange}
737+
size="1"
738+
/>
739+
</Flex>
707740
<Flex align="start" justify="between" gap="4">
708741
<Flex direction="column" gap="1">
709742
<Flex align="center" gap="2">

apps/twig/src/renderer/features/settings/stores/settingsStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface SettingsStore {
2020
completionVolume: number;
2121
sendMessagesWith: SendMessagesWith;
2222
allowBypassPermissions: boolean;
23+
preventSleepWhileRunning: boolean;
2324

2425
setCompletionSound: (sound: CompletionSound) => void;
2526
setCompletionVolume: (volume: number) => void;
@@ -33,6 +34,7 @@ interface SettingsStore {
3334
setAutoConvertLongText: (enabled: boolean) => void;
3435
setSendMessagesWith: (mode: SendMessagesWith) => void;
3536
setAllowBypassPermissions: (enabled: boolean) => void;
37+
setPreventSleepWhileRunning: (enabled: boolean) => void;
3638
}
3739

3840
export const useSettingsStore = create<SettingsStore>()(
@@ -50,6 +52,7 @@ export const useSettingsStore = create<SettingsStore>()(
5052
autoConvertLongText: true,
5153
sendMessagesWith: "enter",
5254
allowBypassPermissions: false,
55+
preventSleepWhileRunning: false,
5356

5457
setCompletionSound: (sound) => set({ completionSound: sound }),
5558
setCompletionVolume: (volume) => set({ completionVolume: volume }),
@@ -68,6 +71,8 @@ export const useSettingsStore = create<SettingsStore>()(
6871
setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }),
6972
setAllowBypassPermissions: (enabled) =>
7073
set({ allowBypassPermissions: enabled }),
74+
setPreventSleepWhileRunning: (enabled) =>
75+
set({ preventSleepWhileRunning: enabled }),
7176
}),
7277
{
7378
name: "settings-storage",

0 commit comments

Comments
 (0)