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
5 changes: 5 additions & 0 deletions .changeset/caffeination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Add a default-off Keep Awake toggle for busy agent sessions without keeping the display on or disabling screen locking. Remember the safety notice after its first acceptance. Keep multi-project toolbar controls visible in narrow sidebars.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/kilo-ui/src/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const icons: Record<string, { path: string; viewBox: string }> = {
viewBox: "0 0 24 24",
path: `<path d="M12 21V7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m16 12 2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 6V4a1 1 0 0 0-1-1h-5a4 4 0 0 0-4 4 4 4 0 0 0-4-4H3a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h6a3 3 0 0 1 3 3 3 3 0 0 1 3-3h6a1 1 0 0 0 1-1v-1.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
},
coffee: {
viewBox: "0 0 24 24",
path: `<path d="M17 8h1a4 4 0 1 1 0 8h-1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M6 2v2M10 2v2M14 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`,
},
"circuit-board": {
viewBox: "0 0 16 16",
path: `<path d="M12.5 1H3.5C2.121 1 1 2.121 1 3.5V12.5C1 13.879 2.121 15 3.5 15H12.5C13.879 15 15 13.879 15 12.5V3.5C15 2.121 13.879 1 12.5 1ZM6 6.5C6 6.775 5.775 7 5.5 7C5.225 7 5 6.775 5 6.5C5 6.225 5.225 6 5.5 6C5.775 6 6 6.225 6 6.5ZM12.5 14H6V11.5C6 11.225 6.225 11 6.5 11H9.092C9.299 11.581 9.849 12 10.5 12C11.327 12 12 11.327 12 10.5C12 9.673 11.327 9 10.5 9C9.849 9 9.299 9.419 9.092 10H6.5C5.673 10 5 10.673 5 11.5V14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H5V5.092C4.419 5.299 4 5.849 4 6.5C4 7.327 4.673 8 5.5 8C6.327 8 7 7.327 7 6.5C7 5.849 6.581 5.299 6 5.092V2H12.5C13.327 2 14 2.673 14 3.5V6H10.908C10.701 5.419 10.151 5 9.5 5C8.673 5 8 5.673 8 6.5C8 7.327 8.673 8 9.5 8C10.151 8 10.701 7.581 10.908 7H14V12.5C14 13.327 13.327 14 12.5 14ZM10 10.5C10 10.225 10.225 10 10.5 10C10.775 10 11 10.225 11 10.5C11 10.775 10.775 11 10.5 11C10.225 11 10 10.775 10 10.5ZM10 6.5C10 6.775 9.775 7 9.5 7C9.225 7 9 6.775 9 6.5C9 6.225 9.225 6 9.5 6C9.775 6 10 6.225 10 6.5Z" fill="currentColor"/>`,
Expand Down
5 changes: 5 additions & 0 deletions packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@
"title": "Toggle Project Memory",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.toggleCaffeination",
"title": "Toggle Keep Awake",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.autocomplete.generateSuggestions",
"title": "Generate Suggested Edits",
Expand Down
17 changes: 17 additions & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { openFileInEditor, getWorkspaceRoot } from "../review-utils"
import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry"
import type { AutoApproveController } from "../commands/toggle-auto-approve"
import type { RemoteStatusService } from "../services/RemoteStatusService"
import type { CaffeinationService } from "../services/caffeination"

const INTRO_KEY = "kilo.agentManager.introDismissed"

Expand All @@ -36,6 +37,7 @@ export class VscodeHost implements Host {
private readonly connectionService: KiloConnectionService,
private readonly context: vscode.ExtensionContext,
private readonly remoteService: RemoteStatusService,
private readonly caffeination?: Pick<CaffeinationService, "getState" | "onChange" | "setEnabled">,
) {}

setDiffVirtualProvider(provider: DiffVirtualProvider): void {
Expand Down Expand Up @@ -137,8 +139,23 @@ export class VscodeHost implements Host {
provider.setDiffVirtualProvider(this.diffVirtual)
}
provider.setRemoteService(this.remoteService)
const snapshot = () => {
if (this.caffeination) {
void panel.webview.postMessage({ type: "agentManager.caffeination", ...this.caffeination.getState() })
Comment thread
marius-kilocode marked this conversation as resolved.
}
}
const unsubscribe = this.caffeination?.onChange(snapshot)
panel.onDidDispose(() => unsubscribe?.())
provider.attachToWebview(panel.webview, {
onBeforeMessage: async (msg) => {
if (msg.type === "agentManager.setCaffeination") {
if (typeof msg.enabled === "boolean") await this.caffeination?.setEnabled(msg.enabled)
return null
}
if (msg.type === "agentManager.requestCaffeination") {
snapshot()
return null
}
if (msg.type !== "agentManager.setIntroDismissed") return opts.onBeforeMessage(msg)
if (typeof msg.dismissed === "boolean") await this.context.globalState.update(INTRO_KEY, msg.dismissed)
return null
Expand Down
54 changes: 52 additions & 2 deletions packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { registerAutocompleteProvider } from "./services/autocomplete"
import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend"
import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager"
import { AttentionService } from "./services/attention"
import { CaffeinationService } from "./services/caffeination"
import { confirmCaffeination } from "./services/caffeination/confirm"
import { createCaffeinationDriver } from "./services/caffeination/inhibitor"
import { BrowserBroker } from "./services/browser-automation"
import { TelemetryEventName, TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
Expand All @@ -30,6 +33,7 @@ import { createGitExecutable } from "./util/git-executable"
import { isCursorHost } from "./utils"

let agentManager: AgentManagerProvider | undefined
let caffeination: CaffeinationService | undefined
let shuttingDown = false

const RESTORE_KEY = "kilo.workbench.restore"
Expand Down Expand Up @@ -161,7 +165,38 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(kiloClawProvider)

// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
const reason = vscode.env.remoteName ? "Keep Awake is only available in a local VS Code window." : undefined
const awake = new CaffeinationService(connectionService, createCaffeinationDriver({ reason }))
caffeination = awake
const toggle = confirmCaffeination(awake, async () => {
if (!vscode.workspace.isTrusted) {
await vscode.window.showWarningMessage("Trust this workspace before enabling Keep Awake.")
return false
}
if (context.globalState.get<boolean>("caffeination.confirmed") === true) return true
const detail = [
"Keep Awake prevents system sleep while Kilo sessions are in progress, including some waits for approval. It does not keep the display on or disable screen locking. It turns off when this VS Code window reloads.",
"Agents may continue to access files, network services, and available credentials while the computer is locked. Enable only if your organization's device policy permits it.",
...(process.platform === "linux"
? ["On Linux, this can also block manual suspend. Turn Keep Awake off before suspending."]
: []),
].join("\n\n")
const answer = await vscode.window.showWarningMessage(
"Keep this computer awake while Kilo agents work?",
{ modal: true, detail },
"Enable Keep Awake",
)
if (answer !== "Enable Keep Awake") return false
await context.globalState.update("caffeination.confirmed", true).then(undefined, (error: unknown) => {
console.warn("[Kilo New] Could not save Keep Awake confirmation:", error)
})
return true
})
const controls = {
getState: () => awake.getState(),
onChange: awake.onChange.bind(awake),
setEnabled: toggle,
}
const git = createGitExecutable({
preferred: async () => {
const extension = vscode.extensions.getExtension("vscode.git")
Expand All @@ -172,6 +207,7 @@ export async function activate(context: vscode.ExtensionContext) {
log: (message) => console.warn(`[Kilo New] ${message}`),
})
const binary = process.platform === "win32" ? await git() : git
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService, controls)
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary, browserBroker)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
Expand Down Expand Up @@ -472,6 +508,14 @@ export async function activate(context: vscode.ExtensionContext) {
await target.waitForReady()
await target.toggleMemory()
}),
vscode.commands.registerCommand("kilo-code.new.toggleCaffeination", (enabled?: boolean) => {
const state = awake.getState()
const next = typeof enabled === "boolean" ? enabled : !(state.enabled || state.active)
if (next && !state.available) {
return vscode.window.showWarningMessage(state.error ?? "Keep Awake is unavailable on this platform.")
}
return toggle(next)
}),
vscode.commands.registerCommand("kilo-code.new.generateTerminalCommand", async () => {
const input = await vscode.window.showInputBox({
prompt: "Describe the terminal command you want to generate",
Expand Down Expand Up @@ -640,6 +684,9 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push({
dispose: () => {
shuttingDown = true
void caffeination?.dispose().catch((error: unknown) => {
console.warn("[Kilo New] Keep-awake cleanup failed:", error)
})
unsubscribeStateChange()
attention.dispose()
browserBroker.dispose()
Expand All @@ -652,7 +699,10 @@ export async function activate(context: vscode.ExtensionContext) {

export async function deactivate() {
shuttingDown = true
await agentManager?.shutdown()
const results = await Promise.allSettled([caffeination?.dispose(), agentManager?.shutdown()])
for (const result of results) {
if (result.status === "rejected") console.warn("[Kilo New] Extension shutdown failed:", result.reason)
}
TelemetryProxy.getInstance().shutdown()
}

Expand Down
37 changes: 37 additions & 0 deletions packages/kilo-vscode/src/services/caffeination/confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { CaffeinationService } from "./service"

export function confirmCaffeination(
service: Pick<CaffeinationService, "getState" | "setEnabled">,
ask: () => Promise<boolean>,
) {
let accepted = false
let pending: Promise<void> | undefined
let revision = 0

return (enabled: boolean): Promise<void> => {
if (!enabled) {
revision++
return service.setEnabled(false)
}
const state = service.getState()
if (state.enabled || !state.available) return Promise.resolve()
if (accepted) return service.setEnabled(true)
if (pending) return pending

const current = revision
pending = Promise.resolve()
.then(ask)
.then((answer) => {
if (!answer || current !== revision) return
accepted = true
return service.setEnabled(true)
})
.catch((error: unknown) => {
console.warn("[Kilo New] Keep-awake confirmation failed:", error)
})
.finally(() => {
pending = undefined
})
return pending
}
}
133 changes: 133 additions & 0 deletions packages/kilo-vscode/src/services/caffeination/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { realpathSync } from "node:fs"
import type { SessionStatus } from "@kilocode/sdk/v2/client"
import type { SSEPayload } from "../cli-backend/sdk-sse-adapter"

type Snapshot = Record<string, Pick<SessionStatus, "type">>
type Update = Extract<SSEPayload, { type: "session.status" | "session.idle" | "session.deleted" | "session.error" }>
type State = { values: Set<string>; request?: { events: Update[]; promise: Promise<void> } }

function key(dir: string): string {
const path = dir.replace(/\\/g, "/").replace(/\/+$/u, "") || "/"
return process.platform === "win32" || process.platform === "darwin" ? path.toLowerCase() : path
}

function busy(status: Pick<SessionStatus, "type">): boolean {
return status.type === "busy" || status.type === "retry"
}

function apply(values: Set<string>, event: Update): void {
const id = event.properties.sessionID ?? (event.type === "session.deleted" ? event.properties.info?.id : undefined)
if (!id) return
if (event.type === "session.status" && busy(event.properties.status)) {
values.add(id)
return
}
values.delete(id)
}

export function feed(opts: {
paths: () => string[]
watching: () => boolean
load: (dir: string) => Promise<Snapshot>
post: (busy: boolean) => void
}) {
const dirs = new Map<string, string>()
const states = new Map<string, State>()
const aliases = new Map<string, string>()
const resolve = (dir: string): string => {
const path = key(dir)
const prior = aliases.get(path)
if (prior || !opts.watching()) return prior ?? path
try {
const id = key(realpathSync.native(dir))
aliases.set(path, id)
aliases.set(id, id)
return id
} catch {
aliases.set(path, path)
return path
}
}
const publish = () => opts.post([...states.values()].some((state) => state.values.size > 0))
const clear = () => {
states.clear()
aliases.clear()
publish()
}
const get = (dir: string, force = false): State => {
const id = resolve(dir)
const prior = states.get(id)
const state: State = prior ?? { values: new Set() }
states.set(id, state)
if (state.request || (prior && !force)) return state
const request: NonNullable<State["request"]> = {
events: [],
promise: Promise.resolve()
.then<Snapshot>(() => (opts.watching() ? opts.load(dir) : {}))
.catch((error: unknown) => {
console.warn(`[Kilo New] Keep-awake status refresh failed for ${dir}:`, error)
return {}
})
.then((snapshot) => {
if (states.get(id) !== state || state.request !== request) return
state.values = new Set(
Object.entries(snapshot)
.filter(([, status]) => busy(status))
.map(([id]) => id),
)
for (const event of request.events) apply(state.values, event)
publish()
})
.finally(() => {
if (state.request === request) state.request = undefined
}),
}
state.request = request
return state
}
const sync = async () => {
if (!opts.watching()) return
for (const dir of opts.paths()) if (dir) dirs.set(key(dir), dir)
const paths = [...dirs.values()]
dirs.clear()
for (const dir of paths) dirs.set(resolve(dir), dir)
await Promise.all([...dirs.values()].map((dir) => get(dir, true).request?.promise))
}
return {
sync,
clear,
event(event: SSEPayload, directory?: string): void {
if (event.type === "server.instance.disposed") {
const id = resolve(event.properties.directory)
for (const [alias, target] of aliases) if (target === id) aliases.delete(alias)
dirs.delete(id)
states.delete(id)
publish()
return
}
if (
event.type !== "session.status" &&
event.type !== "session.idle" &&
event.type !== "session.deleted" &&
event.type !== "session.error"
)
return
if (directory) dirs.set(resolve(directory), directory)
if (!opts.watching()) return
if (!directory && event.type === "session.status" && busy(event.properties.status)) {
clear()
void sync()
return
}
for (const state of directory ? [get(directory)] : states.values()) {
state.request?.events.push(event)
apply(state.values, event)
}
publish()
},
dispose(): void {
clear()
dirs.clear()
},
}
}
3 changes: 3 additions & 0 deletions packages/kilo-vscode/src/services/caffeination/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { CaffeinationService } from "./service"
export type { CaffeinationState } from "./service"
export type { CaffeinationDriver } from "./inhibitor"
Loading
Loading