Merge branch 'main' into feat/vscode-routed-model-usage

This commit is contained in:
Marian Alexandru Alecu
2026-06-29 16:11:45 +03:00
committed by GitHub
17 changed files with 198 additions and 23 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remember the Agent Manager new-worktree sandbox toggle for future sessions.
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
"kilo-code": patch
---
Fix opening KiloClaw from the CLI and VS Code slash commands.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Reconnect the JetBrains plugin when its event stream stalls during startup.
+1
View File
@@ -76,6 +76,7 @@ export {
getNotifications,
getProfile,
getToken,
normalizeClawStatus,
setOrganization,
} from "./server/handlers.js"
+17 -1
View File
@@ -120,7 +120,23 @@ export async function getClawStatus(auth: AuthStore) {
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers })
if (!response.ok) throw new GatewayError(await response.text(), response.status)
return response.json()
return normalizeClawStatus(await response.json())
}
function normalizeTime(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString()
return value
}
export function normalizeClawStatus(input: unknown) {
if (!input || typeof input !== "object" || Array.isArray(input)) return input
const data = input as Record<string, unknown>
return {
...data,
...("lastStartedAt" in data ? { lastStartedAt: normalizeTime(data.lastStartedAt) } : {}),
...("lastStoppedAt" in data ? { lastStoppedAt: normalizeTime(data.lastStoppedAt) } : {}),
}
}
export async function getClawChatCredentials(auth: AuthStore): Promise<ClawChatCredentials> {
@@ -82,6 +82,7 @@ class KiloConnectionService(
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
private const val RECONNECT_DELAY_MS = 250L
private const val SSE_CONNECT_TIMEOUT_MS = 5_000L
}
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
@@ -118,6 +119,7 @@ class KiloConnectionService(
private var healthJob: Job? = null
private var processJob: Job? = null
private var reconnectJob: Job? = null
private var timeoutJob: Job? = null
/**
* Open a connection to the CLI server.
@@ -168,6 +170,7 @@ class KiloConnectionService(
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
timeoutJob?.cancel()
log.info("teardown: closing SSE event source")
source.getAndSet(null)?.cancel()
log.info("teardown: shutting down OkHttp clients")
@@ -183,6 +186,7 @@ class KiloConnectionService(
close()
processJob?.cancel()
healthJob?.cancel()
timeoutJob?.cancel()
setState(ConnectionState.Connecting)
@@ -233,13 +237,25 @@ class KiloConnectionService(
// Reset heartbeat timestamp before connecting so the watcher
// doesn't fire against a stale timestamp from the old connection.
lastEvent.set(System.currentTimeMillis())
source.set(factory.newEventSource(request, listener))
val src = factory.newEventSource(request, listener)
source.set(src)
log.info("SSE: connecting to port $port")
timeoutJob?.cancel()
timeoutJob = cs.launch {
delay(SSE_CONNECT_TIMEOUT_MS)
if (source.get() !== src) return@launch
if (_state.value !is ConnectionState.Connecting) return@launch
log.warn("SSE: connection timed out - scheduling reconnect")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
}
private val listener = object : EventSourceListener() {
override fun onOpen(src: EventSource, response: Response) {
if (source.get() !== src) return
if (response.request.url.port != port) return
timeoutJob?.cancel()
log.info("SSE: connected")
setState(ConnectionState.Connected(port, password))
lastEvent.set(System.currentTimeMillis())
@@ -261,12 +277,14 @@ class KiloConnectionService(
override fun onClosed(src: EventSource) {
if (source.get() !== src) return
timeoutJob?.cancel()
log.info("SSE: stream closed — scheduling reconnect")
scheduleReconnect()
}
override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
if (source.get() !== src) return
timeoutJob?.cancel()
val raw = response?.body?.string()?.trim()?.ifEmpty { null }
val body = raw?.let { ChatLogSummary.body(it) }
val detail = t?.stackTraceToString() ?: body
@@ -383,6 +401,7 @@ class KiloConnectionService(
healthJob?.cancel()
processJob?.cancel()
reconnectJob?.cancel()
timeoutJob?.cancel()
eventJob.cancel()
queue.close()
close()
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
@@ -120,7 +121,8 @@ class KiloBackendChatManagerTest {
val chat = KiloBackendChatManager(scope, log)
chat.start(OkHttpClient(), port, sse)
val received = async { withTimeout(5_000) { chat.events.first() } }
val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } }
withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } }
sse.emit(SseEvent("session.error", """{"payload":{"properties":{"sessionID":"ses_abc","error":42}}}"""))
sse.emit(SseEvent("session.turn.open", """{"payload":{"properties":{"sessionID":"ses_abc"}}}"""))
@@ -40,10 +40,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
var globalConfigDisplayPath = globalConfigPath
var localConfigExists = true
var globalConfigExists = true
val fileCalls = mutableListOf<Pair<String, String>>()
val searchQueries = mutableListOf<String>()
val fileCalls = CopyOnWriteArrayList<Pair<String, String>>()
val searchQueries = CopyOnWriteArrayList<String>()
val opened = CopyOnWriteArrayList<String>()
val localConfigs = mutableListOf<String>()
val localConfigs = CopyOnWriteArrayList<String>()
var globalConfigs = 0
var localConfigPathCalls = 0
private set
+4 -1
View File
@@ -1041,6 +1041,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "openSettingsPanel":
vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab)
break
case "openKiloClaw":
vscode.commands.executeCommand("kilo-code.new.kiloClawOpen")
break
case "openVSCodeSettings":
vscode.commands.executeCommand("workbench.action.openSettings", message.query)
break
@@ -1171,7 +1174,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.fetchAndSendSandboxStatus(message.sessionID)
break
case "requestSandboxDefault":
await this.fetchAndSendSandboxDefault(message.contextDirectory)
await this.fetchAndSendSandboxDefault(message.contextDirectory, message.requestID)
break
case "setSandboxDefault":
await this.handleSetSandboxDefault(message.enabled, message.requestID, message.contextDirectory)
@@ -643,6 +643,7 @@ interface SendCommandIn {
interface RequestSandboxDefaultIn {
type: "requestSandboxDefault"
requestID?: string
agentManagerContext?: string
contextDirectory?: string
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
const path = join(__dirname, "..", "..", "webview-ui", "agent-manager", "NewWorktreeDialog.tsx")
const providerPath = join(__dirname, "..", "..", "src", "KiloProvider.ts")
const src = readFileSync(path, "utf8")
const provider = readFileSync(providerPath, "utf8")
describe("NewWorktreeDialog sandbox toggle", () => {
it("uses the persisted default and only sends explicit modal overrides", () => {
expect(src).toContain('vscode.postMessage({ type: "requestSandboxDefault", requestID: sandboxRequestID })')
expect(src).toContain('if (message.type !== "sandboxDefaultStatus") return')
expect(src).toContain("if (message.requestID !== sandboxRequestID) return")
expect(src).toContain("setSandbox(message.enabled)")
expect(src).toContain("setSandboxOverride(next === sandboxDefault() ? undefined : next)")
expect(src).toContain(
'vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })',
)
expect(src).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined")
expect(src).toContain("const sandboxVisible = () => features().sandboxControls")
expect(provider).toContain("await this.fetchAndSendSandboxDefault(message.contextDirectory, message.requestID)")
expect(src).not.toContain("createSignal(config().experimental?.sandbox === true)")
expect(src).not.toContain("visible as isSandboxVisible")
})
})
@@ -123,10 +123,15 @@ describe("Agent Manager sandbox startup", () => {
expect(provider).toContain("wt.result.path, wt.result.branch, session.id")
})
test("uses the experiment-aware visibility condition for UI and payload", () => {
expect(dialog).toContain("const sandboxVisible = () => isSandboxVisible(features(), config())")
expect(dialog).toContain("sandbox: sandboxVisible() ? sandbox() : undefined")
test("uses the persisted sandbox default for UI and only sends explicit overrides", () => {
expect(dialog).toContain("const sandboxVisible = () => features().sandboxControls")
expect(dialog).toContain('vscode.postMessage({ type: "requestSandboxDefault", requestID: sandboxRequestID })')
expect(dialog).toContain(
'vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })',
)
expect(dialog).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined")
expect(dialog).toContain("<Show when={sandboxVisible()}>")
expect(dialog).not.toContain("visible as isSandboxVisible")
})
test("places the sandbox toggle with prompt actions instead of model selectors", () => {
@@ -20,7 +20,6 @@ import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { visible as isSandboxVisible } from "../src/components/settings/sandboxing"
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
import { SandboxButtonBase, SandboxTooltipContent } from "../src/components/shared/SandboxButton"
import {
@@ -104,8 +103,14 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
const [sandbox, setSandbox] = createSignal(config().experimental?.sandbox === true)
const sandboxVisible = () => isSandboxVisible(features(), config())
const [sandbox, setSandbox] = createSignal<boolean | undefined>()
const [sandboxDefault, setSandboxDefault] = createSignal<boolean | undefined>()
const [sandboxOverride, setSandboxOverride] = createSignal<boolean | undefined>()
const [sandboxAvailable, setSandboxAvailable] = createSignal(true)
const [sandboxReason, setSandboxReason] = createSignal<string | undefined>()
const [sandboxRevision, setSandboxRevision] = createSignal(-1)
const sandboxRequestID = crypto.randomUUID()
const sandboxVisible = () => features().sandboxControls
const speech = useSpeechToText(vscode, server, { t })
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
@@ -146,6 +151,45 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
if (!stored || !list.includes(stored)) setVariant(list[0])
})
createEffect(() => {
if (!sandboxVisible()) return
if (server.connectionState() !== "connected") {
setSandbox(undefined)
setSandboxDefault(undefined)
setSandboxOverride(undefined)
return
}
vscode.postMessage({ type: "requestSandboxDefault", requestID: sandboxRequestID })
})
const unsubSandbox = vscode.onMessage((message) => {
if (message.type !== "sandboxDefaultStatus") return
if (message.requestID !== sandboxRequestID) return
if (message.revision < sandboxRevision()) return
setSandboxRevision(message.revision)
setSandboxDefault(message.desired)
setSandboxAvailable(message.available)
setSandboxReason(message.reason)
const override = sandboxOverride()
if (override === undefined) {
setSandbox(message.enabled)
return
}
if (override === message.desired) setSandboxOverride(undefined)
})
onCleanup(unsubSandbox)
const toggleSandbox = () => {
const current = sandbox()
if (current === undefined || !sandboxAvailable()) return
const next = !current
setSandbox(next)
setSandboxOverride(next === sandboxDefault() ? undefined : next)
vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })
}
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
const cwd = server.workspaceDirectory()
@@ -250,7 +294,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
baseBranch: advanced ? (baseBranch() ?? undefined) : undefined,
branchName: customBranch,
modelAllocations: allocations,
sandbox: sandboxVisible() ? sandbox() : undefined,
sandbox: sandboxVisible() ? sandboxOverride() : undefined,
files: imgFiles,
})
@@ -467,20 +511,20 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<div class="prompt-input-hint-actions">
<Show when={sandboxVisible()}>
<SandboxButtonBase
enabled={sandbox()}
enabled={sandbox() ?? false}
available={sandbox() === undefined ? undefined : sandboxAvailable()}
reason={sandboxReason()}
disabled={sandbox() === undefined}
tooltip={
<SandboxTooltipContent
enabled={sandbox()}
enabled={sandbox() ?? false}
network={config().experimental?.sandbox_restrict_network !== false}
/>
}
tooltipClass="prompt-sandbox-tooltip-content"
onToggle={click(
"sandbox_toggle",
"configure_worktree_dialog",
() => setSandbox(!sandbox()),
() => ({ enabled: !sandbox() }),
)}
onToggle={click("sandbox_toggle", "configure_worktree_dialog", toggleSandbox, () => ({
enabled: !(sandbox() ?? false),
}))}
/>
</Show>
<Show when={canUseSpeech()}>
@@ -143,6 +143,14 @@ export function useSlashCommand(
vscode.postMessage({ type: "toggleRemote" })
},
},
{
name: "kiloclaw",
description: "Open KiloClaw chat",
hints: ["claw"],
action: () => {
vscode.postMessage({ type: "openKiloClaw" })
},
},
{
name: "sandbox",
description: "Toggle sandbox",
@@ -234,6 +234,10 @@ export interface OpenAdvancedWorktreeRequest {
type: "openAdvancedWorktree"
}
export interface OpenKiloClawRequest {
type: "openKiloClaw"
}
export interface RequestAgentsMessage {
type: "requestAgents"
}
@@ -960,6 +964,7 @@ export interface RequestSandboxStatusMessage {
export interface RequestSandboxDefaultMessage {
type: "requestSandboxDefault"
requestID?: string
agentManagerContext?: string
contextDirectory?: string
}
@@ -1179,6 +1184,7 @@ export type WebviewMessage =
| OpenMarketplacePanelRequest
| OpenAgentManagerRequest
| OpenAdvancedWorktreeRequest
| OpenKiloClawRequest
| OpenFileRequest
| ValidateFilesRequest
| CancelLoginRequest
@@ -6,6 +6,7 @@ import {
getOrganizationId,
getToken,
importSessionToDb,
normalizeClawStatus,
} from "@kilocode/kilo-gateway"
import {
HEADER_FEATURE,
@@ -360,7 +361,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
try: async () => {
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers })
if (!response.ok) throw new GatewayError(await response.text(), response.status)
return Schema.decodeUnknownPromise(ClawStatus)(await response.json())
return Schema.decodeUnknownPromise(ClawStatus)(normalizeClawStatus(await response.json()))
},
catch: (err) => err,
}).pipe(
@@ -183,6 +183,32 @@ describe("Kilo gateway HttpApi statuses", () => {
}),
)
it.live("normalizes numeric KiloClaw timestamps", () =>
Effect.gen(function* () {
const started = 1_700_000_000_000
yield* stub(() =>
Response.json({
status: "running",
sandboxId: "sandbox",
userId: "user",
lastStartedAt: started,
lastStoppedAt: null,
}),
)
const response = yield* HttpClient.get(KiloGatewayPaths.clawStatus)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
status: "running",
sandboxId: "sandbox",
userId: "user",
lastStartedAt: new Date(started).toISOString(),
lastStoppedAt: null,
})
}),
)
it.live("maps KiloClaw transport failures to bad gateway", () =>
Effect.gen(function* () {
yield* stub(() => Promise.reject(new TypeError("network error")))