mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into feat/vscode-routed-model-usage
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remember the Agent Manager new-worktree sandbox toggle for future sessions.
|
||||
@@ -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.
|
||||
@@ -76,6 +76,7 @@ export {
|
||||
getNotifications,
|
||||
getProfile,
|
||||
getToken,
|
||||
normalizeClawStatus,
|
||||
setOrganization,
|
||||
} from "./server/handlers.js"
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+20
-1
@@ -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()
|
||||
|
||||
+3
-1
@@ -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"}}}"""))
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
|
||||
Reference in New Issue
Block a user