Merge pull request #8209 from Kilo-Org/eshurakov/puddle-beret

VSCode - Remote control and focused/open session tracking in heartbeat
This commit is contained in:
Evgeny Shurakov
2026-04-09 16:39:13 +02:00
committed by GitHub
65 changed files with 1170 additions and 64 deletions
+51 -9
View File
@@ -36,6 +36,7 @@ import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import type { RemoteStatusService } from "./services/RemoteStatusService"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
@@ -112,6 +113,7 @@ const mapAgent = (a: Agent) => ({
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
private webview: vscode.Webview | null = null
private currentSession: Session | null = null
@@ -173,6 +175,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeDirectoryProvider: (() => void) | null = null
private initConnectionPromise: Promise<void> | null = null
private webviewMessageDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
/** Lazily initialized ignore controller for .kilocodeignore filtering */
private ignoreController: FileIgnoreController | null = null
@@ -198,6 +202,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
| null = null
private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined
private remoteService: RemoteStatusService | null = null
private unsubscribeRemote: (() => void) | null = null
constructor(
private readonly extensionUri: vscode.Uri,
@@ -211,6 +217,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
TelemetryProxy.getInstance().setProvider(this)
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
this.unsubscribeRemote = service.onChange(() => this.sendRemoteStatus())
}
private sendRemoteStatus(): void {
const s = this.remoteService?.getState()
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
}
private focusSession(id?: string): void {
if (id) this.connectionService.registerFocused(this.instanceId, id)
else this.connectionService.unregisterFocused(this.instanceId)
}
public setProjectDirectory(directory: string | null): void {
if (this.projectDirectory === directory) return
this.projectDirectory = directory
@@ -324,6 +343,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// authoritative and reconciliation risks race-resetting busy sessions.
const reconcile = this.sessionStatusMap.size === 0
void this.seedSessionStatusMap(reconcile)
this.sendRemoteStatus()
}
// legacy-migration start
@@ -354,20 +375,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
localResourceRoots: [this.extensionUri],
}
// Set HTML content
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Track sidebar visibility for keybinding when-clauses and stats polling
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
webviewView.onDidChangeVisibility(() => {
this.visibilityDisposable?.dispose()
this.visibilityDisposable = webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
@@ -386,9 +403,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
panel.webview.html = this._getHtmlForWebview(panel.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(panel.webview)
this.viewStateDisposable?.dispose()
this.viewStateDisposable = panel.onDidChangeViewState(() =>
this.focusSession(panel.active ? this.currentSession?.id : undefined),
)
this.initializeConnection()
}
@@ -582,6 +601,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "clearSession":
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
this.currentSession = null
this.focusSession()
break
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
@@ -817,6 +837,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "toggleRemote":
case "setRemoteEnabled":
case "requestRemoteStatus":
this.remoteService
?.handleMessage(message.type, message.enabled)
.then((s) => {
if (s) this.sendRemoteStatus()
})
.catch((err) => console.error("[Kilo New] remote message failed:", err))
break
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
@@ -1048,6 +1078,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(event) => {
// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.connectionService.resolveEventSessionId(event)
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
@@ -1244,6 +1276,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
@@ -2822,6 +2855,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
*/
private handleEvent(event: Event): void {
if (event.type === "kilo-sessions.remote-status-changed") {
this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected })
return
}
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
@@ -3233,6 +3271,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.unsubscribeRemote?.()
this.focusSession()
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.unsubscribeEvent?.()
@@ -3244,6 +3284,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeMigrationComplete?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
this.viewStateDisposable?.dispose()
this.visibilityDisposable?.dispose()
this.webviewMessageDisposable?.dispose()
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import { KiloProvider } from "./KiloProvider"
import { resolvePanelProjectDirectory } from "./project-directory"
import type { KiloConnectionService } from "./services/cli-backend"
import type { RemoteStatusService } from "./services/RemoteStatusService"
type PanelView = "settings" | "profile" | "marketplace"
@@ -26,6 +27,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
private panels = new Map<PanelView, vscode.WebviewPanel>()
private providers = new Map<PanelView, KiloProvider>()
private tabs = new Map<PanelView, string>()
private remoteService: RemoteStatusService | null = null
constructor(
private readonly extensionUri: vscode.Uri,
@@ -101,6 +103,9 @@ export class SettingsEditorProvider implements vscode.Disposable {
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
projectDirectory,
})
if (this.remoteService) {
provider.setRemoteService(this.remoteService)
}
provider.resolveWebviewPanel(panel)
// Listen for closePanel from the webview (back button in panel mode)
@@ -144,6 +149,14 @@ export class SettingsEditorProvider implements vscode.Disposable {
})
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
// Apply to any existing providers
for (const [, provider] of this.providers) {
provider.setRemoteService(service)
}
}
dispose(): void {
for (const [, panel] of this.panels) {
panel.dispose()
@@ -400,6 +400,10 @@ export class AgentManagerProvider implements Disposable {
this.startDiffPolling(m.sessionId)
return null
}
if (m.type === "agentManager.openSessions") {
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
return null
}
if (m.type === "agentManager.stopDiffWatch") {
this.stopDiffPolling()
return null
@@ -427,6 +431,7 @@ export class AgentManagerProvider implements Disposable {
// uses the correct session even before the session provider's async session.get completes.
if (m.type === "loadMessages") {
this.activeSessionId = m.sessionID
this.connectionService.registerFocused("agent-manager", m.sessionID)
this.terminalManager.syncOnSessionSwitch(m.sessionID)
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
}
@@ -434,6 +439,7 @@ export class AgentManagerProvider implements Disposable {
// After clearSession, clear active tracking and re-register worktree sessions
if (m.type === "clearSession") {
this.activeSessionId = undefined
this.connectionService.unregisterFocused("agent-manager")
void Promise.resolve().then(() => {
if (!this.panel || !this.state) return
for (const id of this.state.worktreeSessionIds()) {
@@ -1987,6 +1993,8 @@ export class AgentManagerProvider implements Disposable {
}
public dispose(): void {
this.connectionService.unregisterFocused("agent-manager")
this.connectionService.registerOpen("agent-manager", [])
this.stopDiffPolling()
this.statsPoller.stop()
this.gitOps.dispose()
@@ -465,6 +465,11 @@ interface OpenPRIn {
worktreeId: string
}
interface OpenSessionsIn {
type: "agentManager.openSessions"
sessionIDs: string[]
}
interface OpenFileIn {
type: "agentManager.openFile"
sessionId: string
@@ -619,6 +624,7 @@ export type AgentManagerInMessage =
| RevertWorktreeFileIn
| RefreshPRIn
| OpenPRIn
| OpenSessionsIn
| OpenFileIn
| GenericOpenFileIn
| PreviewImageIn
+33 -2
View File
@@ -16,6 +16,7 @@ import { TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
import { RemoteStatusService } from "./services/RemoteStatusService"
// Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings,
// autocomplete, commit-message generation, and URI deep links all work immediately — without
@@ -33,8 +34,13 @@ export function activate(context: vscode.ExtensionContext) {
const browserAutomationService = new BrowserAutomationService(connectionService)
browserAutomationService.syncWithSettings()
// Create remote status service (one status bar item for all webviews)
const remoteService = new RemoteStatusService()
context.subscriptions.push(remoteService)
connectionService.setRemoteService(remoteService)
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
// and reload autocomplete so it picks up the now-available backend connection.
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
const unsubscribeStateChange = connectionService.onStateChange((state) => {
if (state === "connected") {
browserAutomationService.reregisterIfEnabled()
@@ -42,7 +48,17 @@ export function activate(context: vscode.ExtensionContext) {
if (config) {
telemetry.configure(config.baseUrl, config.password)
}
try {
remoteService.setClient(connectionService.getClient())
console.log("[Kilo New] CLI connected, calling remoteService.refresh()")
remoteService.refresh().catch((err) => console.warn("[Kilo New] initial remote refresh failed:", err))
} catch {
remoteService.setClient(null)
}
AutocompleteServiceManager.getInstance()?.load()
} else {
remoteService.clearState()
remoteService.setClient(null)
}
})
@@ -60,6 +76,7 @@ export function activate(context: vscode.ExtensionContext) {
// Create the provider with shared service
const provider = new KiloProvider(context.extensionUri, connectionService, context)
provider.setRemoteService(remoteService)
// Register the webview view provider for the sidebar.
// retainContextWhenHidden keeps the webview alive when switching to other sidebar panels.
@@ -103,6 +120,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.window.registerWebviewPanelSerializer("kilo-code.new.TabPanel", {
deserializeWebviewPanel(panel: vscode.WebviewPanel) {
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
@@ -138,6 +156,7 @@ export function activate(context: vscode.ExtensionContext) {
// Create settings/profile editor provider (opens in editor area, not sidebar)
const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context)
settingsEditorProvider.setRemoteService(remoteService)
context.subscriptions.push(settingsEditorProvider)
// Create sub-agent viewer provider (read-only editor panel for sub-agent sessions)
@@ -228,8 +247,18 @@ export function activate(context: vscode.ExtensionContext) {
await provider.waitForReady()
provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` })
}),
vscode.commands.registerCommand("kilo-code.new.toggleRemote", () => {
remoteService.toggle().catch((err) => console.error("[Kilo New] toggleRemote command failed:", err))
}),
vscode.commands.registerCommand("kilo-code.new.openInTab", () => {
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels, diffVirtualProvider)
return openKiloInNewTab(
context,
connectionService,
agentManagerProvider,
tabPanels,
diffVirtualProvider,
remoteService,
)
}),
vscode.commands.registerCommand("kilo-code.new.showChanges", () => {
diffViewerProvider.openPanel()
@@ -362,6 +391,7 @@ async function openKiloInNewTab(
agentManagerProvider: AgentManagerProvider,
tabPanels: Map<vscode.WebviewPanel, KiloProvider>,
diffVirtualProvider: DiffVirtualProvider,
remoteService: RemoteStatusService,
) {
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
@@ -384,6 +414,7 @@ async function openKiloInNewTab(
}
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
@@ -0,0 +1,129 @@
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { t } from "./cli-backend/i18n"
export type RemoteState = { enabled: boolean; connected: boolean }
type Listener = (state: RemoteState) => void
/**
* Singleton service that owns all remote-control state and the VS Code status bar item.
* Replaces the per-webview polling in RemoteIndicator.tsx and ExperimentalTab.tsx
* with a push-based model: one status bar item, zero recurring cost for non-remote users.
*/
export class RemoteStatusService implements vscode.Disposable {
private state: RemoteState = { enabled: false, connected: false }
private bar: vscode.StatusBarItem
private listeners = new Set<Listener>()
private client: KiloClient | null = null
constructor() {
this.bar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99)
this.bar.command = "kilo-code.new.toggleRemote"
this.sync()
}
setClient(c: KiloClient | null): void {
this.client = c
}
/** Get current state synchronously. */
getState(): RemoteState {
return this.state
}
updateFromEvent(state: RemoteState): void {
this.update(state)
}
/** Subscribe to state changes. Returns an unsubscribe function. */
onChange(cb: Listener): () => void {
this.listeners.add(cb)
return () => this.listeners.delete(cb)
}
clearState(): void {
this.update({ enabled: false, connected: false })
}
/** One-shot status fetch — broadcasts via onChange if state changed. */
async refresh(): Promise<void> {
if (!this.client) return
const res = await this.client.remote.status().catch((err: unknown) => {
console.warn("[Kilo] remote status refresh failed:", err)
return undefined
})
if (!res?.data) return
this.update({ enabled: res.data.enabled, connected: res.data.connected })
}
/** Toggle remote on/off based on current state. */
async toggle(): Promise<void> {
if (!this.client) return
const { data } = await this.client.remote.status(undefined, { throwOnError: true })
await this.setEnabled(!data.enabled)
}
/** Enable or disable remote. State updates are pushed via events. */
async setEnabled(enabled: boolean): Promise<void> {
if (!this.client) return
if (enabled) {
await this.client.remote.enable(undefined, { throwOnError: true })
} else {
await this.client.remote.disable(undefined, { throwOnError: true })
}
this.update({ enabled, connected: false })
}
/**
* Handle a remote-related webview message.
* Returns a response message to post back to the webview, or null.
*/
async handleMessage(type: string, enabled?: boolean): Promise<RemoteState | null> {
switch (type) {
case "toggleRemote":
await this.toggle()
return null
case "setRemoteEnabled":
if (enabled === undefined) return null
await this.setEnabled(enabled)
return null
case "requestRemoteStatus":
void this.refresh()
return this.state
}
return null
}
dispose(): void {
this.listeners.clear()
this.bar.dispose()
}
// -- internal ---------------------------------------------------------------
private update(next: RemoteState): void {
if (this.state.enabled === next.enabled && this.state.connected === next.connected) return
this.state = next
this.sync()
for (const cb of this.listeners) cb(next)
}
/** Sync status bar appearance to current state. */
private sync(): void {
if (!this.state.enabled) {
this.bar.hide()
return
}
if (this.state.connected) {
this.bar.text = "$(radio-tower) Kilo Remote"
this.bar.tooltip = t("remote.connected")
this.bar.color = new vscode.ThemeColor("testing.iconPassed")
} else {
this.bar.text = "$(radio-tower) Kilo Remote \u2026"
this.bar.tooltip = t("remote.connecting")
this.bar.color = new vscode.ThemeColor("editorWarning.foreground")
}
this.bar.show()
}
}
@@ -34,6 +34,7 @@ export class KiloConnectionService {
private state: ConnectionState = "disconnected"
private connectPromise: Promise<void> | null = null
private healthPollTimer: ReturnType<typeof setInterval> | null = null
private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null
private readonly eventListeners: Set<SSEEventListener> = new Set()
private readonly stateListeners: Set<StateListener> = new Set()
@@ -51,6 +52,13 @@ export class KiloConnectionService {
*/
private readonly messageSessionIdsByMessageId: Map<string, string> = new Map()
/** Provider key → single focused session ID. */
private readonly focused: Map<string, string> = new Map()
/** Provider key → all open (background) session IDs. */
private readonly opened: Map<string, string[]> = new Map()
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private unsubRemote: (() => void) | null = null
constructor(context: vscode.ExtensionContext) {
this.serverManager = new ServerManager(context)
}
@@ -106,6 +114,27 @@ export class KiloConnectionService {
return this.config
}
/**
* Set the remote status service. When remote is disabled, flushViewed()
* is a no-op. When remote becomes enabled (startup refresh, user toggle,
* or SSE event), the accumulated focused/opened state is automatically
* flushed so the server is never left unaware of already-open sessions.
*/
setRemoteService(service: import("../RemoteStatusService").RemoteStatusService | null): void {
this.unsubRemote?.()
this.unsubRemote = null
this.remoteService = service
if (service) {
this.unsubRemote = service.onChange((state) => {
if (state.enabled) this.flushViewed()
})
}
}
private isRemoteEnabled(): boolean {
return this.remoteService?.getState().enabled ?? false
}
/**
* Current connection state.
*/
@@ -345,6 +374,55 @@ export class KiloConnectionService {
}
}
/**
* Register the session a provider is actively viewing (focused).
* After any change the aggregated set is sent to the server (debounced).
*/
registerFocused(key: string, sessionID: string): void {
if (this.focused.get(key) === sessionID) return
this.focused.set(key, sessionID)
this.flushViewed()
}
/**
* Unregister a provider's focused session (e.g. on dispose, hidden, or clearSession).
*/
unregisterFocused(key: string): void {
if (!this.focused.has(key)) return
this.focused.delete(key)
this.flushViewed()
}
/**
* Register the open (background tab) session IDs for a provider.
* Sessions that appear in both focused and open are reported as focused only.
*/
registerOpen(key: string, ids: string[]): void {
const prev = this.opened.get(key)
if (prev && prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
this.opened.set(key, ids)
this.flushViewed()
}
/** Debounced: send the aggregated focused + open session IDs to the server. */
flushViewed(): void {
if (!this.isRemoteEnabled()) return
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null
const focus = new Set(this.focused.values())
const open = new Set<string>()
for (const ids of this.opened.values()) {
for (const id of ids) {
if (!focus.has(id)) open.add(id)
}
}
this.client?.session
.viewed({ focused: [...focus], open: [...open] })
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
}, 150)
}
/**
* Clean up everything: kill server, close SSE, clear listeners.
*/
@@ -361,6 +439,14 @@ export class KiloConnectionService {
this.clearPendingPromptsListeners.clear()
this.directoryProviders.clear()
this.messageSessionIdsByMessageId.clear()
this.focused.clear()
this.opened.clear()
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.unsubRemote?.()
this.unsubRemote = null
this.client = null
this.sseClient = null
this.config = null
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "انتهت عملية CLI بالرمز {{code}} قبل بدء الخادم",
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثانية",
"remote.connected": "Kilo Remote: متصل",
"remote.connecting": "Kilo Remote: جارٍ الاتصال\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes que o servidor fosse iniciado",
"server.startupTimeout": "Tempo limite de inicialização do servidor esgotado após {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI proces je izašao sa kodom {{code}} prije nego što se server pokrenuo",
"server.startupTimeout": "Vrijeme pokretanja servera je isteklo nakon {{seconds}} sekundi",
"remote.connected": "Kilo Remote: Povezano",
"remote.connecting": "Kilo Remote: Povezivanje\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-processen afsluttede med kode {{code}} før serveren startede",
"server.startupTimeout": "Serverens opstartstid udløb efter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Forbundet",
"remote.connecting": "Kilo Remote: Forbinder\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Der CLI-Prozess wurde mit dem Code {{code}} beendet, bevor der Server gestartet wurde",
"server.startupTimeout": "Zeitüberschreitung beim Serverstart nach {{seconds}} Sekunden",
"remote.connected": "Kilo Remote: Verbunden",
"remote.connecting": "Kilo Remote: Verbindung wird hergestellt\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI process exited with code {{code}} before server started",
"server.startupTimeout": "Server startup timeout after {{seconds}} seconds",
"remote.connected": "Kilo Remote: Connected",
"remote.connecting": "Kilo Remote: Connecting\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "El proceso de la CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
"server.startupTimeout": "Tiempo de espera de inicio del servidor agotado después de {{seconds}} segundos",
"remote.connected": "Kilo Remote: Conectado",
"remote.connecting": "Kilo Remote: Conectando\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Le processus CLI s'est terminé avec le code {{code}} avant le démarrage du serveur",
"server.startupTimeout": "Délai de démarrage du serveur dépassé après {{seconds}} secondes",
"remote.connected": "Kilo Remote\u00a0: Connecté",
"remote.connecting": "Kilo Remote\u00a0: Connexion\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "サーバーが起動する前に、CLI プロセスがコード {{code}} で終了しました",
"server.startupTimeout": "サーバーの起動が {{seconds}} 秒後にタイムアウトしました",
"remote.connected": "Kilo Remote: 接続済み",
"remote.connecting": "Kilo Remote: 接続中\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}로 종료되었습니다",
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간이 초과되었습니다",
"remote.connected": "Kilo Remote: 연결됨",
"remote.connecting": "Kilo Remote: 연결 중\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-proces is afgesloten met code {{code}} voordat de server is gestart",
"server.startupTimeout": "Time-out bij opstarten van server na {{seconds}} seconden",
"remote.connected": "Kilo Remote: Verbonden",
"remote.connecting": "Kilo Remote: Verbinden\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI-prosessen avsluttet med kode {{code}} før serveren startet",
"server.startupTimeout": "Tidsavbrudd for serveroppstart etter {{seconds}} sekunder",
"remote.connected": "Kilo Remote: Tilkoblet",
"remote.connecting": "Kilo Remote: Kobler til\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Proces CLI zakończył się z kodem {{code}} przed uruchomieniem serwera",
"server.startupTimeout": "Przekroczono limit czasu uruchamiania serwera po {{seconds}} sekundach",
"remote.connected": "Kilo Remote: Połączono",
"remote.connecting": "Kilo Remote: Łączenie\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Процесс CLI завершился с кодом {{code}} до запуска сервера",
"server.startupTimeout": "Время ожидания запуска сервера истекло через {{seconds}} секунд",
"remote.connected": "Kilo Remote: Подключено",
"remote.connecting": "Kilo Remote: Подключение\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มทำงาน",
"server.startupTimeout": "หมดเวลาการเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
"remote.connected": "Kilo Remote: เชื่อมต่อแล้ว",
"remote.connecting": "Kilo Remote: กำลังเชื่อมต่อ\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "CLI işlemi sunucu başlamadan önce {{code}} koduyla çıktı",
"server.startupTimeout": "{{seconds}} saniye sonra sunucu başlatma zaman aşımı",
"remote.connected": "Kilo Remote: Bağlandı",
"remote.connecting": "Kilo Remote: Bağlanıyor\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера",
"server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд",
"remote.connected": "Kilo Remote: Підключено",
"remote.connecting": "Kilo Remote: Підключення\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "在服务器启动之前,CLI 进程已退出,代码为 {{code}}",
"server.startupTimeout": "服务器启动在 {{seconds}} 秒后超时",
"remote.connected": "Kilo Remote: 已连接",
"remote.connecting": "Kilo Remote: 正在连接\u2026",
} as const
@@ -1,4 +1,6 @@
export const dict = {
"server.processExited": "在伺服器啟動之前,CLI 處理程序已退出,代碼為 {{code}}",
"server.startupTimeout": "伺服器啟動在 {{seconds}} 秒後逾時",
"remote.connected": "Kilo Remote: 已連線",
"remote.connecting": "Kilo Remote: 正在連線\u2026",
} as const
@@ -60,6 +60,10 @@ const mockVscode = {
stat: async () => ({ type: 1, ctime: 0, mtime: 0, size: 0 }),
},
},
StatusBarAlignment: { Left: 1, Right: 2 },
ThemeColor: class {
constructor(public id: string) {}
},
window: {
activeTextEditor: undefined,
visibleTextEditors: [],
@@ -67,6 +71,15 @@ const mockVscode = {
showTextDocument: async () => {},
showWarningMessage: async () => undefined,
createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }),
createStatusBarItem: () => ({
text: "",
tooltip: "",
color: undefined as unknown,
command: undefined as string | undefined,
show: noop,
hide: noop,
dispose: noop,
}),
},
commands: {
registerCommand: () => ({ dispose: noop }),
@@ -0,0 +1,301 @@
import { describe, it, expect, spyOn } from "bun:test"
import { RemoteStatusService, type RemoteState } from "../../src/services/RemoteStatusService"
type StatusResponse = { enabled: boolean; connected: boolean }
function client(opts: { status?: StatusResponse | (() => StatusResponse); fail?: boolean }) {
return {
remote: {
status: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("connection refused")
const data =
typeof opts.status === "function" ? opts.status() : (opts.status ?? { enabled: false, connected: false })
return { data }
},
enable: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("enable failed")
return { data: true }
},
disable: async (_body?: unknown, _opts?: unknown) => {
if (opts.fail) throw new Error("disable failed")
return { data: true }
},
},
}
}
function service() {
return new RemoteStatusService()
}
// ---------------------------------------------------------------------------
// Listener management
// ---------------------------------------------------------------------------
describe("RemoteStatusService", () => {
describe("onChange", () => {
it("listener called on state change", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
expect(states).toEqual([{ enabled: true, connected: true }])
svc.dispose()
})
it("listener not called after unsubscribe", async () => {
const svc = service()
const states: RemoteState[] = []
const unsub = svc.onChange((s) => states.push(s))
unsub()
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
expect(states).toEqual([])
svc.dispose()
})
it("multiple listeners all notified", async () => {
const svc = service()
const a: RemoteState[] = []
const b: RemoteState[] = []
svc.onChange((s) => a.push(s))
svc.onChange((s) => b.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
expect(a).toEqual([{ enabled: true, connected: false }])
expect(b).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// refresh()
// ---------------------------------------------------------------------------
describe("refresh", () => {
it("fetches status and notifies listeners", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
expect(states).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
it("without client is a no-op", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
await svc.refresh() // no client set
expect(states).toEqual([])
svc.dispose()
})
it("does not notify if state unchanged", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
// initial state is { enabled: false, connected: false }, same as client returns
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
await svc.refresh()
expect(states).toEqual([]) // no change from initial
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// setEnabled()
// ---------------------------------------------------------------------------
describe("setEnabled", () => {
it("setEnabled(true) calls enable and broadcasts enabled state", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.setEnabled(true)
expect(states).toEqual([{ enabled: true, connected: false }])
svc.dispose()
})
it("setEnabled(false) calls disable and broadcasts disabled", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
// First get to enabled state
await svc.refresh()
states.length = 0 // reset
await svc.setEnabled(false)
expect(states).toEqual([{ enabled: false, connected: false }])
svc.dispose()
})
it("setEnabled(false) after enable broadcasts disabled", async () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.setEnabled(true)
states.length = 0
await svc.setEnabled(false)
expect(states).toEqual([{ enabled: false, connected: false }])
svc.dispose()
})
it("setEnabled(true) error is surfaced", async () => {
const svc = service()
svc.setClient(client({ fail: true }) as never)
await expect(svc.setEnabled(true)).rejects.toThrow("enable failed")
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// toggle()
// ---------------------------------------------------------------------------
describe("toggle", () => {
it("toggle when disabled calls enable", async () => {
const svc = service()
let enabled = false
const c = {
remote: {
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: false, connected: false } }),
enable: async (_b?: unknown, _o?: unknown) => {
enabled = true
return { data: true }
},
disable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
},
}
svc.setClient(c as never)
await svc.toggle()
expect(enabled).toBe(true)
svc.dispose()
})
it("toggle when enabled calls disable", async () => {
const svc = service()
let disabled = false
const c = {
remote: {
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: true, connected: true } }),
enable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
disable: async (_b?: unknown, _o?: unknown) => {
disabled = true
return { data: true }
},
},
}
svc.setClient(c as never)
await svc.toggle()
expect(disabled).toBe(true)
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// Push-based updates via updateFromEvent
// ---------------------------------------------------------------------------
describe("updateFromEvent", () => {
it("broadcasts state when pushed via event", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([{ enabled: true, connected: true }])
svc.dispose()
})
it("does not notify if event state matches current", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
// initial state is { enabled: false, connected: false }
svc.updateFromEvent({ enabled: false, connected: false })
expect(states).toEqual([])
svc.dispose()
})
it("tracks successive event-driven transitions", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: false })
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([
{ enabled: true, connected: false },
{ enabled: true, connected: true },
])
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// clearState
// ---------------------------------------------------------------------------
describe("clearState", () => {
it("resets to disabled state", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: true })
states.length = 0
svc.clearState()
expect(states).toEqual([{ enabled: false, connected: false }])
expect(svc.getState()).toEqual({ enabled: false, connected: false })
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// Status bar
// ---------------------------------------------------------------------------
describe("status bar", () => {
it("status bar hidden when remote disabled", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
await svc.refresh() // no state change from initial, bar should stay hidden
// Dispose checks bar was never shown — no direct assertion on mock, just no crash
svc.dispose()
})
it("status bar shown with correct text when connected", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
await svc.refresh()
// Service is functional — status bar is managed internally. We verify no errors.
svc.dispose()
})
it("status bar shown with connecting text when enabled but not connected", async () => {
const svc = service()
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
await svc.refresh()
svc.dispose()
})
})
// ---------------------------------------------------------------------------
// dispose()
// ---------------------------------------------------------------------------
describe("dispose", () => {
it("dispose clears listeners", () => {
const svc = service()
const states: RemoteState[] = []
svc.onChange((s) => states.push(s))
svc.updateFromEvent({ enabled: true, connected: false })
svc.dispose()
// No further notifications after dispose
svc.updateFromEvent({ enabled: true, connected: true })
expect(states).toEqual([{ enabled: true, connected: false }])
})
})
})
@@ -106,6 +106,7 @@ import {
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "./diff-state"
import { trackOpenSessions } from "./open-sessions"
import "./agent-manager.css"
import "./agent-manager-review.css"
@@ -676,6 +677,7 @@ const AgentManagerContent: Component = () => {
setLocalSessionIDs(valid)
}
})
trackOpenSessions(localSessionIDs, isPending, managedSessions, vscode.postMessage)
// Drop in-memory review state for worktrees that no longer exist.
createEffect(() => {
@@ -0,0 +1,15 @@
import { createEffect } from "solid-js"
import type { Accessor } from "solid-js"
/** Reactive effect: reports open (non-pending) session IDs to the extension for heartbeat. */
export function trackOpenSessions(
local: Accessor<string[]>,
pending: (id: string) => boolean,
managed: Accessor<Array<{ id: string }>>,
post: (msg: { type: "agentManager.openSessions"; sessionIDs: string[] }) => void,
): void {
createEffect(() => {
const ids = [...new Set([...local().filter((id) => !pending(id)), ...managed().map((s) => s.id)])]
post({ type: "agentManager.openSessions", sessionIDs: ids })
})
}
@@ -1,10 +1,12 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Switch } from "@kilocode/kilo-ui/switch"
import { Select } from "@kilocode/kilo-ui/select"
import { TextField } from "@kilocode/kilo-ui/text-field"
import { Card } from "@kilocode/kilo-ui/card"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import type { ExtensionMessage } from "../../types/messages"
import SettingsRow from "./SettingsRow"
interface ShareOption {
@@ -21,6 +23,20 @@ const SHARE_OPTIONS: ShareOption[] = [
const ExperimentalTab: Component = () => {
const { config, updateConfig } = useConfig()
const language = useLanguage()
const vscode = useVSCode()
const [active, setActive] = createSignal(false)
const handler = (msg: ExtensionMessage) => {
if (msg.type === "remoteStatus") {
setActive(msg.enabled)
}
}
onMount(() => {
const unsub = vscode.onMessage(handler)
vscode.postMessage({ type: "requestRemoteStatus" })
onCleanup(unsub)
})
const experimental = createMemo(() => config().experimental ?? {})
@@ -33,6 +49,37 @@ const ExperimentalTab: Component = () => {
return (
<div>
<Card>
{/* Remote control */}
<div data-component="remote-settings">
<div data-slot="remote-settings-header">
<div data-slot="settings-row-label-title">{language.t("settings.experimental.remote.title")}</div>
<div data-slot="settings-row-label-subtitle">{language.t("settings.experimental.remote.description")}</div>
</div>
<div data-slot="remote-settings-block">
<div data-slot="remote-settings-row">
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.current")}</span>
<span data-slot="remote-settings-status" data-active={active()}>
{active()
? language.t("settings.experimental.remote.active")
: language.t("settings.experimental.remote.inactive")}
</span>
</div>
<div data-slot="remote-settings-hint">{language.t("settings.experimental.remote.hint")}</div>
</div>
<div data-slot="remote-settings-row">
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.startup")}</span>
<Switch
checked={config().remote_control ?? false}
onChange={(checked) => {
updateConfig({ remote_control: checked })
}}
hideLabel
>
{language.t("settings.experimental.remote.startup")}
</Switch>
</div>
</div>
{/* Share mode */}
<SettingsRow
title={language.t("settings.experimental.share.title")}
@@ -22,6 +22,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
"instructions",
"skills",
"snapshot",
"remote_control",
"share",
"username",
"watcher",
@@ -98,6 +98,14 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
vscode.postMessage({ type: "openSettingsPanel" })
},
},
{
name: "remote",
description: "Toggle remote control",
hints: [],
action: () => {
vscode.postMessage({ type: "toggleRemote" })
},
},
]
const client = exclude ? all.filter((c) => !exclude.has(c.name)) : all
+8
View File
@@ -1056,6 +1056,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
"settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)",
"settings.experimental.mcpTimeout.description": "مهلة طلبات خادم MCP بالمللي ثانية",
"settings.experimental.remote.title": "التحكم Remote",
"settings.experimental.remote.description":
"قم بتمكين التحكم Remote في الجلسات عبر Kilo Cloud. سيؤثر هذا أيضًا على واجهات سطر الأوامر (CLIs) على هذا الجهاز.",
"settings.experimental.remote.current": "الحالة الحالية:",
"settings.experimental.remote.startup": "التفعيل التلقائي عند بدء التشغيل:",
"settings.experimental.remote.active": "نشط",
"settings.experimental.remote.inactive": "غير نشط",
"settings.experimental.remote.hint": "استخدم /remote في الدردشة للتبديل",
"settings.experimental.toolToggles": "مفاتيح الأدوات",
"settings.agentBehaviour.defaultAgent.title": "الوكيل الافتراضي",
"settings.agentBehaviour.defaultAgent.description": "الوكيل المستخدم عند عدم التحديد",
+8
View File
@@ -1073,6 +1073,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
"settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tempo limite para solicitações do servidor MCP em milissegundos",
"settings.experimental.remote.title": "Controle Remote",
"settings.experimental.remote.description":
"Habilite o controle Remote de sessões via Kilo Cloud. Isso também afetará as CLIs nesta máquina.",
"settings.experimental.remote.current": "Estado atual:",
"settings.experimental.remote.startup": "Ativar automaticamente na inicialização:",
"settings.experimental.remote.active": "Ativo",
"settings.experimental.remote.inactive": "Inativo",
"settings.experimental.remote.hint": "Use /remote no chat para alternar",
"settings.experimental.toolToggles": "Alternadores de ferramentas",
"settings.agentBehaviour.defaultAgent.title": "Agente padrão",
"settings.agentBehaviour.defaultAgent.description": "Agente a usar quando nenhum é especificado",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
"settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)",
"settings.experimental.mcpTimeout.description": "Istek vremena za MCP server zahtjeve u milisekundama",
"settings.experimental.remote.title": "Remote kontrola",
"settings.experimental.remote.description":
"Omogućite Remote kontrolu sesija putem Kilo Cloud. Ovo će također utjecati na CLI-jeve na ovoj mašini.",
"settings.experimental.remote.current": "Trenutno stanje:",
"settings.experimental.remote.startup": "Automatsko uključivanje pri pokretanju:",
"settings.experimental.remote.active": "Aktivno",
"settings.experimental.remote.inactive": "Neaktivno",
"settings.experimental.remote.hint": "Koristite /remote u chatu za prebacivanje",
"settings.experimental.toolToggles": "Prekidači alata",
"settings.agentBehaviour.defaultAgent.title": "Zadani agent",
"settings.agentBehaviour.defaultAgent.description": "Agent koji se koristi kada nijedan nije naveden",
+8
View File
@@ -1066,6 +1066,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
"settings.experimental.mcpTimeout.title": "MCP-timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP-serveranmodninger i millisekunder",
"settings.experimental.remote.title": "Remote-styring",
"settings.experimental.remote.description":
"Aktivér Remote-styring af sessioner via Kilo Cloud. Dette vil også påvirke CLI'er på denne maskine.",
"settings.experimental.remote.current": "Nuværende status:",
"settings.experimental.remote.startup": "Aktivér automatisk ved opstart:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Brug /remote i chatten for at skifte",
"settings.experimental.toolToggles": "Værktøjsskift",
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
"settings.agentBehaviour.defaultAgent.description": "Agent til brug, når ingen er angivet",
+8
View File
@@ -1086,6 +1086,14 @@ export const dict = {
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
"settings.experimental.mcpTimeout.title": "MCP-Zeitlimit (ms)",
"settings.experimental.mcpTimeout.description": "Zeitlimit für MCP-Server-Anfragen in Millisekunden",
"settings.experimental.remote.title": "Remote-Steuerung",
"settings.experimental.remote.description":
"Aktivieren Sie die Remote-Steuerung von Sitzungen über Kilo Cloud. Dies betrifft auch CLIs auf diesem Computer.",
"settings.experimental.remote.current": "Aktueller Status:",
"settings.experimental.remote.startup": "Automatisch beim Start aktivieren:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Verwende /remote im Chat zum Umschalten",
"settings.experimental.toolToggles": "Werkzeug-Schalter",
"settings.agentBehaviour.defaultAgent.title": "Standard-Agent",
"settings.agentBehaviour.defaultAgent.description": "Agent, der verwendet wird, wenn keiner angegeben ist",
@@ -1067,6 +1067,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds",
"settings.experimental.remote.title": "Remote Control",
"settings.experimental.remote.description":
"Enable remote control of sessions via Kilo Cloud. This will also affect CLIs on this machine.",
"settings.experimental.remote.current": "Current state:",
"settings.experimental.remote.startup": "Auto-enable on startup:",
"settings.experimental.remote.active": "Active",
"settings.experimental.remote.inactive": "Inactive",
"settings.experimental.remote.hint": "Use /remote in chat to toggle",
"settings.experimental.toolToggles": "Tool Toggles",
"settings.agentBehaviour.defaultAgent.title": "Default Agent",
+8
View File
@@ -1077,6 +1077,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
"settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tiempo de espera para solicitudes del servidor MCP en milisegundos",
"settings.experimental.remote.title": "Control Remote",
"settings.experimental.remote.description":
"Habilite el control Remote de las sesiones a través de Kilo Cloud. Esto también afectará a las CLI de este equipo.",
"settings.experimental.remote.current": "Estado actual:",
"settings.experimental.remote.startup": "Activar automáticamente al inicio:",
"settings.experimental.remote.active": "Activo",
"settings.experimental.remote.inactive": "Inactivo",
"settings.experimental.remote.hint": "Usa /remote en el chat para alternar",
"settings.experimental.toolToggles": "Interruptores de herramientas",
"settings.agentBehaviour.defaultAgent.title": "Agente predeterminado",
"settings.agentBehaviour.defaultAgent.description": "Agente a usar cuando no se especifica ninguno",
+8
View File
@@ -1088,6 +1088,14 @@ export const dict = {
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
"settings.experimental.mcpTimeout.title": "Délai MCP (ms)",
"settings.experimental.mcpTimeout.description": "Délai des requêtes du serveur MCP en millisecondes",
"settings.experimental.remote.title": "Contrôle Remote",
"settings.experimental.remote.description":
"Activez le contrôle Remote des sessions via Kilo Cloud. Cela affectera également les CLI sur cette machine.",
"settings.experimental.remote.current": "État actuel :",
"settings.experimental.remote.startup": "Activation automatique au démarrage :",
"settings.experimental.remote.active": "Actif",
"settings.experimental.remote.inactive": "Inactif",
"settings.experimental.remote.hint": "Utilisez /remote dans le chat pour basculer",
"settings.experimental.toolToggles": "Commutateurs d'outils",
"settings.agentBehaviour.defaultAgent.title": "Agent par défaut",
"settings.agentBehaviour.defaultAgent.description": "Agent à utiliser lorsqu'aucun n'est spécifié",
+8
View File
@@ -1066,6 +1066,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
"settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)",
"settings.experimental.mcpTimeout.description": "MCPサーバーリクエストのタイムアウト(ミリ秒)",
"settings.experimental.remote.title": "Remote コントロール",
"settings.experimental.remote.description":
"Kilo Cloud 経由でのセッションの Remote コントロールを有効にします。これはこのマシンの CLI にも影響します。",
"settings.experimental.remote.current": "現在の状態:",
"settings.experimental.remote.startup": "起動時の自動有効化:",
"settings.experimental.remote.active": "アクティブ",
"settings.experimental.remote.inactive": "非アクティブ",
"settings.experimental.remote.hint": "チャットで /remote を使用して切り替えます",
"settings.experimental.toolToggles": "ツールトグル",
"settings.agentBehaviour.defaultAgent.title": "デフォルトエージェント",
"settings.agentBehaviour.defaultAgent.description": "指定されていない場合に使用するエージェント",
+8
View File
@@ -1063,6 +1063,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
"settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)",
"settings.experimental.mcpTimeout.description": "MCP 서버 요청의 타임아웃 시간 (밀리초)",
"settings.experimental.remote.title": "Remote 제어",
"settings.experimental.remote.description":
"Kilo Cloud를 통한 세션의 Remote 제어를 활성화합니다. 이는 이 컴퓨터의 CLI에도 영향을 미칩니다.",
"settings.experimental.remote.current": "현재 상태:",
"settings.experimental.remote.startup": "시작 시 자동 활성화:",
"settings.experimental.remote.active": "활성",
"settings.experimental.remote.inactive": "비활성",
"settings.experimental.remote.hint": "채팅에서 /remote를 사용하여 전환하세요",
"settings.experimental.toolToggles": "도구 토글",
"settings.agentBehaviour.defaultAgent.title": "기본 에이전트",
"settings.agentBehaviour.defaultAgent.description": "지정되지 않은 경우 사용할 에이전트",
+8
View File
@@ -1076,6 +1076,14 @@ export const dict = {
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout voor MCP-serververzoeken in milliseconden",
"settings.experimental.remote.title": "Remote-bediening",
"settings.experimental.remote.description":
"Schakel Remote-bediening van sessies in via Kilo Cloud. Dit heeft ook invloed op CLI's op deze machine.",
"settings.experimental.remote.current": "Huidige status:",
"settings.experimental.remote.startup": "Automatisch inschakelen bij opstarten:",
"settings.experimental.remote.active": "Actief",
"settings.experimental.remote.inactive": "Inactief",
"settings.experimental.remote.hint": "Gebruik /remote in de chat om te schakelen",
"settings.experimental.toolToggles": "Tool Schakelaars",
"settings.agentBehaviour.defaultAgent.title": "Standaard Agent",
+8
View File
@@ -1069,6 +1069,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
"settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)",
"settings.experimental.mcpTimeout.description": "Tidsavbrudd for MCP-serverforespørsler i millisekunder",
"settings.experimental.remote.title": "Remote-kontroll",
"settings.experimental.remote.description":
"Aktiver Remote-kontroll av økter via Kilo Cloud. Dette vil også påvirke CLI-er på denne maskinen.",
"settings.experimental.remote.current": "Nåværende status:",
"settings.experimental.remote.startup": "Aktiver automatisk ved oppstart:",
"settings.experimental.remote.active": "Aktiv",
"settings.experimental.remote.inactive": "Inaktiv",
"settings.experimental.remote.hint": "Bruk /remote i chatten for å veksle",
"settings.experimental.toolToggles": "Verktøybrytere",
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
"settings.agentBehaviour.defaultAgent.description": "Agent å bruke når ingen er angitt",
+8
View File
@@ -1070,6 +1070,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
"settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)",
"settings.experimental.mcpTimeout.description": "Limit czasu żądań serwera MCP w milisekundach",
"settings.experimental.remote.title": "Sterowanie Remote",
"settings.experimental.remote.description":
"Włącz sterowanie Remote sesjami za pośrednictwem Kilo Cloud. Wpłynie to również na CLI na tej maszynie.",
"settings.experimental.remote.current": "Aktualny stan:",
"settings.experimental.remote.startup": "Automatyczne włączanie przy starcie:",
"settings.experimental.remote.active": "Aktywny",
"settings.experimental.remote.inactive": "Nieaktywny",
"settings.experimental.remote.hint": "Użyj /remote na czacie, aby przełączyć",
"settings.experimental.toolToggles": "Przełączniki narzędzi",
"settings.agentBehaviour.defaultAgent.title": "Domyślny agent",
"settings.agentBehaviour.defaultAgent.description": "Agent używany, gdy żaden nie jest określony",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
"settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Таймаут запросов MCP-сервера в миллисекундах",
"settings.experimental.remote.title": "Управление Remote",
"settings.experimental.remote.description":
"Включите управление Remote сеансами через Kilo Cloud. Это также повлияет на CLI на этом компьютере.",
"settings.experimental.remote.current": "Текущее состояние:",
"settings.experimental.remote.startup": "Автоматически включать при запуске:",
"settings.experimental.remote.active": "Активно",
"settings.experimental.remote.inactive": "Неактивно",
"settings.experimental.remote.hint": "Используйте /remote в чате для переключения",
"settings.experimental.toolToggles": "Переключатели инструментов",
"settings.agentBehaviour.defaultAgent.title": "Агент по умолчанию",
"settings.agentBehaviour.defaultAgent.description": "Агент при отсутствии указания",
+8
View File
@@ -1058,6 +1058,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
"settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)",
"settings.experimental.mcpTimeout.description": "หมดเวลาสำหรับคำขอเซิร์ฟเวอร์ MCP เป็นมิลลิวินาที",
"settings.experimental.remote.title": "การควบคุม Remote",
"settings.experimental.remote.description":
"เปิดใช้งานการควบคุม Remote ของเซสชันผ่าน Kilo Cloud ซึ่งจะส่งผลต่อ CLI บนเครื่องนี้ด้วย",
"settings.experimental.remote.current": "สถานะปัจจุบัน:",
"settings.experimental.remote.startup": "เปิดใช้งานอัตโนมัติเมื่อเริ่มต้น:",
"settings.experimental.remote.active": "เปิดใช้งาน",
"settings.experimental.remote.inactive": "ปิดใช้งาน",
"settings.experimental.remote.hint": "ใช้ /remote ในแชทเพื่อสลับสถานะ",
"settings.experimental.toolToggles": "สวิตช์เครื่องมือ",
"settings.agentBehaviour.defaultAgent.title": "เอเจนต์เริ่มต้น",
"settings.agentBehaviour.defaultAgent.description": "เอเจนต์ที่ใช้เมื่อไม่ได้ระบุ",
+8
View File
@@ -1072,6 +1072,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
"settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)",
"settings.experimental.mcpTimeout.description": "MCP sunucu istekleri için milisaniye cinsinden zaman aşımı",
"settings.experimental.remote.title": "Remote Kontrolü",
"settings.experimental.remote.description":
"Kilo Cloud üzerinden oturumların Remote kontrolünü etkinleştirin. Bu, bu makinedeki CLI'leri de etkileyecektir.",
"settings.experimental.remote.current": "Mevcut durum:",
"settings.experimental.remote.startup": "Başlangıçta otomatik etkinleştir:",
"settings.experimental.remote.active": "Aktif",
"settings.experimental.remote.inactive": "Pasif",
"settings.experimental.remote.hint": "Geçiş yapmak için sohbette /remote kullanın",
"settings.experimental.toolToggles": "Araç Açma/Kapatma",
"settings.agentBehaviour.defaultAgent.title": "Varsayılan Ajan",
+8
View File
@@ -1075,6 +1075,14 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
"settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Тайм-аут у мілісекундах для запитів до MCP-сервера",
"settings.experimental.remote.title": "Керування Remote",
"settings.experimental.remote.description":
"Увімкніть керування Remote сеансами через Kilo Cloud. Це також вплине на CLI на цьому комп'ютері.",
"settings.experimental.remote.current": "Поточний стан:",
"settings.experimental.remote.startup": "Автоматичне ввімкнення під час запуску:",
"settings.experimental.remote.active": "Активний",
"settings.experimental.remote.inactive": "Неактивний",
"settings.experimental.remote.hint": "Використовуйте /remote у чаті для перемикання",
"settings.experimental.toolToggles": "Перемикачі інструментів",
"settings.agentBehaviour.defaultAgent.title": "Агент за замовчуванням",
+7
View File
@@ -1048,6 +1048,13 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
"settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 服务器请求的超时时间(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
"settings.experimental.remote.description": "通过 Kilo Cloud 启用会话的 Remote 控制。这也会影响此计算机上的 CLI。",
"settings.experimental.remote.current": "当前状态:",
"settings.experimental.remote.startup": "启动时自动启用:",
"settings.experimental.remote.active": "已启用",
"settings.experimental.remote.inactive": "未启用",
"settings.experimental.remote.hint": "在聊天中使用 /remote 进行切换",
"settings.experimental.toolToggles": "工具开关",
"settings.agentBehaviour.defaultAgent.title": "默认智能体",
"settings.agentBehaviour.defaultAgent.description": "未指定时使用的智能体",
+7
View File
@@ -1050,6 +1050,13 @@ export const dict = {
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
"settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 伺服器請求的逾時時間(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
"settings.experimental.remote.description": "透過 Kilo Cloud 啟用工作階段的 Remote 控制。這也會影響此電腦上的 CLI。",
"settings.experimental.remote.current": "目前狀態:",
"settings.experimental.remote.startup": "啟動時自動啟用:",
"settings.experimental.remote.active": "已啟用",
"settings.experimental.remote.inactive": "已停用",
"settings.experimental.remote.hint": "在聊天中使用 /remote 來切換",
"settings.experimental.toolToggles": "工具開關",
"settings.agentBehaviour.defaultAgent.title": "預設 Agent",
"settings.agentBehaviour.defaultAgent.description": "未指定時使用的 Agent",
@@ -1072,6 +1072,66 @@
}
}
/* Remote Settings */
[data-component="remote-settings"] {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-weak-base);
}
[data-slot="remote-settings-header"] {
margin-bottom: 4px;
[data-slot="settings-row-label-title"] {
font-weight: 500;
margin-bottom: 4px;
}
[data-slot="settings-row-label-subtitle"] {
font-size: 12px;
color: var(--text-weak-base, var(--vscode-descriptionForeground));
}
}
[data-slot="remote-settings-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="remote-settings-label"] {
font-size: 12px;
color: var(--text-weak);
white-space: nowrap;
}
[data-slot="remote-settings-block"] {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-slot="remote-settings-status"] {
font-size: 12px;
font-weight: 500;
color: var(--text-weak);
}
[data-slot="remote-settings-status"][data-active="true"] {
color: var(--vscode-testing-iconPassed, #5cb85c);
}
[data-slot="remote-settings-hint"] {
font-size: 11px;
color: var(--text-weak);
opacity: 0.7;
font-style: italic;
}
.prompt-input-hint-actions {
display: flex;
gap: 4px;
@@ -421,6 +421,7 @@ export interface Config {
instructions?: string[]
skills?: SkillsConfig
snapshot?: boolean
remote_control?: boolean
share?: "manual" | "auto" | "disabled"
username?: string
watcher?: WatcherConfig
@@ -1503,6 +1504,7 @@ export type ExtensionMessage =
| McpStatusLoadedMessage
| ClearPendingPromptsMessage
| ExtensionDataReadyMessage
| RemoteStatusMessage
// ============================================
// Messages FROM webview TO extension
@@ -2153,6 +2155,31 @@ export interface SetDefaultBaseBranchRequest {
branch?: string
}
// Report all open session IDs to extension for heartbeat (webview → extension)
export interface AgentManagerOpenSessionsMessage {
type: "agentManager.openSessions"
sessionIDs: string[]
}
export interface RemoteStatusMessage {
type: "remoteStatus"
enabled: boolean
connected: boolean
}
export interface ToggleRemoteMessage {
type: "toggleRemote"
}
export interface SetRemoteEnabledMessage {
type: "setRemoteEnabled"
enabled: boolean
}
export interface RequestRemoteStatusMessage {
type: "requestRemoteStatus"
}
export interface ConnectProviderMessage {
type: "connectProvider"
requestId: string
@@ -2398,6 +2425,7 @@ export type WebviewMessage =
| OpenSubAgentViewerRequest
| PreviewImageRequest
| SetDefaultBaseBranchRequest
| AgentManagerOpenSessionsMessage
| FetchMarketplaceDataMessage
| FilterMarketplaceItemsMessage
| InstallMarketplaceItemMessage
@@ -2412,6 +2440,9 @@ export type WebviewMessage =
| RequestRecentsMessage
| ToggleFavoriteRequest
| RequestFavoritesMessage
| ToggleRemoteMessage
| SetRemoteEnabledMessage
| RequestRemoteStatusMessage
| ContinueInWorktreeRequest
| CreateSectionRequest
| RenameSectionRequest
+1 -1
View File
@@ -275,7 +275,7 @@ function App() {
// kilocode_change start — notify server which session the user is viewing (for live session indicators)
createEffect(() => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
sdk.client.session.viewed({ sessionID }).catch(() => {})
sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
})
// kilocode_change end
@@ -1,4 +1,5 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
@@ -20,6 +21,16 @@ import { SessionStatus } from "@/session/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
export namespace KiloSessions {
export const Event = {
RemoteStatusChanged: BusEvent.define(
"kilo-sessions.remote-status-changed",
z.object({
enabled: z.boolean(),
connected: z.boolean(),
}),
),
}
const log = Log.create({ service: "kilo-sessions" })
const Uuid = z.uuid()
@@ -136,7 +147,8 @@ export namespace KiloSessions {
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender; heartbeat: () => Promise<void> } | undefined
let enabling: Promise<void> | undefined
let remoteSeq = 0
let viewedSessionId: string | undefined
const focused = new Set<string>()
const opened = new Set<string>()
export async function init() {
if (ingestDisabled) return
@@ -210,7 +222,11 @@ export namespace KiloSessions {
const cfg = await Config.getGlobal()
if (remoteEnabled || cfg.remote_control)
enableRemote().catch((err) => log.warn("remote not enabled", { error: String(err) }))
Bus.subscribe(Bus.InstanceDisposed, () => disableRemote())
// Use wildcard subscription so the dispose handler actually fires —
// Bus.state dispose only notifies "*" subscribers, not event-type ones.
Bus.subscribeAll((evt) => {
if (evt.type === Bus.InstanceDisposed.type) disableRemote()
})
}
export async function enableRemote() {
@@ -244,7 +260,8 @@ export namespace KiloSessions {
])
const statuses = SessionStatus.list()
const ids = new Set(Object.keys(statuses))
if (viewedSessionId) ids.add(viewedSessionId)
for (const id of focused) ids.add(id)
for (const id of opened) ids.add(id)
const results = await Promise.all(
[...ids].map(async (id) => {
const session = await Session.get(id).catch(() => undefined)
@@ -259,7 +276,12 @@ export namespace KiloSessions {
}
}),
)
return results.filter((r): r is NonNullable<typeof r> => !!r)
const sessions = results.filter((r): r is NonNullable<typeof r> => !!r)
return {
sessions,
focused: focused.size > 0 ? [...focused] : undefined,
open: opened.size > 0 ? [...opened] : undefined,
}
}
const conn = RemoteWS.connect({
@@ -268,6 +290,12 @@ export namespace KiloSessions {
withContext: (fn) => Instance.provide({ directory, fn }),
getSessions,
log,
onOpen: () => {
void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: true })
},
onDisconnect: () => {
void Bus.publish(Event.RemoteStatusChanged, { enabled: !!remote, connected: false })
},
onMessage: (msg) => {
// Must run inside Instance.provide so Bus.subscribeAll can access
// the instance-scoped subscription map via Instance.state().
@@ -283,7 +311,7 @@ export namespace KiloSessions {
})
const heartbeat = async () => {
conn.send({ type: "heartbeat", sessions: await getSessions() })
conn.send({ type: "heartbeat", ...(await getSessions()) })
}
if (seq !== remoteSeq) {
@@ -293,8 +321,9 @@ export namespace KiloSessions {
}
remote = { conn, sender, heartbeat }
log.info("remote connection enabled")
log.info("remote connection enabled", { connected: conn.connected })
Telemetry.trackRemoteConnectionOpened()
void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: conn.connected })
})().finally(() => {
if (remoteSeq === seq) enabling = undefined
})
@@ -310,6 +339,7 @@ export namespace KiloSessions {
remote.conn.close()
remote = undefined
log.info("remote connection disabled")
void Bus.publish(Event.RemoteStatusChanged, { enabled: false, connected: false })
}
export function remoteStatus() {
@@ -318,8 +348,15 @@ export namespace KiloSessions {
connected: remote?.conn.connected ?? false,
}
}
export function setViewedSession(sessionID: string | undefined) {
viewedSessionId = sessionID
export function setViewedSessions(input: { focused: string[]; open?: string[] }) {
focused.clear()
opened.clear()
for (const id of input.focused) {
focused.add(id)
}
for (const id of input.open ?? []) {
opened.add(id)
}
if (remote) void remote.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
}
@@ -18,6 +18,8 @@ export namespace RemoteProtocol {
export const Heartbeat = z.object({
type: z.literal("heartbeat"),
sessions: z.array(SessionInfo),
focused: z.array(z.string()).optional(),
open: z.array(z.string()).optional(),
})
export type Heartbeat = z.infer<typeof Heartbeat>
@@ -1,6 +1,6 @@
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import type { RemoteWS } from "@/kilo-sessions/remote-ws"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionPrompt } from "@/session/prompt"
@@ -48,7 +48,7 @@ export namespace RemoteSender {
error: (...args: any[]) => void
warn: (...args: any[]) => void
}
subscribe?: typeof Bus.subscribeAll
subscribe?: (callback: (event: any) => void) => () => void
provide?: typeof Instance.provide
}
@@ -62,7 +62,20 @@ export namespace RemoteSender {
const children = new Map<string, string>() // childId → parentId
let unsub: (() => void) | undefined
const sub = options.subscribe ?? Bus.subscribeAll
const sub =
options.subscribe ??
((callback: (event: any) => void) => {
const handler = (event: { directory?: string; payload: any }) => callback(event.payload)
GlobalBus.on("event", handler)
return () => {
GlobalBus.off("event", handler)
}
})
async function directoryFor(sid: string): Promise<string> {
const info = await Session.get(sid).catch(() => undefined)
return info?.directory ?? options.directory
}
function subscribed(sid: string) {
if (sessions.has(sid)) return true
@@ -79,8 +92,9 @@ export namespace RemoteSender {
async function backfillChildren(parentId: string) {
const provide = options.provide ?? Instance.provide
try {
const dir = await directoryFor(parentId)
await provide({
directory: options.directory,
directory: dir,
fn: async () => {
await discoverChildren(parentId)
},
@@ -118,8 +132,9 @@ export namespace RemoteSender {
async function backfillPendingState(sessionId: string) {
const provide = options.provide ?? Instance.provide
try {
const dir = await directoryFor(sessionId)
await provide({
directory: options.directory,
directory: dir,
fn: () => replay(sessionId),
})
} catch (e) {
@@ -179,12 +194,12 @@ export namespace RemoteSender {
})
}
function dispatchLongRunning(msg: RemoteProtocol.Command, work: () => Promise<void>) {
function dispatchLongRunning(msg: RemoteProtocol.Command, dir: Promise<string>, work: () => Promise<void>) {
const provide = options.provide ?? Instance.provide
options.conn.send({ type: "response", id: msg.id, result: {} })
void (async () => {
try {
await provide({ directory: options.directory, fn: work })
await provide({ directory: await dir, fn: work })
} catch (e) {
options.log.error("long-running command failed after ACK", {
id: msg.id,
@@ -195,11 +210,11 @@ export namespace RemoteSender {
})()
}
function dispatchQuick(msg: RemoteProtocol.Command, work: () => Promise<void>) {
function dispatchQuick(msg: RemoteProtocol.Command, dir: Promise<string>, work: () => Promise<void>) {
const provide = options.provide ?? Instance.provide
void (async () => {
try {
await provide({ directory: options.directory, fn: work })
await provide({ directory: await dir, fn: work })
options.conn.send({ type: "response", id: msg.id, result: {} })
} catch (e) {
options.conn.send({ type: "response", id: msg.id, error: String(e) })
@@ -227,7 +242,7 @@ export namespace RemoteSender {
})
return
}
dispatchLongRunning(msg, async () => {
dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => {
await SessionPrompt.prompt(input.data)
})
return
@@ -242,7 +257,8 @@ export namespace RemoteSender {
})
return
}
dispatchQuick(msg, () => Question.reply(parsed.data))
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () => Question.reply(parsed.data))
return
}
if (msg.command === "question_reject") {
@@ -255,7 +271,8 @@ export namespace RemoteSender {
})
return
}
dispatchQuick(msg, () => Question.reject(parsed.data.requestID))
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () => Question.reject(parsed.data.requestID))
return
}
if (msg.command === "permission_respond") {
@@ -268,7 +285,8 @@ export namespace RemoteSender {
})
return
}
dispatchQuick(msg, () => PermissionNext.reply(parsed.data))
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () => PermissionNext.reply(parsed.data))
return
}
options.conn.send({
@@ -6,13 +6,15 @@ export namespace RemoteWS {
export type Options = {
url: string
getToken: () => Promise<string | undefined>
getSessions: () => SessionInfo[] | Promise<SessionInfo[]>
getSessions: () => Promise<{ sessions: SessionInfo[]; focused?: string[]; open?: string[] }>
log: {
info: (...args: any[]) => void
error: (...args: any[]) => void
warn: (...args: any[]) => void
}
onMessage?: (msg: RemoteProtocol.Inbound) => void
onOpen?: () => void
onDisconnect?: () => void
heartbeat?: number
/** Wraps callbacks that need to run in a specific async context (e.g. Instance.provide) */
withContext?: <R>(fn: () => R) => Promise<R> | R
@@ -46,7 +48,7 @@ export namespace RemoteWS {
stopHeartbeat()
beat = setInterval(() => {
void withContext(async () => {
send({ type: "heartbeat", sessions: await options.getSessions() })
send({ type: "heartbeat", ...(await options.getSessions()) })
}).catch((err) => {
options.log.error("remote-ws heartbeat failed", { error: String(err) })
})
@@ -95,6 +97,7 @@ export namespace RemoteWS {
ws.onopen = () => {
options.log.info("remote-ws connected", { buffered: buffer.length })
void withContext(() => options.onOpen?.())
backoff = 1000
for (const msg of buffer) ws!.send(msg)
buffer.length = 0
@@ -128,14 +131,16 @@ export namespace RemoteWS {
ws = undefined
stopHeartbeat()
stopWatchdog()
if (closed) return
if (event.code === 4401 || event.code === 4403 || event.code === 4409) {
options.log.warn("remote-ws closed permanently", {
code: event.code,
reason: event.reason,
})
options.onClose?.(event.code, event.reason)
void withContext(() => options.onClose?.(event.code, event.reason))
return
}
void withContext(() => options.onDisconnect?.())
schedule()
}
+14 -5
View File
@@ -971,21 +971,30 @@ export const SessionRoutes = lazy(() =>
)
.post(
"/viewed",
// kilocode_change start
describeRoute({
summary: "Set viewed session",
description: "Notify the server which session the user is currently viewing, or clear it.",
summary: "Set viewed sessions",
description: "Notify the server which sessions the user is currently viewing, or clear all.",
operationId: "session.viewed",
responses: {
200: {
description: "Viewed session updated",
description: "Viewed sessions updated",
content: { "application/json": { schema: resolver(z.boolean()) } },
},
},
}),
validator("json", z.object({ sessionID: z.string().optional() })),
validator(
"json",
z.object({
focused: z.array(z.string()).optional(),
open: z.array(z.string()).optional(),
}),
),
async (c) => {
const { KiloSessions } = await import("../../kilo-sessions/kilo-sessions")
await KiloSessions.setViewedSession(c.req.valid("json").sessionID)
const body = c.req.valid("json")
KiloSessions.setViewedSessions({ focused: body.focused ?? [], open: body.open ?? [] })
// kilocode_change end
return c.json(true)
},
),
@@ -94,7 +94,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [{ id: "s1", status: "active", title: "Test" }],
getSessions: async () => ({ sessions: [{ id: "s1", status: "active", title: "Test" }] }),
log: nolog(),
heartbeat: 100,
})
@@ -116,7 +116,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
})
@@ -149,7 +149,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
})
@@ -175,7 +175,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
})
@@ -198,7 +198,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
onClose: (code) => codes.push(code),
@@ -223,7 +223,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: cap.log,
heartbeat: 60_000,
onMessage: (msg) => received.push(msg),
@@ -263,7 +263,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [{ id: "s1", status: "active", title: "Test" }],
getSessions: async () => ({ sessions: [{ id: "s1", status: "active", title: "Test" }] }),
log: nolog(),
heartbeat: 100,
})
@@ -292,7 +292,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
timeout: 200,
@@ -319,7 +319,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
timeout: 300,
@@ -347,7 +347,7 @@ describe("RemoteWS", () => {
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: () => [],
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
timeout: 100,
+6 -4
View File
@@ -2382,15 +2382,16 @@ export class Session2 extends HeyApiClient {
}
/**
* Set viewed session
* Set viewed sessions
*
* Notify the server which session the user is currently viewing, or clear it.
* Notify the server which sessions the user is currently viewing, or clear all.
*/
public viewed<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
focused?: Array<string>
open?: Array<string>
},
options?: Options<never, ThrowOnError>,
) {
@@ -2401,7 +2402,8 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
{ in: "body", key: "focused" },
{ in: "body", key: "open" },
],
},
],
+12 -2
View File
@@ -994,6 +994,14 @@ export type EventWorktreeFailed = {
}
}
export type EventKiloSessionsRemoteStatusChanged = {
type: "kilo-sessions.remote-status-changed"
properties: {
enabled: boolean
connected: boolean
}
}
export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
@@ -1043,6 +1051,7 @@ export type Event =
| EventPtyDeleted
| EventWorktreeReady
| EventWorktreeFailed
| EventKiloSessionsRemoteStatusChanged
export type GlobalEvent = {
directory: string
@@ -3992,7 +4001,8 @@ export type PermissionRespondResponse = PermissionRespondResponses[keyof Permiss
export type SessionViewedData = {
body?: {
sessionID?: string
focused?: Array<string>
open?: Array<string>
}
path?: never
query?: {
@@ -4004,7 +4014,7 @@ export type SessionViewedData = {
export type SessionViewedResponses = {
/**
* Viewed session updated
* Viewed sessions updated
*/
200: boolean
}
+45 -5
View File
@@ -4358,11 +4358,11 @@
}
}
],
"summary": "Set viewed session",
"description": "Notify the server which session the user is currently viewing, or clear it.",
"summary": "Set viewed sessions",
"description": "Notify the server which sessions the user is currently viewing, or clear all.",
"responses": {
"200": {
"description": "Viewed session updated",
"description": "Viewed sessions updated",
"content": {
"application/json": {
"schema": {
@@ -4378,8 +4378,17 @@
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
"focused": {
"type": "array",
"items": {
"type": "string"
}
},
"open": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -12373,6 +12382,34 @@
},
"required": ["type", "properties"]
},
"Event.kilo-sessions.remote-status-changed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "kilo-sessions.remote-status-changed"
},
"properties": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"connected": {
"type": "boolean"
}
},
"required": [
"enabled",
"connected"
]
}
},
"required": [
"type",
"properties"
]
},
"Event": {
"anyOf": [
{
@@ -12518,6 +12555,9 @@
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed"
}
]
},
+3 -1
View File
@@ -22,6 +22,7 @@
* - packages/opencode/src/kilocode/**
* - packages/opencode/test/kilocode/**
* - Any path containing "kilocode" in directory or filename
* - Any path with a directory starting with "kilo-" (e.g. kilo-sessions/)
*/
import { spawnSync } from "node:child_process"
@@ -52,7 +53,7 @@ function changedFiles() {
function isExempt(file: string) {
const norm = file.replaceAll("\\", "/").toLowerCase()
return norm.split("/").some((part) => part.includes("kilocode"))
return norm.split("/").some((part) => part.includes("kilocode") || part.startsWith("kilo-"))
}
function isSource(file: string) {
@@ -180,6 +181,7 @@ console.error(
" - packages/opencode/src/kilocode/**",
" - packages/opencode/test/kilocode/**",
" - Any path containing 'kilocode' in the directory or filename",
" - Any directory starting with 'kilo-' (e.g. kilo-sessions/)",
"",
"See AGENTS.md for details.",
].join("\n"),