From 8bab99c025e7a91120337e1584ca5628544e0ffa Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 23 Oct 2026 17:51:04 +0200 Subject: [PATCH 1/3] feat(vscode): add experimental hosted cloud sessions Discover, open, start, and interact with Kilo Cloud hosted sessions through the local Kilo backend instead of talking to Cloud directly. Hosted sessions use a namespaced host:: key and one attachment registry, so colliding native session ids never cross-route. Durable reads use the session-host transcript surface and never attach, while live work attaches lazily, retries once on a recoverable attachment failure, and re-snapshots when continuity cannot be proven. The feature is gated by kilo-code.new.experimental.hostedSessions. --- .changeset/hosted-sessions-vscode.md | 5 + packages/kilo-vscode/package.json | 6 + packages/kilo-vscode/src/KiloProvider.ts | 330 +++++++++++++ .../kilo-vscode/src/diff/sources/catalog.ts | 6 + packages/kilo-vscode/src/hosted/events.ts | 53 +++ packages/kilo-vscode/src/hosted/failure.ts | 86 ++++ packages/kilo-vscode/src/hosted/identity.ts | 33 ++ packages/kilo-vscode/src/hosted/registry.ts | 106 +++++ packages/kilo-vscode/src/hosted/service.ts | 436 ++++++++++++++++++ .../src/kilo-provider/chat-settings.ts | 2 + .../src/kilo-provider/handlers/hosted.ts | 125 +++++ .../cli-backend/connection-service.ts | 3 + .../tests/unit/chat-settings-message.test.ts | 1 + .../tests/unit/hosted-core.test.ts | 102 ++++ .../tests/unit/hosted-identity.test.ts | 31 ++ .../tests/unit/hosted-service.test.ts | 238 ++++++++++ .../tests/unit/prompt-send-contract.test.ts | 29 +- packages/kilo-vscode/webview-ui/src/App.tsx | 6 +- .../src/components/history/HistoryView.tsx | 66 ++- .../components/history/HostedSessionList.tsx | 168 +++++++ .../components/history/HostedStartDialog.tsx | 99 ++++ .../components/settings/ExperimentalTab.tsx | 13 + .../src/context/cloud-session-failure.ts | 46 ++ .../webview-ui/src/context/config.tsx | 1 + .../src/context/hosted-session-bridge.ts | 69 +++ .../webview-ui/src/context/session-store.ts | 34 ++ .../webview-ui/src/context/session-types.ts | 1 + .../webview-ui/src/context/session.tsx | 109 ++--- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 10 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 10 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 11 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 10 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 10 + .../webview-ui/src/styles/history.css | 30 +- .../src/types/messages/extension-messages.ts | 76 +++ .../src/types/messages/webview-messages.ts | 40 ++ 52 files changed, 2498 insertions(+), 79 deletions(-) create mode 100644 .changeset/hosted-sessions-vscode.md create mode 100644 packages/kilo-vscode/src/hosted/events.ts create mode 100644 packages/kilo-vscode/src/hosted/failure.ts create mode 100644 packages/kilo-vscode/src/hosted/identity.ts create mode 100644 packages/kilo-vscode/src/hosted/registry.ts create mode 100644 packages/kilo-vscode/src/hosted/service.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts create mode 100644 packages/kilo-vscode/tests/unit/hosted-core.test.ts create mode 100644 packages/kilo-vscode/tests/unit/hosted-identity.test.ts create mode 100644 packages/kilo-vscode/tests/unit/hosted-service.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/history/HostedSessionList.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/context/cloud-session-failure.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-store.ts diff --git a/.changeset/hosted-sessions-vscode.md b/.changeset/hosted-sessions-vscode.md new file mode 100644 index 000000000000..2962ca206ca1 --- /dev/null +++ b/.changeset/hosted-sessions-vscode.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add experimental hosted cloud sessions: discover, start, read, and interact with Kilo Cloud sessions from VS Code through the local Kilo backend. Enable it with the `kilo-code.new.experimental.hostedSessions` setting. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 8d8ec117e9ae..cd950b27f0d0 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1099,6 +1099,12 @@ "scope": "application", "description": "Enable the experimental Integrated Browser panel and browser_open tool in Agent Manager." }, + "kilo-code.new.experimental.hostedSessions": { + "type": "boolean", + "default": false, + "scope": "application", + "description": "Discover, start, and interact with Kilo Cloud hosted sessions from VS Code through the local Kilo backend." + }, "kilo-code.new.experimental.conversationPromptHistory": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 44f669830a78..04cb440025bb 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -135,6 +135,17 @@ import { handleImportAndSend, type CloudSessionContext, } from "./kilo-provider/handlers/cloud-session" +import { + handleHostedDetach, + handleHostedOpen, + handleHostedProviders, + handleHostedSessions, + handleHostedStart, + hostedRepository, + type HostedContext, +} from "./kilo-provider/handlers/hosted" +import { isHostedKey } from "./hosted/identity" +import type { HostedTurn } from "./hosted/service" import { handlePermissionResponse, fetchAndSendPendingPermissions, @@ -148,6 +159,7 @@ import { import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity" +import { Identifier } from "../webview-ui/src/utils/id" import type { PRReviewCommentData, ReviewMessageData } from "./shared/review-comments" import { feedbackMetadata, parseFeedback, type BrowserFeedbackData } from "./shared/browser-feedback" import { mergeInjected } from "./shared/injected-prompt" @@ -514,6 +526,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private sessionGitDirectories = new Map() // Stable Git root resolved for each session. private sessionGitRecoveries = new Set() // Sessions whose older history was scanned for a Git root. private readonly aborts = new SessionAbort() + private readonly hostedUnsubs = new Map void>() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. @@ -1311,6 +1324,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (this.handleEditorOpenMessage(message)) return + if (await this.handleHostedMessage(message)) return if (await this.handleMcpMessage(message)) return if (await this.handleAgentManagerSettingsMessage(message)) return if (await this.handleAutoCleanupMessage(message)) return @@ -4732,6 +4746,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (isHostedKey(sessionID)) { + await this.sendHostedMessage({ + key: sessionID, + text, + messageID, + providerID, + modelID, + agent, + variant, + files, + review, + browserFeedback, + injectedTitle, + }) + return + } + let resolved: { sid: string; dir: string } | undefined try { const sandbox = this.sandboxTransitions.get( @@ -5097,6 +5128,303 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Cloud session handlers extracted to kilo-provider/handlers/cloud-session.ts + private get hostedCtx(): HostedContext { + return { + hosted: this.connectionService.hosted, + postMessage: (msg) => this.postMessage(msg), + mapTurns: (items) => this.mapHostedTurns(items), + watch: (key) => { + this.trackedSessionIds.add(key) + this.watchHosted(key) + }, + } + } + + /** Hosted discovery, open, start, and detach, routed ahead of the main switch. */ + private async handleHostedMessage(message: TypedWebviewMessage & Record): Promise { + switch (message.type) { + case "requestHostedProviders": + await handleHostedProviders(this.hostedCtx) + return true + case "requestHostedSessions": + await handleHostedSessions(this.hostedCtx, typeof message.limit === "number" ? message.limit : undefined) + return true + case "hostedOpenSession": + if (typeof message.providerID === "string" && typeof message.sessionID === "string") { + await handleHostedOpen( + this.hostedCtx, + message.providerID, + message.sessionID, + typeof message.limit === "number" ? message.limit : undefined, + ) + } + return true + case "hostedStartSession": + await this.startHosted(message) + return true + case "hostedDetachSession": + if (typeof message.key === "string") { + this.hostedUnsubs.get(message.key)?.() + this.hostedUnsubs.delete(message.key) + await handleHostedDetach(this.hostedCtx, message.key) + } + return true + case "loadMessages": { + if (!isHostedKey(typeof message.sessionID === "string" ? message.sessionID : undefined)) return false + void this.handleHostedMessages(message.sessionID as string, { + mode: message.mode as MessageLoadMode | undefined, + before: typeof message.before === "string" ? message.before : undefined, + limit: typeof message.limit === "number" ? message.limit : undefined, + }) + return true + } + case "abort": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key)) return false + this.cancelRetry(key) + await this.connectionService.hosted + .abort(key) + .catch((error) => console.error("[Kilo New] KiloProvider: Failed to abort hosted session:", error)) + this.publish(key, { type: "idle" }) + return true + } + case "permissionResponse": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.permissionId !== "string") return false + const response = message.response + if (response !== "once" && response !== "always" && response !== "reject") return false + await this.connectionService.hosted + .replyPermission(key, message.permissionId, response) + .then(() => this.postMessage({ type: "permissionResolved", requestID: message.permissionId })) + .catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to answer hosted permission:", error) + this.postMessage({ type: "permissionError", requestID: message.permissionId }) + }) + return true + } + case "questionReply": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.requestID !== "string") return false + const answers = Array.isArray(message.answers) ? (message.answers as string[][]) : [] + this.noteFollowup(answers, key) + const ok = await this.connectionService.hosted + .replyQuestion(key, message.requestID, answers) + .then(() => true) + .catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to answer hosted question:", error) + this.postMessage({ type: "questionError", requestID: message.requestID }) + return false + }) + if (!ok) this.pendingFollowup = null + return true + } + case "questionReject": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.requestID !== "string") return false + this.pendingFollowup = null + await this.connectionService.hosted.rejectQuestion(key, message.requestID).catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to reject hosted question:", error) + this.postMessage({ type: "questionError", requestID: message.requestID }) + }) + return true + } + default: + return false + } + } + + private async startHosted(message: Record): Promise { + const repository = hostedRepository({ + type: typeof message.repositoryType === "string" ? message.repositoryType : undefined, + repo: typeof message.repo === "string" ? message.repo : undefined, + url: typeof message.url === "string" ? message.url : undefined, + branch: typeof message.branch === "string" ? message.branch : undefined, + }) + if ( + !repository || + typeof message.prompt !== "string" || + typeof message.mode !== "string" || + typeof message.model !== "string" || + typeof message.operationKey !== "string" || + typeof message.messageID !== "string" + ) { + this.postMessage({ + type: "hostedSessionFailed", + error: "Hosted session start requires a repository, prompt, agent, and model", + }) + return + } + await handleHostedStart(this.hostedCtx, { + repository, + prompt: message.prompt, + mode: message.mode, + model: message.model, + ...(typeof message.organizationID === "string" ? { organizationID: message.organizationID } : {}), + operationKey: message.operationKey, + messageID: message.messageID, + }) + } + + private mapHostedTurns(items: HostedTurn[]) { + return items.map((item) => ({ + ...this.slimInfo(item.info), + parts: this.slimParts(item.parts), + createdAt: new Date(item.info.time.created).toISOString(), + })) + } + + /** Mirrors `handleLoadMessages` for a hosted key without touching local routing. */ + private async handleHostedMessages( + key: string, + options: { mode?: MessageLoadMode; limit?: number; before?: string }, + ): Promise { + this.trackedSessionIds.add(key) + try { + const page = await this.connectionService.hosted.transcript(key, options.before, options.limit) + this.postMessage({ + type: "messagesLoaded", + sessionID: key, + messages: this.mapHostedTurns(page.items), + mode: options.mode ?? "replace", + cursor: page.cursor, + hasMore: Boolean(page.cursor), + }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to load hosted messages:", error) + this.postMessage({ + type: "error", + message: getErrorMessage(error) || "Failed to load hosted messages", + sessionID: key, + }) + } + } + + /** Re-reads every hosted state surface after continuity could not be proven. */ + private async resnapshotHosted(key: string): Promise { + try { + const open = await this.connectionService.hosted.resnapshot(key) + this.postMessage({ + type: "messagesLoaded", + sessionID: key, + messages: this.mapHostedTurns(open.items), + mode: "replace", + cursor: open.cursor, + hasMore: Boolean(open.cursor), + }) + const pending = await this.connectionService.hosted.pending(key) + for (const [sessionID, status] of Object.entries(pending.status)) { + if (sessionID !== open.session.id) continue + this.postMessage({ type: "sessionStatus", sessionID: key, status: status.type }) + } + for (const question of pending.questions as Array>) { + this.postMessage({ + type: "questionRequest", + question: { ...question, sessionID: key }, + }) + } + for (const permission of pending.permissions as Array>) { + this.postMessage({ + type: "permissionRequest", + permission: { + id: permission.id, + sessionID: key, + toolName: permission.permission, + patterns: permission.patterns, + always: permission.always, + args: permission.metadata, + message: `Permission required: ${String(permission.permission)}`, + tool: permission.tool, + }, + }) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to resnapshot hosted session:", error) + } + } + + private watchHosted(key: string): void { + if (this.hostedUnsubs.has(key)) return + const off = this.connectionService.hosted.subscribe( + key, + (payload) => this.handleHostedEvent(key, payload), + (failure) => { + // Terminal: drop the subscription so a later action can resubscribe. + this.hostedUnsubs.delete(key) + this.postMessage({ + type: "hostedSessionFailed", + error: failure.message, + code: failure.code, + sessionID: key, + }) + }, + () => void this.resnapshotHosted(key), + ) + this.hostedUnsubs.set(key, off) + } + + private handleHostedEvent(key: string, payload: unknown): void { + const event = payload as Parameters[0] + if (!event || typeof event.type !== "string") return + if (event.type === "question.asked" || event.type === "permission.asked") { + const properties = (event as { properties?: { id?: unknown } }).properties + if (typeof properties?.id === "string") { + this.connectionService.recordQuestionDirectory(properties.id, key) + this.connectionService.recordPermissionDirectory(properties.id, key, key) + } + } + const msg = mapSSEEventToWebviewMessage(event, key) + if (msg) this.postMessage(msg) + } + + private async sendHostedMessage(input: { + key: string + text: string + messageID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: MessageFile[] + review?: ReviewMessageData + browserFeedback?: BrowserFeedbackData + injectedTitle?: string + }): Promise { + const parts: Array> = [] + for (const file of input.files ?? []) { + parts.push({ type: "file", mime: file.mime, url: file.url, filename: file.filename, source: file.source }) + } + parts.push({ + type: "text", + text: input.text, + metadata: mergeInjected(feedbackMetadata(input.review, input.browserFeedback), input.injectedTitle), + }) + const messageID = input.messageID ?? Identifier.ascending("message") + try { + this.trackedSessionIds.add(input.key) + this.connectionService.recordMessageSessionId(messageID, input.key) + this.watchHosted(input.key) + await this.connectionService.hosted.prompt(input.key, { + messageID, + parts, + agent: input.agent, + model: input.providerID && input.modelID ? { providerID: input.providerID, modelID: input.modelID } : undefined, + variant: input.variant, + }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to send hosted message:", error) + this.postMessage({ + type: "sendMessageFailed", + error: getErrorMessage(error) || "Failed to send message", + text: input.text, + sessionID: input.key, + messageID, + files: input.files, + review: input.review, + browserFeedback: input.browserFeedback, + }) + } + } + private get cloudSessionCtx(): CloudSessionContext { const self = this return { @@ -6318,6 +6646,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ dispose(): void { this.opts.onHidden?.() + for (const off of this.hostedUnsubs.values()) off() + this.hostedUnsubs.clear() if (this.opts.focusContext) { void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 0ab753a88d91..957a18880bfa 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -4,6 +4,7 @@ import { GitOps } from "../../agent-manager/GitOps" import { gitGeneratedFiles } from "../shared/git-attributes" import { resolveLocalDiffTarget } from "../shared/target" import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import { isHostedKey } from "../../hosted/identity" import type { BranchListItem } from "../../agent-manager/git-import" import type { PanelContext } from "../types" import type { DiffSource, DiffSourceDescriptor } from "./types" @@ -44,6 +45,7 @@ export interface WorkspaceBranchesResult { */ export class DiffSourceCatalog implements vscode.Disposable { private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { + if (isHostedKey(sessionID)) return [] const client = this.connection.getClient() const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) return data ?? [] @@ -53,8 +55,12 @@ export class DiffSourceCatalog implements vscode.Disposable { * Turn diffs are stored on the user message itself (`summary.diffs`), not * on the session-level snapshot. The `/session/:id/diff` endpoint ignores * its `messageID` param today, so we fetch the message directly instead. + * + * Single-message reads are not part of the hosted native allowlist, so a + * hosted session returns no turn diffs rather than failing the view. */ private readonly turnFetch: TurnDiffFetch = async ({ sessionID, messageID, directory }) => { + if (isHostedKey(sessionID)) return [] const client = this.connection.getClient() const { data } = await client.session.message({ sessionID, messageID, directory }, { throwOnError: true }) const info = data?.info diff --git a/packages/kilo-vscode/src/hosted/events.ts b/packages/kilo-vscode/src/hosted/events.ts new file mode 100644 index 000000000000..31a58b278960 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/events.ts @@ -0,0 +1,53 @@ +/** + * Hosted stream framing. + * + * The local server relays the provider's native SSE frames unchanged. It does + * not define a schema for them, so the client has to recognise three shapes: + * ordinary events, generation fences, and full-resnapshot requests. Anything + * unrecognised is forwarded as an event; a resnapshot always wins over a + * concurrent event because continuity can no longer be proven. + */ +export type FrameKind = "event" | "fence" | "resnapshot" | "ignore" + +export interface Frame { + kind: FrameKind + payload?: unknown + directory?: string +} + +function object(value: unknown): Record | undefined { + if (value && typeof value === "object") return value as Record + return +} + +export function classifyFrame(item: unknown): Frame { + const envelope = object(item) + if (!envelope) return { kind: "ignore" } + const payload = object(envelope.payload) ?? envelope + const type = typeof payload.type === "string" ? payload.type : undefined + const directory = typeof envelope.directory === "string" ? envelope.directory : undefined + if (!type) return { kind: "ignore" } + if (type === "kilo.native.resnapshot") return { kind: "resnapshot", payload, directory } + if (type === "kilo.native.fence") return { kind: "fence", payload, directory } + return { kind: "event", payload, directory } +} + +/** + * Rewrites the native session identity carried by a hosted event to the + * namespaced view key, so the existing renderer and stores can key on it + * without knowing about attachments. + */ +export function retag(payload: unknown, sessionID: string, key: string): unknown { + const event = object(payload) + if (!event) return payload + const properties = object(event.properties) + if (!properties) return payload + if (properties.sessionID !== sessionID) return payload + const next: Record = { ...properties, sessionID: key } + for (const field of ["info", "part"]) { + const value = object(properties[field]) + if (!value || value.sessionID !== sessionID) continue + next[field] = { ...value, sessionID: key } + } + return { ...event, properties: next } +} diff --git a/packages/kilo-vscode/src/hosted/failure.ts b/packages/kilo-vscode/src/hosted/failure.ts new file mode 100644 index 000000000000..2cda24019c19 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/failure.ts @@ -0,0 +1,86 @@ +/** + * Attachment failures. + * + * Routed-attachment errors are not part of the generated SDK error union: the + * server answers `{ error: "KILO_SESSION_ATTACHMENT", code, message }`. The + * session-host control plane answers `{ code, message }`. Both shapes are + * parsed here so callers can decide between re-attaching and surfacing a + * failure. There is never a local fallback for a failed attachment. + */ +const CODES = [ + "unknown", + "expired", + "disposed", + "unavailable", + "too_large", + "invalid_request", + "auth_unavailable", + "signed_out", + "unauthorized", + "payment_required", + "not_found", + "invalid_response", + "rejected", + "unknown_provider", +] as const + +export type AttachmentCode = (typeof CODES)[number] + +export interface AttachmentFailure { + code: AttachmentCode + message: string + /** True when re-attaching can plausibly restore the session. */ + recoverable: boolean +} + +const KNOWN = new Set(CODES) + +const RECOVERABLE = new Set(["unknown", "expired", "disposed", "unavailable", "auth_unavailable"]) + +function object(value: unknown): Record | undefined { + if (typeof value === "string") { + return parse(value) + } + if (value && typeof value === "object") return value as Record + return +} + +function parse(text: string): Record | undefined { + if (!text.startsWith("{")) return + try { + const value = JSON.parse(text) as unknown + if (value && typeof value === "object") return value as Record + } catch { + return + } + return +} + +export function attachmentFailure(error: unknown): AttachmentFailure | undefined { + const body = bodyOf(error) + if (!body) return + const code = typeof body.code === "string" ? body.code : undefined + if (!code || !KNOWN.has(code)) return + const message = typeof body.message === "string" ? body.message : code + return { code: code as AttachmentCode, message, recoverable: RECOVERABLE.has(code as AttachmentCode) } +} + +/** + * The SDK wraps thrown bodies in an `Error` whose `cause.body` holds the parsed + * response, so both the raw body and the wrapped form are understood. + */ +function bodyOf(error: unknown): Record | undefined { + const direct = object(error) + if (direct && typeof direct.code === "string") return direct + if (direct) { + const cause = object(direct.cause) + const body = cause ? object(cause.body) : undefined + if (body) return body + return direct + } + return +} + +export function isRecoverable(error: unknown): boolean { + return attachmentFailure(error)?.recoverable === true +} diff --git a/packages/kilo-vscode/src/hosted/identity.ts b/packages/kilo-vscode/src/hosted/identity.ts new file mode 100644 index 000000000000..5f360dc327c2 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/identity.ts @@ -0,0 +1,33 @@ +/** + * Hosted session identity. + * + * A provider's native session id is not unique across providers, and the + * extension keys its stores, tabs, and caches by a single string. Hosted + * sessions therefore use a namespaced view key that can never collide with a + * local session id or the legacy `cloud:` preview key. + */ +const PREFIX = "host:" + +export interface HostedIdentity { + providerID: string + sessionID: string +} + +export function hostedKey(providerID: string, sessionID: string): string { + return `${PREFIX}${providerID}:${sessionID}` +} + +export function isHostedKey(key: string | null | undefined): key is string { + return typeof key === "string" && key.startsWith(PREFIX) +} + +export function parseHostedKey(key: string | null | undefined): HostedIdentity | undefined { + if (!isHostedKey(key)) return + const rest = key.slice(PREFIX.length) + const index = rest.indexOf(":") + if (index <= 0) return + const providerID = rest.slice(0, index) + const sessionID = rest.slice(index + 1) + if (!providerID || !sessionID) return + return { providerID, sessionID } +} diff --git a/packages/kilo-vscode/src/hosted/registry.ts b/packages/kilo-vscode/src/hosted/registry.ts new file mode 100644 index 000000000000..d6180186598a --- /dev/null +++ b/packages/kilo-vscode/src/hosted/registry.ts @@ -0,0 +1,106 @@ +/** + * Local attachment registry. + * + * Attachments are process-local on the backend, so the client caches at most + * one per hosted identity and treats a backend restart as invalidation. A + * burst of requests for the same identity shares one attach call, and a + * detach or invalidation always wins over an in-flight attach. + */ +export interface Attachment { + key: string + providerID: string + sessionID: string + attachmentID: string + /** Provider-declared descriptor expiry. A hint for diagnostics only. */ + expiresAt: number +} + +export interface AttachmentPort { + attach(input: { providerID: string; sessionID: string }): Promise<{ attachmentID: string; expiresAt: number }> + detach(attachmentID: string): Promise +} + +interface Pending { + promise: Promise + resolve: (attachment: Attachment) => void + reject: (error: unknown) => void +} + +export class HostedRegistry { + private readonly entries = new Map() + private readonly pending = new Map() + private readonly versions = new Map() + + private version(key: string): number { + return this.versions.get(key) ?? 0 + } + + private stale(key: string): void { + this.versions.set(key, this.version(key) + 1) + this.entries.delete(key) + const pending = this.pending.get(key) + this.pending.delete(key) + pending?.reject(new Error("Hosted attachment was invalidated")) + } + + get(key: string): Attachment | undefined { + return this.entries.get(key) + } + + keys(): string[] { + return [...this.entries.keys()] + } + + ensure(key: string, identity: { providerID: string; sessionID: string }, port: AttachmentPort): Promise { + const found = this.entries.get(key) + if (found) return Promise.resolve(found) + const pending = this.pending.get(key) + if (pending) return pending.promise + + // The rejection is delivered to callers; also mark it handled so an + // invalidation during shutdown cannot surface as an unhandled rejection. + const deferred = Promise.withResolvers() + deferred.promise.catch(() => undefined) + const start = this.version(key) + this.pending.set(key, { promise: deferred.promise, resolve: deferred.resolve, reject: deferred.reject }) + + port.attach(identity).then( + (value) => { + const entry: Attachment = { + key, + providerID: identity.providerID, + sessionID: identity.sessionID, + attachmentID: value.attachmentID, + expiresAt: value.expiresAt, + } + if (this.version(key) !== start) { + deferred.reject(new Error("Hosted attachment was invalidated")) + return + } + this.entries.set(key, entry) + if (this.pending.get(key)?.promise === deferred.promise) this.pending.delete(key) + deferred.resolve(entry) + }, + (error: unknown) => { + if (this.pending.get(key)?.promise === deferred.promise) this.pending.delete(key) + deferred.reject(error) + }, + ) + return deferred.promise + } + + invalidate(key: string): void { + this.stale(key) + } + + async detach(key: string, port: AttachmentPort): Promise { + const found = this.entries.get(key) + this.stale(key) + if (!found) return + await port.detach(found.attachmentID) + } + + reset(): void { + for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.stale(key) + } +} diff --git a/packages/kilo-vscode/src/hosted/service.ts b/packages/kilo-vscode/src/hosted/service.ts new file mode 100644 index 000000000000..cf4087006610 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/service.ts @@ -0,0 +1,436 @@ +import type { + KiloClient, + Message, + Part, + Session, + SessionHostProviderStatus, + SessionHostStartInput, +} from "@kilocode/sdk/v2/client" +import { hostedKey, parseHostedKey, type HostedIdentity } from "./identity" +import { attachmentFailure, isRecoverable, type AttachmentFailure } from "./failure" +import { HostedRegistry, type Attachment, type AttachmentPort } from "./registry" +import { classifyFrame, retag } from "./events" + +export interface HostedSessionSummary { + key: string + providerID: string + sessionID: string + title: string + created: number + updated: number + directory?: string +} + +export interface HostedTurn { + info: Message + parts: Part[] +} + +export interface HostedOpen { + key: string + session: Session + items: HostedTurn[] + cursor?: string + omitted?: number +} + +export interface HostedStart { + key: string + providerID: string + sessionID: string + replayed: boolean + delivery: "sent" | "queued" +} + +export interface HostedStartInput { + repository: SessionHostStartInput["repository"] + prompt: string + mode: string + model: string + organizationID?: string + operationKey: string + messageID: string +} + +export type HostedHit = (payload: unknown) => void +export type HostedUnsubscribe = () => void + +interface Stream { + abort: AbortController + listeners: Set + failures: Set<(failure: AttachmentFailure) => void> + snapshots: Set<() => void> +} + +const RECONNECT_MS = 500 +const RECONNECT_MAX_MS = 5_000 + +/** + * Hosted session access through the shared local backend. + * + * Durable reads never attach. Live work attaches lazily and re-attaches once + * after a recoverable failure; an unrecoverable failure is surfaced, never + * answered with a local session. + */ +export class HostedService { + private readonly registry = new HostedRegistry() + private readonly identities = new Map() + private readonly streams = new Map() + private providers_cache: SessionHostProviderStatus[] = [] + + constructor(private readonly client: () => KiloClient | null) {} + + private port(): AttachmentPort { + const client = this.client() + if (!client) throw new Error("Not connected to the local Kilo backend") + return { + attach: async (input) => { + const { data } = await client.sessionHost.attach(input, { throwOnError: true }) + return { attachmentID: data.attachmentID, expiresAt: data.expiresAt } + }, + detach: async (attachmentID) => { + await client.sessionHost.detach({ attachmentID }, { throwOnError: true }).catch(() => undefined) + }, + } + } + + private require(): KiloClient { + const client = this.client() + if (!client) throw new Error("Not connected to the local Kilo backend") + return client + } + + remember(providerID: string, sessionID: string): string { + const key = hostedKey(providerID, sessionID) + this.identities.set(key, { providerID, sessionID }) + return key + } + + identity(key: string): HostedIdentity | undefined { + return this.identities.get(key) ?? parseHostedKey(key) + } + + async providers(): Promise { + const { data } = await this.require().sessionHost.providers({ throwOnError: true }) + this.providers_cache = data.providers + return data.providers + } + + cache(): SessionHostProviderStatus[] { + return this.providers_cache + } + + async list(limit = 50): Promise { + const { data } = await this.require().sessionHost.list({ limit: String(limit) }, { throwOnError: true }) + return data.items.map((item) => { + const key = this.remember(item.id.providerID, item.id.sessionID) + return { + key, + providerID: item.id.providerID, + sessionID: item.id.sessionID, + title: item.session.title, + created: item.session.time.created, + updated: item.session.time.updated, + directory: item.session.directory, + } + }) + } + + /** Opens a hosted session from durable history only. Never attaches or wakes compute. */ + async open(providerID: string, sessionID: string, limit = 80): Promise { + const key = this.remember(providerID, sessionID) + const client = this.require() + const [detail, page] = await Promise.all([ + client.sessionHost.get({ providerID, sessionID }, { throwOnError: true }), + client.sessionHost.transcript({ providerID, sessionID, limit: String(limit) }, { throwOnError: true }), + ]) + return { + key, + session: detail.data.session, + items: page.data.items, + cursor: page.data.cursor, + omitted: page.data.omitted, + } + } + + async transcript(key: string, before?: string, limit = 80): Promise<{ items: HostedTurn[]; cursor?: string }> { + const identity = this.need(key) + const { data } = await this.require().sessionHost.transcript( + { + providerID: identity.providerID, + sessionID: identity.sessionID, + limit: String(limit), + ...(before ? { before } : {}), + }, + { throwOnError: true }, + ) + return { items: data.items, ...(data.cursor ? { cursor: data.cursor } : {}) } + } + + async start(input: HostedStartInput): Promise { + const { data } = await this.require().sessionHost.start( + { + sessionHostStartInput: { + repository: input.repository, + prompt: input.prompt, + agent: { mode: input.mode, model: input.model }, + ...(input.organizationID ? { organizationID: input.organizationID } : {}), + origin: "desktop", + operationKey: input.operationKey, + messageID: input.messageID, + }, + }, + { throwOnError: true }, + ) + const key = this.remember(data.session.providerID, data.session.sessionID) + return { + key, + providerID: data.session.providerID, + sessionID: data.session.sessionID, + replayed: data.replayed, + delivery: data.delivery, + } + } + + async attach(key: string): Promise { + const identity = this.need(key) + const entry = await this.registry.ensure(key, identity, this.port()) + return entry.attachmentID + } + + async detach(key: string): Promise { + this.stop(key) + await this.registry.detach(key, this.port()) + } + + /** Backend restarts invalidate every attachment. */ + reset(): void { + for (const [key, stream] of [...this.streams]) { + this.streams.delete(key) + stream.abort.abort() + // Tell clients the stream is gone so they can resubscribe after reconnect. + for (const hit of stream.failures) { + hit({ code: "unavailable", message: "The local Kilo backend restarted", recoverable: true }) + } + } + this.registry.reset() + this.providers_cache = [] + } + + /** + * Runs one native request against the current attachment. A recoverable + * attachment failure mints exactly one replacement and retries; anything + * else is surfaced, and no request ever falls back to local routing. + */ + private async withAttachment(key: string, run: (attachmentID: string) => Promise): Promise { + const attachmentID = await this.attach(key) + try { + return await run(attachmentID) + } catch (error) { + if (!isRecoverable(error)) throw error + this.registry.invalidate(key) + return run(await this.attach(key)) + } + } + + async prompt( + key: string, + input: { + messageID: string + parts: Array> + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + editorContext?: Record + }, + ): Promise { + const identity = this.need(key) + await this.withAttachment(key, (workspace) => + this.require().session.promptAsync( + { + sessionID: identity.sessionID, + workspace, + messageID: input.messageID, + parts: input.parts as never, + ...(input.agent ? { agent: input.agent } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.variant ? { variant: input.variant } : {}), + ...(input.editorContext ? { editorContext: input.editorContext as never } : {}), + }, + { throwOnError: true }, + ), + ) + } + + async abort(key: string): Promise { + const identity = this.need(key) + await this.withAttachment(key, (workspace) => + this.require().session.abort({ sessionID: identity.sessionID, workspace }, { throwOnError: true }), + ) + } + + async messages(key: string, input: { limit: number; before?: string }) { + const identity = this.need(key) + return this.withAttachment(key, (workspace) => + this.require().session.messages( + { + sessionID: identity.sessionID, + workspace, + limit: input.limit, + ...(input.before ? { before: input.before } : {}), + }, + { throwOnError: true }, + ), + ) + } + + async session(key: string): Promise { + const identity = this.need(key) + const result = await this.withAttachment(key, (workspace) => + this.require().session.get({ sessionID: identity.sessionID, workspace }, { throwOnError: true }), + ) + return result.data + } + + /** Re-reads every state surface after continuity could not be proven. */ + async resnapshot(key: string): Promise { + const identity = this.need(key) + return this.open(identity.providerID, identity.sessionID) + } + + async replyQuestion(key: string, requestID: string, answers: string[][]): Promise { + await this.withAttachment(key, (workspace) => + this.require().question.reply({ requestID, workspace, answers: answers as never }, { throwOnError: true }), + ) + } + + async rejectQuestion(key: string, requestID: string): Promise { + await this.withAttachment(key, (workspace) => + this.require().question.reject({ requestID, workspace }, { throwOnError: true }), + ) + } + + async replyPermission(key: string, requestID: string, reply: "once" | "always" | "reject"): Promise { + await this.withAttachment(key, (workspace) => + this.require().permission.reply({ requestID, workspace, reply }, { throwOnError: true }), + ) + } + + async pending( + key: string, + ): Promise<{ questions: unknown[]; permissions: unknown[]; status: Record }> { + return this.withAttachment(key, async (workspace) => { + const client = this.require() + const [questions, permissions, status] = await Promise.all([ + client.question.list({ workspace }, { throwOnError: true }), + client.permission.list({ workspace }, { throwOnError: true }), + client.session.status({ workspace }, { throwOnError: true }), + ]) + return { questions: questions.data, permissions: permissions.data, status: status.data } + }) + } + + subscribe( + key: string, + listener: HostedHit, + onFailure?: (failure: AttachmentFailure) => void, + onSnapshot?: () => void, + ): HostedUnsubscribe { + const stream = this.streams.get(key) ?? this.begin(key) + stream.listeners.add(listener) + if (onFailure) stream.failures.add(onFailure) + if (onSnapshot) stream.snapshots.add(onSnapshot) + return () => { + stream.listeners.delete(listener) + if (onFailure) stream.failures.delete(onFailure) + if (onSnapshot) stream.snapshots.delete(onSnapshot) + if (stream.listeners.size === 0) this.stop(key) + } + } + + private begin(key: string): Stream { + const stream: Stream = { + abort: new AbortController(), + listeners: new Set(), + failures: new Set(), + snapshots: new Set(), + } + this.streams.set(key, stream) + void this.run(key, stream) + return stream + } + + private stop(key: string): void { + const stream = this.streams.get(key) + if (!stream) return + this.streams.delete(key) + stream.abort.abort() + } + + private need(key: string): HostedIdentity { + const identity = this.identity(key) + if (!identity) throw new Error(`Not a hosted session key: ${key}`) + return identity + } + + private async run(key: string, stream: Stream): Promise { + const identity = this.need(key) + let delay = RECONNECT_MS + while (!stream.abort.signal.aborted) { + let attachmentID: string + try { + attachmentID = await this.attach(key) + } catch (error) { + this.stop(key) + this.fail(stream, error) + return + } + try { + const events = await this.require().event.subscribe( + { workspace: attachmentID }, + { signal: stream.abort.signal, sseMaxRetryAttempts: 1 }, + ) + delay = RECONNECT_MS + for await (const item of events.stream) { + if (stream.abort.signal.aborted) return + const frame = classifyFrame(item) + if (frame.kind === "resnapshot") { + for (const hit of stream.snapshots) hit() + continue + } + if (frame.kind !== "event") continue + const payload = retag(frame.payload, identity.sessionID, key) + for (const hit of stream.listeners) hit(payload) + } + } catch (error) { + if (stream.abort.signal.aborted) return + const failure = attachmentFailure(error) + if (failure?.recoverable) { + this.registry.invalidate(key) + } else { + this.stop(key) + this.fail(stream, error) + return + } + } + if (stream.abort.signal.aborted) return + await new Promise((resolve) => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, RECONNECT_MAX_MS) + } + } + + private fail(stream: Stream, error: unknown) { + const failure = attachmentFailure(error) + const value: AttachmentFailure = failure ?? { + code: "unavailable", + message: error instanceof Error ? error.message : "Hosted session is unavailable", + recoverable: false, + } + for (const hit of stream.failures) hit(value) + } + + /** Test/inspection hook: the current attachment for a key, when one exists. */ + attachment(key: string): Attachment | undefined { + return this.registry.get(key) + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts index 9c385cd20283..e163902e3267 100644 --- a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts +++ b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts @@ -12,6 +12,7 @@ export function buildChatSettingsMessage() { browserAutomation: vscode.workspace .getConfiguration("kilo-code.new.experimental") .get("browserAutomation", false), + hostedSessions: vscode.workspace.getConfiguration("kilo-code.new.experimental").get("hostedSessions", false), agentManagerBrowserOpenLinksIn: integratedBrowserLinkDestination(), workspaceTrusted: vscode.workspace.isTrusted, }, @@ -31,6 +32,7 @@ export function watchChatConfig(post: Post): vscode.Disposable { if ( event.affectsConfiguration("kilo-code.new.chat") || event.affectsConfiguration("kilo-code.new.experimental.browserAutomation") || + event.affectsConfiguration("kilo-code.new.experimental.hostedSessions") || event.affectsConfiguration("kilo-code.new.agentManager.browser.openLinksIn") ) { post(buildChatSettingsMessage()) diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts b/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts new file mode 100644 index 000000000000..dc573374f3f7 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts @@ -0,0 +1,125 @@ +/** + * Hosted session handlers — extracted from KiloProvider. + * + * Discovery, durable open, and start for hosted sessions. No vscode + * dependency, and no direct provider transport: everything goes through the + * local backend's session-host surface. + */ +import type { SessionHostStartInput } from "@kilocode/sdk/v2/client" +import type { HostedService, HostedStartInput, HostedTurn } from "../../hosted/service" +import { attachmentFailure } from "../../hosted/failure" +import { hostedKey } from "../../hosted/identity" + +export interface HostedContext { + readonly hosted: HostedService + postMessage(msg: unknown): void + /** Converts native turns into the webview transcript shape. */ + mapTurns(items: HostedTurn[]): unknown[] + /** Starts live event delivery for a session that is already running. */ + watch?(key: string): void +} + +function fail(ctx: HostedContext, error: unknown, sessionID?: string) { + const failure = attachmentFailure(error) + const message = failure?.message ?? (error instanceof Error ? error.message : "Hosted session request failed") + ctx.postMessage({ type: "hostedSessionFailed", error: message, code: failure?.code, sessionID }) +} + +export async function handleHostedProviders(ctx: HostedContext): Promise { + try { + const providers = await ctx.hosted.providers() + ctx.postMessage({ type: "hostedProvidersLoaded", providers }) + } catch (error) { + console.error("[Kilo New] Failed to read hosted providers:", error) + ctx.postMessage({ + type: "hostedProvidersLoaded", + providers: [], + error: error instanceof Error ? error.message : "Hosted providers are unavailable", + }) + } +} + +export async function handleHostedSessions(ctx: HostedContext, limit?: number): Promise { + try { + const sessions = await ctx.hosted.list(limit) + ctx.postMessage({ type: "hostedSessionsLoaded", sessions }) + } catch (error) { + console.error("[Kilo New] Failed to list hosted sessions:", error) + ctx.postMessage({ + type: "hostedSessionsLoaded", + sessions: [], + error: error instanceof Error ? error.message : "Hosted sessions are unavailable", + }) + } +} + +/** Durable open: reads stored history without attaching or waking compute. */ +export async function handleHostedOpen( + ctx: HostedContext, + providerID: string, + sessionID: string, + limit?: number, +): Promise { + try { + const open = await ctx.hosted.open(providerID, sessionID, limit) + ctx.postMessage({ + type: "hostedSessionLoaded", + key: open.key, + providerID, + sessionID, + session: open.session, + title: open.session.title || "Hosted session", + messages: ctx.mapTurns(open.items), + cursor: open.cursor, + omitted: open.omitted, + mode: "replace", + }) + } catch (error) { + console.error("[Kilo New] Failed to open hosted session:", error) + const key = hostedKey(providerID, sessionID) + fail(ctx, error, key) + ctx.postMessage({ + type: "error", + sessionID: key, + message: + attachmentFailure(error)?.message ?? (error instanceof Error ? error.message : "Hosted session is unavailable"), + }) + } +} + +export async function handleHostedStart(ctx: HostedContext, input: HostedStartInput): Promise { + try { + const start = await ctx.hosted.start(input) + ctx.watch?.(start.key) + ctx.postMessage({ type: "hostedSessionStarted", ...start }) + } catch (error) { + console.error("[Kilo New] Failed to start hosted session:", error) + fail(ctx, error) + } +} + +export async function handleHostedDetach(ctx: HostedContext, key: string): Promise { + try { + await ctx.hosted.detach(key) + } catch (error) { + console.error("[Kilo New] Failed to detach hosted session:", error) + } + ctx.postMessage({ type: "hostedSessionDetached", key }) +} + +export function hostedRepository(input: { + type?: string + repo?: string + url?: string + branch?: string +}): SessionHostStartInput["repository"] | undefined { + const branch = input.branch?.trim() + const repo = input.repo?.trim() + const url = input.url?.trim() + if (input.type === "github" && repo) return { type: "github", repo, ...(branch ? { branch } : {}) } + if (input.type === "gitlab" && url) return { type: "gitlab", url, ...(branch ? { branch } : {}) } + if (input.type === "git" && url) return { type: "git", url, ...(branch ? { branch } : {}) } + if (repo) return { type: "github", repo, ...(branch ? { branch } : {}) } + if (url) return { type: "git", url, ...(branch ? { branch } : {}) } + return +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index a573bbd71bc9..8fc0ef6f3110 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -6,6 +6,7 @@ import type { ServerConfig } from "./types" import { createDuplicateEventFilter, resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" import { ExplicitAbortState } from "./explicit-abort" +import { HostedService } from "../../hosted/service" import type { PermissionResponseResult } from "../../kilo-provider/handlers/permission-handler" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" @@ -88,6 +89,7 @@ async function drainNetworkWaits(client: KiloClient, dir: string) { */ export class KiloConnectionService { readonly sandboxPreference: SandboxPreference + readonly hosted = new HostedService(() => this.client) private readonly serverManager: ServerManager private client: KiloClient | null = null private sseClient: SdkSSEAdapter | null = null @@ -939,6 +941,7 @@ export class KiloConnectionService { this.sseClient = null sse?.disconnect() this.client = null + this.hosted.reset() this.config = null this.info = null this.permissionDirectories.clear() diff --git a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts index c41ec35480ac..4095072f66b3 100644 --- a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts +++ b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts @@ -85,6 +85,7 @@ describe("buildChatSettingsMessage", () => { settings: { shiftTabCyclesVariant: true, browserAutomation: true, + hostedSessions: false, agentManagerBrowserOpenLinksIn: "external", workspaceTrusted: true, }, diff --git a/packages/kilo-vscode/tests/unit/hosted-core.test.ts b/packages/kilo-vscode/tests/unit/hosted-core.test.ts new file mode 100644 index 000000000000..c4aab4da40e7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-core.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import { attachmentFailure, isRecoverable } from "../../src/hosted/failure" +import { classifyFrame, retag } from "../../src/hosted/events" +import { HostedRegistry } from "../../src/hosted/registry" + +describe("attachment failures", () => { + test("recoverable attachment codes allow one re-attach", () => { + for (const code of ["unknown", "expired", "disposed", "unavailable"]) { + const failure = attachmentFailure({ error: "KILO_SESSION_ATTACHMENT", code, message: code }) + expect(failure?.code).toBe(code as never) + expect(isRecoverable({ code })).toBe(true) + } + }) + + test("unrecoverable codes surface instead of re-attaching", () => { + for (const code of ["invalid_request", "too_large", "payment_required", "signed_out"]) { + expect(isRecoverable({ code })).toBe(false) + } + }) + + test("parses a JSON string body and ignores unrelated errors", () => { + expect(attachmentFailure('{"code":"expired","message":"gone"}')?.code).toBe("expired") + expect(attachmentFailure({ code: "KILO_LIVE_RUNTIME_UNAVAILABLE" })).toBeUndefined() + expect(attachmentFailure(new Error("boom"))).toBeUndefined() + expect(attachmentFailure("not json")).toBeUndefined() + }) + + test("reads the body the SDK preserves under Error.cause", () => { + const wrapped = new Error("Session attachment has expired", { + cause: { body: { error: "KILO_SESSION_ATTACHMENT", code: "expired", message: "Session attachment has expired" } }, + }) + expect(attachmentFailure(wrapped)?.code).toBe("expired") + expect(isRecoverable(wrapped)).toBe(true) + }) +}) + +describe("hosted stream frames", () => { + test("classifies resnapshot and fence frames ahead of ordinary events", () => { + expect( + classifyFrame({ payload: { type: "kilo.native.resnapshot", properties: { reason: "overflow" } } }).kind, + ).toBe("resnapshot") + expect(classifyFrame({ payload: { type: "kilo.native.fence", properties: { generation: 1 } } }).kind).toBe("fence") + expect(classifyFrame({ type: "message.updated", properties: {} }).kind).toBe("event") + expect(classifyFrame({ payload: {} }).kind).toBe("ignore") + expect(classifyFrame(undefined).kind).toBe("ignore") + }) + + test("retags only the matching native session identity", () => { + const key = "host:kilo-cloud:ses_1" + const event = { + type: "message.part.updated", + properties: { sessionID: "ses_1", part: { id: "prt_1", sessionID: "ses_1" } }, + } + const next = retag(event, "ses_1", key) as typeof event + expect(next.properties.sessionID).toBe(key) + expect(next.properties.part.sessionID).toBe(key) + + const other = { type: "message.part.updated", properties: { sessionID: "ses_2" } } + expect(retag(other, "ses_1", key)).toBe(other) + }) +}) + +describe("attachment registry", () => { + const port = (calls: string[]) => ({ + attach: async () => { + calls.push("attach") + return { attachmentID: `wrk_att_${calls.length}`, expiresAt: Date.now() + 60_000 } + }, + detach: async () => { + calls.push("detach") + }, + }) + + test("coalesces concurrent attaches for one identity", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + const [a, b] = await Promise.all([ + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)), + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)), + ]) + expect(a.attachmentID).toBe(b.attachmentID) + expect(calls.filter((call) => call === "attach")).toHaveLength(1) + }) + + test("invalidation forces the next request to mint a new attachment", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + const first = await registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)) + registry.invalidate("host:a:ses_1") + const second = await registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)) + expect(second.attachmentID).not.toBe(first.attachmentID) + }) + + test("detach drops the entry and never throws for an unknown key", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)).catch(() => undefined) + await registry.detach("host:a:ses_1", port(calls)) + expect(registry.get("host:a:ses_1")).toBeUndefined() + await registry.detach("host:missing:ses_1", port(calls)) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-identity.test.ts b/packages/kilo-vscode/tests/unit/hosted-identity.test.ts new file mode 100644 index 000000000000..8ec842d6e8a8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-identity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { hostedKey, isHostedKey, parseHostedKey } from "../../src/hosted/identity" + +describe("hosted identity", () => { + test("mints a namespaced key that cannot look local", () => { + expect(hostedKey("kilo-cloud", "ses_1")).toBe("host:kilo-cloud:ses_1") + expect(hostedKey("kilo-cloud", "ses_1").startsWith("ses_")).toBe(false) + expect(hostedKey("kilo-cloud", "ses_1").startsWith("cloud:")).toBe(false) + }) + + test("round-trips provider and native session identity", () => { + expect(parseHostedKey(hostedKey("kilo-cloud", "ses_1"))).toEqual({ + providerID: "kilo-cloud", + sessionID: "ses_1", + }) + }) + + test("rejects local, cloud-preview, and malformed keys", () => { + expect(isHostedKey("ses_1")).toBe(false) + expect(isHostedKey("cloud:ses_1")).toBe(false) + expect(isHostedKey(undefined)).toBe(false) + expect(parseHostedKey("host:")).toBeUndefined() + expect(parseHostedKey("host:kilo-cloud")).toBeUndefined() + expect(parseHostedKey("host::ses_1")).toBeUndefined() + expect(parseHostedKey("host:kilo-cloud:")).toBeUndefined() + }) + + test("keeps colliding native ids distinct across providers", () => { + expect(hostedKey("alpha", "ses_same")).not.toBe(hostedKey("beta", "ses_same")) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-service.test.ts b/packages/kilo-vscode/tests/unit/hosted-service.test.ts new file mode 100644 index 000000000000..2839c59703f8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-service.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test, afterEach } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2/client" +import { HostedService } from "../../src/hosted/service" + +interface Call { + method: string + path: string + workspace: string | null + body?: unknown +} + +interface Reply { + status?: number + body: unknown +} + +function session(id = "ses_hosted") { + return { + id, + slug: id, + projectID: "project", + directory: "/cloud-agent/sessions/" + id, + title: "Hosted work", + version: "1", + time: { created: 1_700_000_000_000, updated: 1_700_000_100_000 }, + } +} + +/** + * A real local backend stand-in. The service must speak the same generated SDK + * contract the CLI exposes, so a fake HTTP server catches selector mistakes + * that a stubbed client would hide. + */ +function backend(handler: (call: Call, index: number) => Reply) { + const calls: Call[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + const call: Call = { + method: request.method, + path: url.pathname, + workspace: url.searchParams.get("workspace"), + body: request.method === "POST" ? await request.json().catch(() => undefined) : undefined, + } + calls.push(call) + const reply = handler(call, calls.length - 1) + return new Response(JSON.stringify(reply.body), { + status: reply.status ?? 200, + headers: { "content-type": "application/json" }, + }) + }, + }) + const client = createKiloClient({ baseUrl: server.url.href }) + const service = new HostedService(() => client) + return { server, calls, service } +} + +let stop: (() => void) | undefined +afterEach(() => { + stop?.() + stop = undefined +}) + +describe("hosted service", () => { + test("opens durable history without attaching or subscribing", async () => { + const ctx = backend((call) => { + if (call.path.endsWith("/message")) { + return { + body: { + items: [{ info: { id: "msg_1", sessionID: "ses_hosted", role: "user", time: { created: 1 } }, parts: [] }], + cursor: "next", + }, + } + } + if (call.path.startsWith("/kilocode/session-host/session/")) { + return { body: { id: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, session: session() } } + } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + const open = await ctx.service.open("kilo-cloud", "ses_hosted") + expect(open.key).toBe("host:kilo-cloud:ses_hosted") + expect(open.items).toHaveLength(1) + expect(open.cursor).toBe("next") + expect(ctx.calls.some((call) => call.path.endsWith("/attach"))).toBe(false) + expect(ctx.calls.some((call) => call.path === "/event")).toBe(false) + expect(ctx.calls.some((call) => call.path === "/session/status")).toBe(false) + }) + + test("prompt routes the native session through the attachment selector", async () => { + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + return { + body: { + attachmentID: "wrk_att_1", + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + if (call.path === "/session/ses_hosted/prompt_async") return { body: true } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + await ctx.service.prompt("host:kilo-cloud:ses_hosted", { + messageID: "msg_1", + parts: [{ type: "text", text: "hello" }], + agent: "build", + model: { providerID: "p", modelID: "m" }, + }) + + const prompt = ctx.calls.find((call) => call.path === "/session/ses_hosted/prompt_async") + expect(prompt?.workspace).toBe("wrk_att_1") + expect(prompt?.method).toBe("POST") + }) + + test("one recoverable failure mints a new attachment and retries once", async () => { + let attaches = 0 + let reads = 0 + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + attaches += 1 + return { + body: { + attachmentID: `wrk_att_old`, + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + if (call.path === "/session/ses_hosted/message") { + reads += 1 + if (reads === 1) { + return { + status: 410, + body: { error: "KILO_SESSION_ATTACHMENT", code: "expired", message: "Session attachment has expired" }, + } + } + return { body: [] } + } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + const result = await ctx.service.messages("host:kilo-cloud:ses_hosted", { limit: 10 }) + expect(result.data).toEqual([]) + expect(attaches).toBe(2) + expect(reads).toBe(2) + }) + + test("does not retry an unrecoverable failure", async () => { + let attaches = 0 + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + attaches += 1 + return { + body: { + attachmentID: "wrk_att_1", + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + return { + status: 400, + body: { error: "KILO_SESSION_ATTACHMENT", code: "invalid_request", message: "unsupported" }, + } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + await expect(ctx.service.messages("host:kilo-cloud:ses_hosted", { limit: 10 })).rejects.toBeDefined() + expect(attaches).toBe(1) + }) + + test("start forwards the caller's idempotency fields and keeps the hosted key", async () => { + const ctx = backend((call) => { + if (call.path !== "/kilocode/session-host/session") return { status: 404, body: {} } + return { + body: { + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + messageID: "msg_1", + delivery: "queued", + replayed: true, + }, + } + }) + stop = () => ctx.server.stop() + + const start = await ctx.service.start({ + repository: { type: "github", repo: "owner/repo" }, + prompt: "hello", + mode: "build", + model: "p/m", + operationKey: "11111111-1111-4111-8111-111111111111", + messageID: "msg_1", + }) + expect(start).toEqual({ + key: "host:kilo-cloud:ses_hosted", + providerID: "kilo-cloud", + sessionID: "ses_hosted", + replayed: true, + delivery: "queued", + }) + const body = ctx.calls[0]?.body as { operationKey?: string; messageID?: string } + expect(body.operationKey).toBe("11111111-1111-4111-8111-111111111111") + expect(body.messageID).toBe("msg_1") + }) + + test("keeps colliding native session ids on their own attachment", async () => { + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + return { + body: { + attachmentID: "wrk_att_1", + session: { providerID: "kilo-cloud", sessionID: "ses_same" }, + expiresAt: Date.now() + 60_000, + }, + } + } + return { body: [] } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("alpha", "ses_same") + ctx.service.remember("beta", "ses_same") + await ctx.service.attach("host:alpha:ses_same") + await ctx.service.attach("host:beta:ses_same") + expect(ctx.service.attachment("host:alpha:ses_same")?.attachmentID).toBe("wrk_att_1") + expect(ctx.service.attachment("host:beta:ses_same")?.attachmentID).toBe("wrk_att_1") + expect(ctx.calls.filter((call) => call.path.endsWith("/attach"))).toHaveLength(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 90e0e442da3b..7a0b771b572c 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -24,6 +24,7 @@ const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx") const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const CLOUD_SESSION_FILE = path.join(ROOT, "src/kilo-provider/handlers/cloud-session.ts") +const CLOUD_FAILURE_FILE = path.join(ROOT, "webview-ui/src/context/cloud-session-failure.ts") const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts") function readFile(filePath: string): string { @@ -594,14 +595,16 @@ describe("Cloud import parts cleanup contract", () => { }) it("handleCloudSessionImportFailed prunes cloud parts and the synthetic session entries", () => { - // Implemented as a switch case inside handleExtensionMessage, not a - // standalone function, so search the source for the case body directly. - const idx = source.indexOf('case "cloudSessionImportFailed"') + // The cleanup lives in cloud-session-failure.ts so the session switch + // stays under the complexity cap. The key must remain the synthetic + // "cloud:" scope, not the raw preview id. + const cleanup = readFile(CLOUD_FAILURE_FILE) + const idx = cleanup.indexOf("cloudSessionImportFailed") expect(idx).toBeGreaterThan(-1) - const after = source.slice(idx, idx + 4000) - expect(after).toMatch(/pruneCloudOrphans\(failedKey\)/) - expect(after).toMatch(/delete sessions\[failedKey\]/) - expect(after).toMatch(/delete messages\[failedKey\]/) + const after = cleanup.slice(idx) + expect(after).toMatch(/deps\.prune\(key\)/) + expect(after).toMatch(/delete sessions\[key\]/) + expect(after).toMatch(/delete messages\[key\]/) }) it("handleCloudSessionImportFailed clears cloudPreviewId, currentSessionID, draftSessionID, and loading only when still on the failed cloud session", () => { @@ -627,13 +630,13 @@ describe("Cloud import parts cleanup contract", () => { // otherwise `cloudPreviewId() === message.cloudSessionId` would be // false even on the failing preview and the spinner would stick // until later navigation clears it. - const idx = source.indexOf('case "cloudSessionImportFailed"') + const idx = source.indexOf("const cloudFailureDeps") expect(idx).toBeGreaterThan(-1) - const after = source.slice(idx, idx + 4000) - expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setLoading\(false\), message\.cloudSessionId\)/) - expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setCloudPreviewId\(null\), message\.cloudSessionId\)/) - expect(after).toMatch(/clearIfOn\(currentSessionID, \(\) => setCurrentSessionID\(undefined\), failedKey\)/) - expect(after).toMatch(/clearIfOn\(draftSessionID, \(\) => setDraftSessionID\(undefined\), failedKey\)/) + const after = source.slice(idx, idx + 2000) + expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setLoading\(false\), raw\)/) + expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setCloudPreviewId\(null\), raw\)/) + expect(after).toMatch(/clearIfOn\(currentSessionID, \(\) => setCurrentSessionID\(undefined\), key\)/) + expect(after).toMatch(/clearIfOn\(draftSessionID, \(\) => setDraftSessionID\(undefined\), key\)/) expect(after).not.toMatch(/^\s*setLoading\(false\)\s*$/m) // Loading check must come before cloudPreviewId null in the case body. const loadIdx = after.indexOf("setLoading(false)") diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 2da2d6b4f234..8423badf4f39 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -422,6 +422,8 @@ const AppContent: Component = () => { onCleanup(() => window.removeEventListener("message", handler)) }) + const hostedCurrent = () => session.currentSessionID()?.startsWith("host:") === true + const handleSelectSession = (id: string) => { if (tabs) tabs.open(id) if (!tabs) session.selectSession(id) @@ -466,7 +468,7 @@ const AppContent: Component = () => { { onSelectSession={handleSelectSession} onShowHistory={() => setCurrentView("history")} onForkMessage={session.status() === "idle" ? handleForkMessage : undefined} - continueInWorktree + continueInWorktree={!hostedCurrent()} promptBoxId="sidebar:new-task" emptyState={emptyState} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx index 09978acddfa0..10f0d3370ef8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx @@ -10,9 +10,11 @@ import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { useLanguage } from "../../context/language" import { useSession } from "../../context/session" import { useLocalTabs } from "../../context/local-tabs" +import { useConfig } from "../../context/config" import { CloudImportDialog } from "../chat/CloudImportDialog" import SessionList from "./SessionList" import CloudSessionList from "./CloudSessionList" +import HostedSessionList from "./HostedSessionList" import type { SessionInfo } from "../../types/messages" interface HistoryViewProps { @@ -25,7 +27,7 @@ interface HistoryViewProps { rowActions?: (session: SessionInfo) => JSX.Element } -type Source = "local" | "cloud" | "worktree" +type Source = "local" | "cloud" | "hosted" | "worktree" const EMPTY_SESSION_IDS = new Set() @@ -34,21 +36,27 @@ const HistoryView: Component = (props) => { const dialog = useDialog() const session = useSession() const tabs = useLocalTabs() + const config = useConfig() const worktreeIds = () => props.worktreeSessionIds?.() + const hosted = () => config.settings().hostedSessions === true const [tab, setTab] = createSignal(worktreeIds() ? "worktree" : "local") let local: HTMLButtonElement | undefined let cloud: HTMLButtonElement | undefined + let hostedTab: HTMLButtonElement | undefined let worktree: HTMLButtonElement | undefined let localPanel: HTMLDivElement | undefined let cloudPanel: HTMLDivElement | undefined + let hostedPanel: HTMLDivElement | undefined let worktreePanel: HTMLDivElement | undefined createEffect(() => { if (tab() === "worktree" && !worktreeIds()) setTab("local") + if (tab() === "hosted" && !hosted()) setTab("local") }) createEffect(() => { - const panel = tab() === "local" ? localPanel : tab() === "cloud" ? cloudPanel : worktreePanel + const panel = + tab() === "local" ? localPanel : tab() === "cloud" ? cloudPanel : tab() === "hosted" ? hostedPanel : worktreePanel const frame = requestAnimationFrame(() => { panel @@ -78,7 +86,12 @@ const HistoryView: Component = (props) => { } function move(event: KeyboardEvent, current: Source) { - const sources: Source[] = worktreeIds() ? ["local", "cloud", "worktree"] : ["local", "cloud"] + const sources: Source[] = [ + "local", + "cloud", + ...(hosted() ? (["hosted"] as Source[]) : []), + ...(worktreeIds() ? (["worktree"] as Source[]) : []), + ] const index = sources.indexOf(current) const source = event.key === "Home" @@ -90,7 +103,16 @@ const HistoryView: Component = (props) => { : event.key === "ArrowRight" ? sources[(index + 1) % sources.length] : undefined - const next = source === "local" ? local : source === "cloud" ? cloud : source === "worktree" ? worktree : undefined + const next = + source === "local" + ? local + : source === "cloud" + ? cloud + : source === "hosted" + ? hostedTab + : source === "worktree" + ? worktree + : undefined if (!next) return event.preventDefault() next.focus() @@ -133,6 +155,42 @@ const HistoryView: Component = (props) => { > {language.t("session.tab.cloud")} + + + + + + + + )} + + +
+ {language.t("common.requestFailed")} + +
+
+
+ + +
+ + + + + items={sessions()} + key={(s) => s.key} + filterKeys={["title"]} + onMove={announce} + onSelect={(s) => { + if (s) props.onSelectSession?.(s) + }} + search={{ placeholder: language.t("session.search.placeholder"), autofocus: true }} + emptyMessage={loading() ? language.t("common.loading") : language.t("hosted.empty")} + > + {(s) => ( + <> + + {s.title || "Untitled"} + + {formatRelativeDate(new Date(s.updated).toISOString())} + + )} + +
+ {notice()} +
+ + ) +} + +export default HostedSessionList diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx new file mode 100644 index 000000000000..411ab765bf11 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx @@ -0,0 +1,99 @@ +import { Component, createSignal } from "solid-js" +import { Dialog } from "@kilocode/kilo-ui/dialog" +import { Button } from "@kilocode/kilo-ui/button" +import { TextField } from "@kilocode/kilo-ui/text-field" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useLanguage } from "../../context/language" +import { useSession } from "../../context/session" +import { Identifier } from "../../utils/id" +import type { HostedStartSessionMessage } from "../../types/messages" + +interface HostedStartDialogProps { + onStart: (message: HostedStartSessionMessage) => void +} + +/** + * Starts one hosted session. The operation key and initial message id are + * minted once per dialog submission so retrying the same intent deduplicates + * instead of creating a second session. + */ +export const HostedStartDialog: Component = (props) => { + const language = useLanguage() + const dialog = useDialog() + const session = useSession() + + const selection = () => session.selected() + const defaultModel = () => { + const model = selection() + return model ? `${model.providerID}/${model.modelID}` : "" + } + + const [repository, setRepository] = createSignal("") + const [branch, setBranch] = createSignal("") + const [prompt, setPrompt] = createSignal("") + const [agent, setAgent] = createSignal(session.agents()[0]?.name ?? "build") + const [model, setModel] = createSignal(defaultModel()) + const [error, setError] = createSignal(undefined) + + function submit() { + const repo = repository().trim() + const value = prompt().trim() + const mode = agent().trim() + const name = model().trim() + if (!repo) { + setError(language.t("hosted.repository")) + return + } + if (!value) { + setError(language.t("hosted.prompt")) + return + } + if (!mode || !name) { + setError(`${language.t("hosted.agent")} / ${language.t("hosted.model")}`) + return + } + const isUrl = repo.includes("://") || repo.endsWith(".git") + props.onStart({ + type: "hostedStartSession", + ...(isUrl ? { repositoryType: "git" as const, url: repo } : { repositoryType: "github" as const, repo }), + ...(branch().trim() ? { branch: branch().trim() } : {}), + prompt: value, + mode, + model: name, + operationKey: crypto.randomUUID(), + messageID: Identifier.ascending("message"), + }) + dialog.close() + } + + return ( + +
+ + + + + +
+ + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 624ed8d080a8..c210b1724f72 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -251,6 +251,19 @@ const ExperimentalTab: Component = () => {
+ + applySetting("hostedSessions", checked, "experimental.hostedSessions")} + hideLabel + > + {language.t("settings.experimental.hostedSessions.title")} + + + + prune(key: string): void + clearPreview(rawID: string): void + clearCurrent(key: string): void + clearDraft(key: string): void + toast(error: string): void +} + +/** + * Cloud import failure cleanup stays separate from hosted session handling: + * the legacy `cloud:` preview is a different identity space and must not + * be cleared by a hosted attachment failure. + */ +export function handleCloudImportFailure(message: ExtensionMessage, deps: CloudFailureDeps): void { + if (message.type !== "cloudSessionImportFailed") return + const key = `cloud:${message.cloudSessionId}` + deps.prune(key) + deps.store( + "sessions", + produce((sessions) => { + delete sessions[key] + }), + ) + deps.store( + "messages", + produce((messages) => { + delete messages[key] + }), + ) + deps.store( + "toolParts", + produce((parts) => { + delete parts[key] + }), + ) + // cloudPreviewId stores the raw cloud session id, not the synthetic key. + deps.clearPreview(message.cloudSessionId) + deps.clearCurrent(key) + deps.clearDraft(key) + deps.toast(message.error) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 305060c3638e..108f162ebda4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -82,6 +82,7 @@ function loadedSettings(message: ExtensionMessage): Record | un return { "chat.shiftTabCyclesVariant": message.settings.shiftTabCyclesVariant, browserAutomation: message.settings.browserAutomation, + hostedSessions: message.settings.hostedSessions, agentManagerBrowserOpenLinksIn: message.settings.agentManagerBrowserOpenLinksIn, workspaceTrusted: message.settings.workspaceTrusted, } diff --git a/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts new file mode 100644 index 000000000000..6c2be93ef494 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts @@ -0,0 +1,69 @@ +import type { SetStoreFunction } from "solid-js/store" +import type { SessionStore } from "./session-store" +import type { ExtensionMessage, Message, Part } from "../types/messages" + +export interface HostedBridgeDeps { + store: SetStoreFunction + loaded(key: string): void + page(key: string, hasMore: boolean): void + toolParts(key: string, messages: Message[]): void + isolate(part: Part): Part + current(key: string): void + draft(key: string): void + loading(value: boolean): void + clearDraft(): void + connected(): boolean + post(message: { type: "hostedOpenSession"; providerID: string; sessionID: string }): void + batch(run: () => void): void +} + +type Loaded = Extract + +/** + * Bridges hosted session messages into the existing session store. + * + * Hosted sessions reuse the local renderer under a namespaced key, so nothing + * here needs a second transcript path; it only writes the same shapes the + * local `messagesLoaded` handler writes. + */ +export function createHostedBridge(deps: HostedBridgeDeps) { + function load(message: Loaded) { + deps.batch(() => { + deps.loaded(message.key) + deps.store("sessions", message.key, { + id: message.key, + title: message.title, + createdAt: new Date(message.session.time.created).toISOString(), + updatedAt: new Date(message.session.time.updated).toISOString(), + }) + deps.page(message.key, Boolean(message.cursor)) + deps.store("messages", message.key, message.messages) + for (const msg of message.messages) { + if (msg.parts && msg.parts.length > 0) deps.store("parts", msg.id, msg.parts.map(deps.isolate)) + } + deps.toolParts(message.key, message.messages) + deps.current(message.key) + deps.loading(false) + }) + } + + function select(providerID: string, sessionID: string) { + if (!deps.connected()) { + console.warn("[Kilo New] Cannot select hosted session: not connected") + return + } + const key = `host:${providerID}:${sessionID}` + deps.clearDraft() + deps.current(key) + deps.draft(key) + deps.loading(true) + deps.post({ type: "hostedOpenSession", providerID, sessionID }) + } + + function handle(message: ExtensionMessage) { + if (message.type === "hostedSessionLoaded") load(message) + if (message.type === "hostedSessionFailed" && message.sessionID) deps.loading(false) + } + + return { handle, select } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-store.ts new file mode 100644 index 000000000000..13fbc45cb98c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-store.ts @@ -0,0 +1,34 @@ +import type { + Message, + ModelSelection, + ModelUsageMap, + Part, + SessionCloseReason, + SessionInfo, + SessionModelUsage, + TodoItem, + ToolPart, +} from "../types/messages" + +/** Store structure for messages and parts. */ +export interface SessionStore { + sessions: Record + messages: Record // sessionID -> messages + parts: Record // messageID -> parts + toolParts: Record // sessionID -> compact per-session tool index + todos: Record // sessionID -> todos + sessionOverrides: Record> // scope -> agent -> explicit model pick + agentSelections: Record // sessionID -> agent name + variantSelections: Record // session/agent scoped variant key -> variant name + recentModels: ModelSelection[] + favoriteModels: ModelSelection[] + modelUsageHistory: ModelUsageMap + modelUsage: Record +} + +export interface CloseState { + reason: SessionCloseReason + parentID?: string + eventID?: string + seen?: boolean +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts index 8bc90596e390..e03ee93a97d9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -245,6 +245,7 @@ export interface SessionContextValue { // Cloud session preview cloudPreviewId: Accessor selectCloudSession: (cloudSessionId: string) => void + selectHostedSession: (providerID: string, sessionID: string) => void draftSessionID: Accessor setDraftSessionID: (id: string | undefined) => void userClearedSession: Accessor diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 21da128cc467..6e2294cbce15 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -110,32 +110,13 @@ import { createModelSelector } from "./session-model-selector" import { createModelPreferences } from "./session-model-preferences" import { activities, blockedSessionIds, type Activity } from "../utils/session-activity" import { createTiming, running, type Timing } from "./session-timing" +import { createHostedBridge } from "./hosted-session-bridge" +import { handleCloudImportFailure, type CloudFailureDeps } from "./cloud-session-failure" +import type { CloseState, SessionStore } from "./session-store" import type { SessionContextValue } from "./session-types" const RECENT_LIMIT = 5 const MESSAGE_PAGE_LIMIT = 80 -// Store structure for messages and parts -interface SessionStore { - sessions: Record - messages: Record // sessionID -> messages - parts: Record // messageID -> parts - toolParts: Record // sessionID -> compact per-session tool index - todos: Record // sessionID -> todos - sessionOverrides: Record> // scope -> agent -> explicit model pick - agentSelections: Record // sessionID -> agent name - variantSelections: Record // session/agent scoped variant key -> variant name - recentModels: ModelSelection[] - favoriteModels: ModelSelection[] - modelUsageHistory: ModelUsageMap - modelUsage: Record -} - -interface CloseState { - reason: SessionCloseReason - parentID?: string - eventID?: string - seen?: boolean -} export const SessionContext = createContext() @@ -894,6 +875,8 @@ export const SessionProvider: ParentComponent = (props) => { if (handleStreamMessage(message)) return handleCommandCompletion(message) handleResumeResult(message) + hosted.handle(message) + handleCloudImportFailure(message, cloudFailureDeps) cah.handleMessage(message) switch (message.type) { case "sessionCreated": @@ -1010,42 +993,6 @@ export const SessionProvider: ParentComponent = (props) => { handleCloudSessionImported(message.cloudSessionId, message.session) break - case "cloudSessionImportFailed": { - const failedKey = `cloud:${message.cloudSessionId}` - pruneCloudOrphans(failedKey) - setStore( - "sessions", - produce((sessions) => { - delete sessions[failedKey] - }), - ) - setStore( - "messages", - produce((messages) => { - delete messages[failedKey] - }), - ) - setStore( - "toolParts", - produce((toolParts) => { - delete toolParts[failedKey] - }), - ) - // cloudPreviewId stores the raw cloud session id (see selectCloudSession), - // not the synthetic "cloud:" key used for session/draft ids. - clearIfOn(cloudPreviewId, () => setLoading(false), message.cloudSessionId) - clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), message.cloudSessionId) - clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), failedKey) - clearIfOn(draftSessionID, () => setDraftSessionID(undefined), failedKey) - showToast({ - variant: "error", - title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", - description: message.error, - }) - console.error("[Kilo New] Cloud session import failed:", message.error) - break - } - case "worktreeStatsLoaded": setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions }) break @@ -1210,6 +1157,46 @@ export const SessionProvider: ParentComponent = (props) => { setTools(sessionID, tools, mode) } + const hosted = createHostedBridge({ + store: setStore, + loaded: (key) => + setLoaded((prev) => { + if (prev.has(key)) return prev + const next = new Set(prev) + next.add(key) + return next + }), + page: (key, hasMore) => patchPage(key, { hasMore, lastMutation: "replace" }), + toolParts: (key, messages) => rebuildToolParts(key, messages), + isolate: (part) => isolate(part), + current: (key) => setCurrentSessionID(key), + draft: (key) => setDraftSessionID(key), + loading: (value) => setLoading(value), + clearDraft: () => agentDrafts.prune(draftSessionID()), + connected: () => server.isConnected(), + post: (message) => vscode.postMessage(message), + batch, + }) + + const cloudFailureDeps: CloudFailureDeps = { + store: setStore, + prune: (key) => pruneCloudOrphans(key), + clearPreview: (raw) => { + clearIfOn(cloudPreviewId, () => setLoading(false), raw) + clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), raw) + }, + clearCurrent: (key) => clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), key), + clearDraft: (key) => clearIfOn(draftSessionID, () => setDraftSessionID(undefined), key), + toast: (error) => { + showToast({ + variant: "error", + title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", + description: error, + }) + console.error("[Kilo New] Cloud session import failed:", error) + }, + } + function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) { const sid = sessionID ?? part.sessionID if (!sid) return @@ -2685,6 +2672,11 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestCloudSessionData", sessionId: cloudSessionId }) } + function selectHostedSession(providerID: string, sessionID: string) { + setUserClearedSession(false) + hosted.select(providerID, sessionID) + } + function deleteSession(id: string) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot delete session: not connected") @@ -3071,6 +3063,7 @@ export const SessionProvider: ParentComponent = (props) => { unsyncSession, cloudPreviewId, selectCloudSession, + selectHostedSession, draftSessionID, setDraftSessionID, userClearedSession, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 97f9c4db310e..fa750df58f94 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -580,6 +580,17 @@ export const dict = { "session.tabs.switcher.scheduled": "مجدولة", "session.tabs.pinHint": "Shift+النقر للتثبيت أو إلغاء التثبيت", "session.tab.local": "محلي", + "session.tab.hosted": "مستضافة", + "hosted.empty": "لا توجد جلسات مستضافة", + "hosted.start": "بدء جلسة مستضافة", + "hosted.repository": "المستودع", + "hosted.branch": "الفرع (اختياري)", + "hosted.prompt": "الموجّه", + "hosted.agent": "الوكيل", + "hosted.model": "النموذج", + "settings.experimental.hostedSessions.title": "جلسات سحابية مستضافة", + "settings.experimental.hostedSessions.description": + "اكتشف جلسات Kilo Cloud المستضافة وابدأها وتفاعل معها من VS Code.", "session.tab.cloud": "السحابة", "session.tab.worktree": "شجرة العمل", "session.cloud.repoOnly": "هذا المستودع فقط", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index b8c2e64dd841..d371f89c2a57 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -594,6 +594,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Agendado", "session.tabs.pinHint": "Shift+clique para fixar ou desafixar", "session.tab.local": "Local", + "session.tab.hosted": "Hospedadas", + "hosted.empty": "Nenhuma sessão hospedada", + "hosted.start": "Iniciar sessão hospedada", + "hosted.repository": "Repositório", + "hosted.branch": "Branch (opcional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modelo", + "settings.experimental.hostedSessions.title": "Sessões na nuvem hospedadas", + "settings.experimental.hostedSessions.description": + "Descubra, inicie e interaja com sessões hospedadas do Kilo Cloud no VS Code.", "session.tab.cloud": "Nuvem", "session.tab.worktree": "Árvore de trabalho", "session.cloud.repoOnly": "Apenas este repositório", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 51c049a7a177..950b4ab2662f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -635,6 +635,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zakazano", "session.tabs.pinHint": "Shift+klik za zakačiti ili otkačiti", "session.tab.local": "Lokalno", + "session.tab.hosted": "Hostovane", + "hosted.empty": "Nema hostovanih sesija", + "hosted.start": "Pokreni hostovanu sesiju", + "hosted.repository": "Repozitorij", + "hosted.branch": "Grana (opcionalno)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostovane cloud sesije", + "settings.experimental.hostedSessions.description": + "Otkrijte, pokrenite i koristite hostovane Kilo Cloud sesije iz VS Code.", "session.tab.cloud": "Oblak", "session.tab.worktree": "Radno stablo", "session.cloud.repoOnly": "Samo ovaj repozitorij", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 400be71bcd0c..0de7dd1b3e62 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -632,6 +632,16 @@ export const dict = { "session.tabs.switcher.scheduled": "Planlagt", "session.tabs.pinHint": "Skift+klik for at fastgøre eller frigøre", "session.tab.local": "Lokal", + "session.tab.hosted": "Hostede", + "hosted.empty": "Ingen hostede sessioner", + "hosted.start": "Start hostet session", + "hosted.repository": "Repository", + "hosted.branch": "Branch (valgfrit)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostede cloud-sessioner", + "settings.experimental.hostedSessions.description": "Find, start og brug hostede Kilo Cloud-sessioner fra VS Code.", "session.tab.cloud": "Sky", "session.tab.worktree": "Arbejdstræ", "session.cloud.repoOnly": "Kun dette repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 66f683b35ffc..e49647ace2cf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -644,6 +644,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Geplant", "session.tabs.pinHint": "Umschalt+Klick zum Anheften oder Lösen", "session.tab.local": "Lokal", + "session.tab.hosted": "Gehostet", + "hosted.empty": "Keine gehosteten Sitzungen", + "hosted.start": "Gehostete Sitzung starten", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Modell", + "settings.experimental.hostedSessions.title": "Gehostete Cloud-Sitzungen", + "settings.experimental.hostedSessions.description": + "Gehostete Kilo Cloud-Sitzungen in VS Code entdecken, starten und nutzen.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Arbeitsbaum", "session.cloud.repoOnly": "Nur dieses Repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index dd4af1679ab4..18133ef71309 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -542,6 +542,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Scheduled", "session.tabs.pinHint": "Shift+click to pin or unpin", "session.tab.local": "Local", + "session.tab.hosted": "Hosted", + "hosted.empty": "No hosted sessions", + "hosted.start": "Start hosted session", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hosted cloud sessions", + "settings.experimental.hostedSessions.description": + "Discover, start, and interact with Kilo Cloud hosted sessions from VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Worktree", "session.cloud.repoOnly": "Only this repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index cc7172d002c0..329916a14ab5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -637,6 +637,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programado", "session.tabs.pinHint": "Mayús+clic para fijar o dejar de fijar", "session.tab.local": "Local", + "session.tab.hosted": "Alojadas", + "hosted.empty": "No hay sesiones alojadas", + "hosted.start": "Iniciar sesión alojada", + "hosted.repository": "Repositorio", + "hosted.branch": "Rama (opcional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modelo", + "settings.experimental.hostedSessions.title": "Sesiones en la nube alojadas", + "settings.experimental.hostedSessions.description": + "Descubre, inicia e interactúa con sesiones alojadas de Kilo Cloud desde VS Code.", "session.tab.cloud": "Nube", "session.tab.worktree": "Árbol de trabajo", "session.cloud.repoOnly": "Solo este repositorio", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 0df28b5b92a2..05cd1df4fc52 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -548,6 +548,17 @@ export const dict = { "session.tabs.switcher.scheduled": "زمان‌بندی‌شده", "session.tabs.pinHint": "Shift+کلیک برای سنجاق کردن یا لغو آن", "session.tab.local": "محلی", + "session.tab.hosted": "میزبانی‌شده", + "hosted.empty": "هیچ نشست میزبانی‌شده‌ای وجود ندارد", + "hosted.start": "شروع نشست میزبانی‌شده", + "hosted.repository": "مخزن", + "hosted.branch": "شاخه (اختیاری)", + "hosted.prompt": "اعلان", + "hosted.agent": "عامل", + "hosted.model": "مدل", + "settings.experimental.hostedSessions.title": "نشست‌های ابری میزبانی‌شده", + "settings.experimental.hostedSessions.description": + "نشست‌های میزبانی‌شده Kilo Cloud را در VS Code کشف، شروع و استفاده کنید.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Worktree", "session.cloud.repoOnly": "فقط این مخزن", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3d39475ce5b5..7e9ce4433f31 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -640,6 +640,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programmé", "session.tabs.pinHint": "Maj+clic pour épingler ou détacher", "session.tab.local": "Local", + "session.tab.hosted": "Hébergées", + "hosted.empty": "Aucune session hébergée", + "hosted.start": "Démarrer une session hébergée", + "hosted.repository": "Dépôt", + "hosted.branch": "Branche (facultatif)", + "hosted.prompt": "Invite", + "hosted.agent": "Agent", + "hosted.model": "Modèle", + "settings.experimental.hostedSessions.title": "Sessions cloud hébergées", + "settings.experimental.hostedSessions.description": + "Découvrez, démarrez et utilisez des sessions hébergées Kilo Cloud depuis VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Arbre de travail", "session.cloud.repoOnly": "Uniquement ce dépôt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index f1bc2f0a44df..1ba97d3fecf3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -466,6 +466,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programmato", "session.tabs.pinHint": "Shift+clic per fissare o sbloccare", "session.tab.local": "Locale", + "session.tab.hosted": "Ospitate", + "hosted.empty": "Nessuna sessione ospitata", + "hosted.start": "Avvia sessione ospitata", + "hosted.repository": "Repository", + "hosted.branch": "Branch (facoltativo)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modello", + "settings.experimental.hostedSessions.title": "Sessioni cloud ospitate", + "settings.experimental.hostedSessions.description": + "Scopri, avvia e interagisci con le sessioni ospitate di Kilo Cloud da VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Albero di lavoro", "session.cloud.repoOnly": "Solo questa repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index b720b75a06b7..536a2b25b83b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -628,6 +628,17 @@ export const dict = { "session.tabs.switcher.scheduled": "予約済み", "session.tabs.pinHint": "Shift+クリックで固定または固定解除", "session.tab.local": "ローカル", + "session.tab.hosted": "ホスト済み", + "hosted.empty": "ホストされたセッションはありません", + "hosted.start": "ホストされたセッションを開始", + "hosted.repository": "リポジトリ", + "hosted.branch": "ブランチ(任意)", + "hosted.prompt": "プロンプト", + "hosted.agent": "エージェント", + "hosted.model": "モデル", + "settings.experimental.hostedSessions.title": "ホストされたクラウドセッション", + "settings.experimental.hostedSessions.description": + "VS Code で Kilo Cloud のホストされたセッションを検出、開始、操作します。", "session.tab.cloud": "クラウド", "session.tab.worktree": "ワークツリー", "session.cloud.repoOnly": "このリポジトリのみ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 42bd484af66b..8985e212b6e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -588,6 +588,17 @@ export const dict = { "session.tabs.switcher.scheduled": "예약됨", "session.tabs.pinHint": "Shift+클릭으로 고정하거나 고정 해제", "session.tab.local": "로컬", + "session.tab.hosted": "호스팅됨", + "hosted.empty": "호스팅된 세션이 없습니다", + "hosted.start": "호스팅된 세션 시작", + "hosted.repository": "저장소", + "hosted.branch": "브랜치(선택 사항)", + "hosted.prompt": "프롬프트", + "hosted.agent": "에이전트", + "hosted.model": "모델", + "settings.experimental.hostedSessions.title": "호스팅된 클라우드 세션", + "settings.experimental.hostedSessions.description": + "VS Code에서 Kilo Cloud 호스팅 세션을 찾아 시작하고 상호 작용합니다.", "session.tab.cloud": "클라우드", "session.tab.worktree": "작업 트리", "session.cloud.repoOnly": "이 저장소만", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 4158ee5e6c00..a323b3e6b850 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -585,6 +585,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Gepland", "session.tabs.pinHint": "Shift+klik om vast te maken of los te maken", "session.tab.local": "Lokaal", + "session.tab.hosted": "Gehost", + "hosted.empty": "Geen gehoste sessies", + "hosted.start": "Gehoste sessie starten", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optioneel)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Gehoste cloudsessies", + "settings.experimental.hostedSessions.description": + "Ontdek, start en gebruik gehoste Kilo Cloud-sessies vanuit VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Werkboom", "session.cloud.repoOnly": "Alleen deze repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 24b7542e0ce6..cf6c6e211d2a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -595,6 +595,16 @@ export const dict = { "session.tabs.switcher.scheduled": "Planlagt", "session.tabs.pinHint": "Shift+klikk for å feste eller løsne", "session.tab.local": "Lokal", + "session.tab.hosted": "Hostede", + "hosted.empty": "Ingen hostede økter", + "hosted.start": "Start hostet økt", + "hosted.repository": "Repository", + "hosted.branch": "Branch (valgfritt)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Modell", + "settings.experimental.hostedSessions.title": "Hostede skyøkter", + "settings.experimental.hostedSessions.description": "Oppdag, start og bruk hostede Kilo Cloud-økter fra VS Code.", "session.tab.cloud": "Sky", "session.tab.worktree": "Arbeidstre", "session.cloud.repoOnly": "Kun dette repositoriet", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d83ade6d9992..1eaae255046c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -591,6 +591,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zaplanowano", "session.tabs.pinHint": "Shift+kliknij, aby przypiąć lub odpiąć", "session.tab.local": "Lokalny", + "session.tab.hosted": "Hostowane", + "hosted.empty": "Brak sesji hostowanych", + "hosted.start": "Rozpocznij sesję hostowaną", + "hosted.repository": "Repozytorium", + "hosted.branch": "Gałąź (opcjonalnie)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostowane sesje w chmurze", + "settings.experimental.hostedSessions.description": + "Odkrywaj, uruchamiaj i korzystaj z hostowanych sesji Kilo Cloud w VS Code.", "session.tab.cloud": "Chmura", "session.tab.worktree": "Drzewo robocze", "session.cloud.repoOnly": "Tylko to repozytorium", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 2c92db65005c..5a655bd56377 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -630,6 +630,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Запланировано", "session.tabs.pinHint": "Shift+щелчок, чтобы закрепить или открепить", "session.tab.local": "Локальный", + "session.tab.hosted": "Облачные", + "hosted.empty": "Нет облачных сессий", + "hosted.start": "Запустить облачную сессию", + "hosted.repository": "Репозиторий", + "hosted.branch": "Ветка (необязательно)", + "hosted.prompt": "Запрос", + "hosted.agent": "Агент", + "hosted.model": "Модель", + "settings.experimental.hostedSessions.title": "Облачные сессии", + "settings.experimental.hostedSessions.description": + "Обнаруживайте, запускайте и используйте облачные сессии Kilo Cloud в VS Code.", "session.tab.cloud": "Облако", "session.tab.worktree": "Рабочее дерево", "session.cloud.repoOnly": "Только этот репозиторий", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 128e875332c6..bc07cfe6932a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -625,6 +625,17 @@ export const dict = { "session.tabs.switcher.scheduled": "ตั้งเวลาไว้", "session.tabs.pinHint": "Shift+คลิกเพื่อปักหมุดหรือเลิกปักหมุด", "session.tab.local": "ในเครื่อง", + "session.tab.hosted": "โฮสต์", + "hosted.empty": "ไม่มีเซสชันที่โฮสต์", + "hosted.start": "เริ่มเซสชันที่โฮสต์", + "hosted.repository": "ที่เก็บโค้ด", + "hosted.branch": "สาขา (ไม่บังคับ)", + "hosted.prompt": "พรอมต์", + "hosted.agent": "เอเจนต์", + "hosted.model": "โมเดล", + "settings.experimental.hostedSessions.title": "เซสชันคลาวด์ที่โฮสต์", + "settings.experimental.hostedSessions.description": + "ค้นหา เริ่ม และโต้ตอบกับเซสชันที่โฮสต์ของ Kilo Cloud จาก VS Code", "session.tab.cloud": "คลาวด์", "session.tab.worktree": "เวิร์กทรี Git", "session.cloud.repoOnly": "เฉพาะรีโพซิทอรีนี้", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 89c313cd720d..9f7f36572a70 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -580,6 +580,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zamanlandı", "session.tabs.pinHint": "Sabitlemek veya kaldırmak için Shift+tıkla", "session.tab.local": "Local", + "session.tab.hosted": "Barındırılan", + "hosted.empty": "Barındırılan oturum yok", + "hosted.start": "Barındırılan oturum başlat", + "hosted.repository": "Depo", + "hosted.branch": "Dal (isteğe bağlı)", + "hosted.prompt": "İstem", + "hosted.agent": "Aracı", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Barındırılan bulut oturumları", + "settings.experimental.hostedSessions.description": + "VS Code'dan Kilo Cloud barındırılan oturumlarını keşfedin, başlatın ve kullanın.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Çalışma ağacı", "session.cloud.repoOnly": "Yalnızca bu depo", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 3938768ddd5b..05c7246d6223 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -585,6 +585,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Заплановано", "session.tabs.pinHint": "Shift+клік, щоб закріпити або відкріпити", "session.tab.local": "Локальний", + "session.tab.hosted": "Хмарні", + "hosted.empty": "Немає хмарних сесій", + "hosted.start": "Запустити хмарну сесію", + "hosted.repository": "Репозиторій", + "hosted.branch": "Гілка (необов’язково)", + "hosted.prompt": "Запит", + "hosted.agent": "Агент", + "hosted.model": "Модель", + "settings.experimental.hostedSessions.title": "Хмарні сесії", + "settings.experimental.hostedSessions.description": + "Знаходьте, запускайте та використовуйте хмарні сесії Kilo Cloud у VS Code.", "session.tab.cloud": "Хмарний", "session.tab.worktree": "Робоче дерево", "session.cloud.repoOnly": "Лише цей репозиторій", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 9e553948765c..207515ecf607 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -606,6 +606,16 @@ export const dict = { "session.tabs.switcher.scheduled": "已计划", "session.tabs.pinHint": "按住 Shift 点击可固定或取消固定", "session.tab.local": "本地", + "session.tab.hosted": "托管", + "hosted.empty": "没有托管的会话", + "hosted.start": "启动托管会话", + "hosted.repository": "仓库", + "hosted.branch": "分支(可选)", + "hosted.prompt": "提示词", + "hosted.agent": "代理", + "hosted.model": "模型", + "settings.experimental.hostedSessions.title": "托管的云端会话", + "settings.experimental.hostedSessions.description": "在 VS Code 中发现、启动和交互 Kilo Cloud 托管的会话。", "session.tab.cloud": "云端", "session.tab.worktree": "工作树", "session.cloud.repoOnly": "仅此仓库", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index a696cb09219c..87b0b1bf76a2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -566,6 +566,16 @@ export const dict = { "session.tabs.switcher.scheduled": "已排程", "session.tabs.pinHint": "按住 Shift 點擊可釘選或取消釘選", "session.tab.local": "本機", + "session.tab.hosted": "託管", + "hosted.empty": "沒有託管的會話", + "hosted.start": "啟動託管會話", + "hosted.repository": "儲存庫", + "hosted.branch": "分支(選用)", + "hosted.prompt": "提示詞", + "hosted.agent": "代理", + "hosted.model": "模型", + "settings.experimental.hostedSessions.title": "託管的雲端會話", + "settings.experimental.hostedSessions.description": "在 VS Code 中探索、啟動並互動 Kilo Cloud 託管的會話。", "session.tab.cloud": "雲端", "session.tab.worktree": "工作樹", "session.cloud.repoOnly": "僅此儲存庫", diff --git a/packages/kilo-vscode/webview-ui/src/styles/history.css b/packages/kilo-vscode/webview-ui/src/styles/history.css index d8fb48587742..1868a17b4b58 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/history.css +++ b/packages/kilo-vscode/webview-ui/src/styles/history.css @@ -8,7 +8,8 @@ /* Session List Items */ .session-list [data-slot="list-item-title"], -.cloud-session-list [data-slot="list-item-title"] { +.cloud-session-list [data-slot="list-item-title"], +.hosted-session-list [data-slot="list-item-title"] { flex: 1; min-width: 0; overflow: hidden; @@ -18,7 +19,8 @@ } .session-list [data-slot="list-item-description"], -.cloud-session-list [data-slot="list-item-description"] { +.cloud-session-list [data-slot="list-item-description"], +.hosted-session-list [data-slot="list-item-description"] { flex-shrink: 0; margin-left: 8px; font-size: var(--kilo-font-size-12); @@ -177,6 +179,30 @@ body.vscode-light min-height: 0; } +/* Hosted Session List (inside History View) */ +.hosted-session-list { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.hosted-session-actions { + display: flex; + gap: 6px; + padding: 8px 10px; +} + +.hosted-session-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + font-size: var(--kilo-font-size-12); + color: var(--text-weak, var(--vscode-descriptionForeground)); +} + /* Make the list search wrapper wrap so the repo filter appears below search */ .cloud-session-list [data-slot="list-search-wrapper"] { flex-wrap: wrap; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d22972129c56..f821ca662441 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -324,6 +324,75 @@ export interface CloudSessionDataLoadedMessage { messages: Message[] } +/** Provider-neutral availability, independent of any session's turn state. */ +export interface HostedProviderStatus { + id: string + default: boolean + availability: + | { state: "available" } + | { state: "unavailable"; reason: "signed_out" | "rejected" | "unreachable"; message: string } + transport: + | { state: "unknown" } + | { state: "reachable"; checkedAt: number } + | { state: "unreachable"; checkedAt: number; message: string } +} + +export interface HostedSessionSummary { + key: string + providerID: string + sessionID: string + title: string + created: number + updated: number + directory?: string +} + +export interface HostedProvidersLoadedMessage { + type: "hostedProvidersLoaded" + providers: HostedProviderStatus[] + error?: string +} + +export interface HostedSessionsLoadedMessage { + type: "hostedSessionsLoaded" + sessions: HostedSessionSummary[] + error?: string +} + +export interface HostedSessionLoadedMessage { + type: "hostedSessionLoaded" + key: string + providerID: string + sessionID: string + session: { id: string; title: string; time: { created: number; updated: number } } + title: string + messages: Message[] + cursor?: string + omitted?: number + mode?: MessageLoadMode +} + +export interface HostedSessionStartedMessage { + type: "hostedSessionStarted" + key: string + providerID: string + sessionID: string + replayed: boolean + delivery: "sent" | "queued" +} + +export interface HostedSessionFailedMessage { + type: "hostedSessionFailed" + error: string + code?: string + sessionID?: string +} + +export interface HostedSessionDetachedMessage { + type: "hostedSessionDetached" + key: string +} + export interface CloudSessionImportedMessage { type: "cloudSessionImported" cloudSessionId: string @@ -525,6 +594,7 @@ export interface ChatSettingsLoadedMessage { settings: { shiftTabCyclesVariant: boolean browserAutomation: boolean + hostedSessions: boolean agentManagerBrowserOpenLinksIn: "external" | "integrated" workspaceTrusted: boolean } @@ -1844,6 +1914,12 @@ export type ExtensionMessage = | MessageCreatedMessage | SessionsLoadedMessage | CloudSessionsLoadedMessage + | HostedProvidersLoadedMessage + | HostedSessionsLoadedMessage + | HostedSessionLoadedMessage + | HostedSessionStartedMessage + | HostedSessionFailedMessage + | HostedSessionDetachedMessage | GitRemoteUrlLoadedMessage | ActionMessage | ProfileDataMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index cebe0e5a498e..38b7d96086c0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -138,6 +138,41 @@ export interface RequestCloudSessionsMessage { gitUrl?: string } +export interface RequestHostedProvidersMessage { + type: "requestHostedProviders" +} + +export interface RequestHostedSessionsMessage { + type: "requestHostedSessions" + limit?: number +} + +export interface HostedOpenSessionMessage { + type: "hostedOpenSession" + providerID: string + sessionID: string + limit?: number +} + +export interface HostedStartSessionMessage { + type: "hostedStartSession" + repositoryType?: "github" | "gitlab" | "git" + repo?: string + url?: string + branch?: string + prompt: string + mode: string + model: string + organizationID?: string + operationKey: string + messageID: string +} + +export interface HostedDetachSessionMessage { + type: "hostedDetachSession" + key: string +} + export interface RequestGitRemoteUrlMessage { type: "requestGitRemoteUrl" } @@ -1765,6 +1800,11 @@ export type WebviewMessage = | LoadSessionsRequest | RequestSessionModelUsageMessage | RequestCloudSessionsMessage + | RequestHostedProvidersMessage + | RequestHostedSessionsMessage + | HostedOpenSessionMessage + | HostedStartSessionMessage + | HostedDetachSessionMessage | RequestGitRemoteUrlMessage | LoginRequest | LogoutRequest From cc124547e164677113b3a4917daae9a1c7af45d5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 25 Oct 2026 17:35:20 +0100 Subject: [PATCH 2/3] fix(vscode): address hosted session review feedback Clear the stale cloud preview when a hosted session is selected so prompts cannot be routed to a previously previewed cloud session, and keep the preview fallback off hosted keys entirely. Move the hosted panel out of the tab list so it renders in the history body, release hosted subscriptions when a session is no longer open, and detach an attachment that lost the invalidation race instead of leaking it. A late hosted load no longer steals focus or clears another session's loading state, and provider/session discovery errors are surfaced with a retry. --- packages/kilo-vscode/src/KiloProvider.ts | 22 +++++-- packages/kilo-vscode/src/hosted/registry.ts | 3 + packages/kilo-vscode/src/hosted/service.ts | 5 ++ .../tests/unit/hosted-service.test.ts | 41 +++++++++++-- .../tests/unit/hosted-session-bridge.test.ts | 57 +++++++++++++++++++ .../tests/unit/hosted-wiring.test.ts | 46 +++++++++++++++ .../src/components/history/HistoryView.tsx | 41 ++++++------- .../components/history/HostedSessionList.tsx | 25 ++++---- .../src/context/hosted-session-bridge.ts | 10 ++-- .../webview-ui/src/context/local-tabs.tsx | 18 ++++++ .../webview-ui/src/context/session.tsx | 14 ++++- 11 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts create mode 100644 packages/kilo-vscode/tests/unit/hosted-wiring.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 04cb440025bb..064c8f4545f0 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1178,6 +1178,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const id of this.openSessionIds) { if (!next.has(id)) this.trackedSessionIds.delete(id) } + // A hosted session that is no longer open must stop streaming: its SSE + // reader re-attaches with backoff forever otherwise. + for (const key of [...this.hostedUnsubs.keys()]) { + if (next.has(key)) continue + this.releaseHosted(key) + } this.openSessionIds.clear() for (const id of next) { this.openSessionIds.add(id) @@ -5164,9 +5170,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return true case "hostedDetachSession": if (typeof message.key === "string") { - this.hostedUnsubs.get(message.key)?.() - this.hostedUnsubs.delete(message.key) - await handleHostedDetach(this.hostedCtx, message.key) + this.openSessionIds.delete(message.key) + this.trackedSessionIds.delete(message.key) + this.releaseHosted(message.key) } return true case "loadMessages": { @@ -5349,7 +5355,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper (payload) => this.handleHostedEvent(key, payload), (failure) => { // Terminal: drop the subscription so a later action can resubscribe. - this.hostedUnsubs.delete(key) + this.releaseHosted(key) this.postMessage({ type: "hostedSessionFailed", error: failure.message, @@ -5362,6 +5368,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.hostedUnsubs.set(key, off) } + /** Stops streaming a hosted session and drops its local attachment. */ + private releaseHosted(key: string): void { + const off = this.hostedUnsubs.get(key) + this.hostedUnsubs.delete(key) + off?.() + void handleHostedDetach(this.hostedCtx, key) + } + private handleHostedEvent(key: string, payload: unknown): void { const event = payload as Parameters[0] if (!event || typeof event.type !== "string") return diff --git a/packages/kilo-vscode/src/hosted/registry.ts b/packages/kilo-vscode/src/hosted/registry.ts index d6180186598a..7da2be25b9de 100644 --- a/packages/kilo-vscode/src/hosted/registry.ts +++ b/packages/kilo-vscode/src/hosted/registry.ts @@ -74,6 +74,9 @@ export class HostedRegistry { expiresAt: value.expiresAt, } if (this.version(key) !== start) { + // Invalidation or a detach won the race. The backend attachment is + // already minted, so release it instead of leaking it until expiry. + port.detach(value.attachmentID).catch(() => undefined) deferred.reject(new Error("Hosted attachment was invalidated")) return } diff --git a/packages/kilo-vscode/src/hosted/service.ts b/packages/kilo-vscode/src/hosted/service.ts index cf4087006610..ce5378f451a6 100644 --- a/packages/kilo-vscode/src/hosted/service.ts +++ b/packages/kilo-vscode/src/hosted/service.ts @@ -203,6 +203,11 @@ export class HostedService { await this.registry.detach(key, this.port()) } + /** Drops the cached attachment so the next request mints a fresh one. */ + invalidate(key: string): void { + this.registry.invalidate(key) + } + /** Backend restarts invalidate every attachment. */ reset(): void { for (const [key, stream] of [...this.streams]) { diff --git a/packages/kilo-vscode/tests/unit/hosted-service.test.ts b/packages/kilo-vscode/tests/unit/hosted-service.test.ts index 2839c59703f8..3283d8f38a33 100644 --- a/packages/kilo-vscode/tests/unit/hosted-service.test.ts +++ b/packages/kilo-vscode/tests/unit/hosted-service.test.ts @@ -213,11 +213,12 @@ describe("hosted service", () => { }) test("keeps colliding native session ids on their own attachment", async () => { - const ctx = backend((call) => { + const ctx = backend((call, index) => { if (call.path.endsWith("/attach")) { return { body: { - attachmentID: "wrk_att_1", + // Per-call ids, so the assertions can prove each key keeps its own. + attachmentID: `wrk_att_${index + 1}`, session: { providerID: "kilo-cloud", sessionID: "ses_same" }, expiresAt: Date.now() + 60_000, }, @@ -231,8 +232,40 @@ describe("hosted service", () => { ctx.service.remember("beta", "ses_same") await ctx.service.attach("host:alpha:ses_same") await ctx.service.attach("host:beta:ses_same") - expect(ctx.service.attachment("host:alpha:ses_same")?.attachmentID).toBe("wrk_att_1") - expect(ctx.service.attachment("host:beta:ses_same")?.attachmentID).toBe("wrk_att_1") + const alpha = ctx.service.attachment("host:alpha:ses_same")?.attachmentID + const beta = ctx.service.attachment("host:beta:ses_same")?.attachmentID + expect(alpha).toBe("wrk_att_1") + expect(beta).toBe("wrk_att_2") + expect(alpha).not.toBe(beta) expect(ctx.calls.filter((call) => call.path.endsWith("/attach"))).toHaveLength(2) }) + + test("detaches an attachment that lost the invalidation race instead of leaking it", async () => { + const gate = Promise.withResolvers() + const detached: string[] = [] + const service = new HostedService( + () => + ({ + sessionHost: { + attach: async () => { + await gate.promise + return { data: { attachmentID: "wrk_att_stale", expiresAt: Date.now() + 60_000 } } + }, + detach: async (input: { attachmentID: string }) => { + detached.push(input.attachmentID) + return { data: true } + }, + }, + }) as never, + ) + const key = service.remember("kilo-cloud", "ses_hosted") + + const pending = service.attach(key) + service.invalidate(key) + gate.resolve() + await pending.catch(() => undefined) + await Promise.resolve() + await Promise.resolve() + expect(detached).toEqual(["wrk_att_stale"]) + }) }) diff --git a/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts b/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts new file mode 100644 index 000000000000..14865ea82201 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test" +import { createHostedBridge } from "../../webview-ui/src/context/hosted-session-bridge" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages" + +type Loaded = Extract + +function loaded(key: string): Loaded { + return { + type: "hostedSessionLoaded", + key, + providerID: "kilo-cloud", + sessionID: key, + session: { id: key, title: key, time: { created: 1, updated: 2 } }, + title: key, + messages: [], + } +} + +function bridge(current: string | undefined, calls: string[]) { + return createHostedBridge({ + store: (() => undefined) as never, + loaded: () => calls.push("loaded"), + page: () => calls, + toolParts: () => calls, + isolate: (part) => part, + current: () => current, + focus: (key) => calls.push(`focus:${key}`), + draft: (key) => calls.push(`draft:${key}`), + loading: (value) => calls.push(`loading:${value}`), + clearDraft: () => calls.push("clearDraft"), + connected: () => true, + post: (message) => calls.push(`post:${message.type}`), + batch: (run) => run(), + }) +} + +describe("hosted session bridge", () => { + it("does not pull the user back when a late load is no longer the current session", () => { + const key = "host:kilo-cloud:ses_a" + const calls: string[] = [] + bridge("ses_other", calls).handle(loaded(key)) + + // Data is still written so a later selection is warm, but focus and the + // loading state belong to the session the user is actually on. + expect(calls).toContain("loaded") + expect(calls).not.toContain(`focus:${key}`) + expect(calls.some((call) => call.startsWith("loading:"))).toBe(false) + }) + + it("clears loading when the loaded session is still current", () => { + const key = "host:kilo-cloud:ses_a" + const calls: string[] = [] + bridge(key, calls).handle(loaded(key)) + + expect(calls).toContain("loading:false") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts new file mode 100644 index 000000000000..5ef614470935 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const read = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf-8") + +/** + * Regressions that are cheap to verify statically and expensive to notice at + * runtime: a hosted prompt routed to a stale cloud preview, hosted sessions + * rendering outside the history panel region, and hosted streams that are + * never released. + */ +describe("hosted session wiring", () => { + it("clears the cloud preview when a hosted session is selected", () => { + const source = read("webview-ui/src/context/session.tsx") + const start = source.indexOf("function selectHostedSession") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 400) + expect(body).toMatch(/setCloudPreviewId\(null\)/) + }) + + it("never falls back to a cloud preview for a hosted session", () => { + const source = read("webview-ui/src/context/session.tsx") + const guards = source.match(/origin === undefined && !sid\?\.startsWith\("host:"\)/g) ?? [] + expect(guards).toHaveLength(2) + }) + + it("renders the hosted panel outside the tab list", () => { + const source = read("webview-ui/src/components/history/HistoryView.tsx") + const tabs = source.indexOf('class="history-view-tabs"') + const hosted = source.indexOf('id="history-panel-hosted"') + expect(tabs).toBeGreaterThan(-1) + expect(hosted).toBeGreaterThan(-1) + const between = source.slice(tabs, hosted) + expect(between.includes('id="history-panel-cloud"')).toBe(true) + }) + + it("releases hosted subscriptions when a session is no longer open", () => { + const source = read("src/KiloProvider.ts") + const start = source.indexOf("private trackOpenSessions") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 800) + expect(body).toMatch(/this\.releaseHosted\(key\)/) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx index 10f0d3370ef8..1efbddfcb223 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx @@ -172,25 +172,6 @@ const HistoryView: Component = (props) => { {language.t("session.tab.hosted")} - - - - diff --git a/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts index 6c2be93ef494..bfb4bd5128b6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts +++ b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts @@ -8,7 +8,8 @@ export interface HostedBridgeDeps { page(key: string, hasMore: boolean): void toolParts(key: string, messages: Message[]): void isolate(part: Part): Part - current(key: string): void + current(): string | undefined + focus(key: string): void draft(key: string): void loading(value: boolean): void clearDraft(): void @@ -42,7 +43,9 @@ export function createHostedBridge(deps: HostedBridgeDeps) { if (msg.parts && msg.parts.length > 0) deps.store("parts", msg.id, msg.parts.map(deps.isolate)) } deps.toolParts(message.key, message.messages) - deps.current(message.key) + // A late response must not pull the user back if they already switched + // away, nor clear another session's loading state. + if (deps.current() !== message.key) return deps.loading(false) }) } @@ -54,8 +57,7 @@ export function createHostedBridge(deps: HostedBridgeDeps) { } const key = `host:${providerID}:${sessionID}` deps.clearDraft() - deps.current(key) - deps.draft(key) + deps.focus(key) deps.loading(true) deps.post({ type: "hostedOpenSession", providerID, sessionID }) } diff --git a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx index 989e5509ec87..033e08673f19 100644 --- a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx @@ -59,6 +59,7 @@ interface LocalTabsValue { isPinned: (id: string) => boolean togglePinned: (id: string) => void previewCloud: (id: string) => void + openHosted: (id: string) => void reorder: (from: string, to: string) => boolean move: (id: string, offset: -1 | 1) => number | undefined persist: () => void @@ -66,6 +67,9 @@ interface LocalTabsValue { const LocalTabsContext = createContext() +/** Hosted sessions are keyed `host::` (see src/hosted/identity.ts). */ +export const HOSTED_TAB_PREFIX = "host:" + const same = (left: string[], right: string[]) => left.length === right.length && left.every((id, i) => right[i] === id) export const LocalTabsProvider: ParentComponent = (props) => { @@ -182,6 +186,13 @@ export const LocalTabsProvider: ParentComponent = (props) => { const closeToRightTab = (id: string) => closeToRight(id, closeDeps) const rightTabs = (id: string) => closableRight(id, closeDeps) const previewCloud = (id: string) => setCloud(id) + const openHosted = (id: string) => { + if (!id.startsWith(HOSTED_TAB_PREFIX)) return + setCloud(undefined) + apply(openSessionTab(current(), id)) + focus(id) + persist() + } const reorder = (from: string, to: string) => { const next = reorderPinnedTabs(ids(), pinned(), from, to) if (!next) return false @@ -250,6 +261,12 @@ export const LocalTabsProvider: ParentComponent = (props) => { focus(next.active) return } + if (message.type === "hostedSessionStarted") { + fresh.add(message.key) + apply(openSessionTab(current(), message.key)) + focus(message.key) + return + } if (message.type === "cloudSessionImported") { const activate = cloud() === message.cloudSessionId fresh.add(message.session.id) @@ -297,6 +314,7 @@ export const LocalTabsProvider: ParentComponent = (props) => { isPinned, togglePinned, previewCloud, + openHosted, reorder, move, persist, diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 6e2294cbce15..663f35aa6193 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1169,7 +1169,11 @@ export const SessionProvider: ParentComponent = (props) => { page: (key, hasMore) => patchPage(key, { hasMore, lastMutation: "replace" }), toolParts: (key, messages) => rebuildToolParts(key, messages), isolate: (part) => isolate(part), - current: (key) => setCurrentSessionID(key), + current: () => currentSessionID(), + focus: (key) => { + setCurrentSessionID(key) + setDraftSessionID(key) + }, draft: (key) => setDraftSessionID(key), loading: (value) => setLoading(value), clearDraft: () => agentDrafts.prune(draftSessionID()), @@ -2214,7 +2218,7 @@ export const SessionProvider: ParentComponent = (props) => { recordModelUsage(selection.providerID, selection.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) - : origin === undefined + : origin === undefined && !sid?.startsWith("host:") ? cloudPreviewId() : null if (preview) { @@ -2314,7 +2318,7 @@ export const SessionProvider: ParentComponent = (props) => { // Cloud previews need import-then-command; post importAndSend with command metadata const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) - : origin === undefined + : origin === undefined && !sid?.startsWith("host:") ? cloudPreviewId() : null if (preview) { @@ -2674,6 +2678,10 @@ export const SessionProvider: ParentComponent = (props) => { function selectHostedSession(providerID: string, sessionID: string) { setUserClearedSession(false) + // A hosted key is not a cloud preview. Clear it, otherwise the prompt + // fallback would route the next send to a previously previewed cloud + // session instead of the hosted one. + setCloudPreviewId(null) hosted.select(providerID, sessionID) } From dc67f62dc742d0d0a860284d0022a8bb5ac13197 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 25 Oct 2026 19:19:32 +0100 Subject: [PATCH 3/3] fix(vscode): preserve hosted session state Keep opened hosted tabs through local session reconciliation and track provider and session discovery failures independently so one successful response cannot hide the other request's error. --- .../tests/unit/hosted-wiring.test.ts | 16 ++++++++++++++++ .../components/history/HostedSessionList.tsx | 17 +++++++++++------ .../webview-ui/src/context/local-tabs.tsx | 3 +++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts index 5ef614470935..101789c42820 100644 --- a/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts +++ b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts @@ -43,4 +43,20 @@ describe("hosted session wiring", () => { const body = source.slice(start, start + 800) expect(body).toMatch(/this\.releaseHosted\(key\)/) }) + + it("preserves opened hosted tabs across complete local session reconciliation", () => { + const source = read("webview-ui/src/context/local-tabs.tsx") + const start = source.indexOf("const openHosted") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 500) + expect(body).toMatch(/fresh\.add\(id\)/) + expect(body.indexOf("fresh.add(id)")).toBeLessThan(body.indexOf("openSessionTab")) + }) + + it("keeps provider and session discovery errors independent", () => { + const source = read("webview-ui/src/components/history/HostedSessionList.tsx") + expect(source).toMatch(/setProviderError\(message\.error \?\? ""\)/) + expect(source).toMatch(/setSessionError\(message\.error \?\? ""\)/) + expect(source).toMatch(/actionError\(\) \|\| providerError\(\) \|\| sessionError\(\)/) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HostedSessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HostedSessionList.tsx index f908b1b9f79c..8dc3379aa090 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HostedSessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HostedSessionList.tsx @@ -27,7 +27,9 @@ const HostedSessionList: Component = (props) => { const [sessions, setSessions] = createSignal([]) const [loading, setLoading] = createSignal(true) const [notice, setNotice] = createSignal("") - const [error, setError] = createSignal("") + const [providerError, setProviderError] = createSignal("") + const [sessionError, setSessionError] = createSignal("") + const [actionError, setActionError] = createSignal("") const [availability, setAvailability] = createSignal< { state: "available" } | { state: "unavailable"; reason: string; message: string } | undefined >(undefined) @@ -37,6 +39,7 @@ const HostedSessionList: Component = (props) => { const value = availability() return value?.state === "unavailable" ? value : undefined } + const error = () => actionError() || providerError() || sessionError() let seq = 0 @@ -47,18 +50,18 @@ const HostedSessionList: Component = (props) => { setTransport(provider?.transport.state) // A failed provider read must not look like "Start is disabled for no // reason": keep a sign-in path and show the failure. - setError(message.error ?? "") + setProviderError(message.error ?? "") return } if (message.type === "hostedSessionsLoaded") { setSessions(message.sessions) setLoading(false) - setError(message.error ?? "") + setSessionError(message.error ?? "") return } if (message.type === "hostedSessionStarted") { setLoading(true) - setError("") + setActionError("") vscode.postMessage({ type: "requestHostedSessions", limit: 50 }) props.onSelectSession?.({ key: message.key, @@ -72,7 +75,7 @@ const HostedSessionList: Component = (props) => { } if (message.type === "hostedSessionFailed") { setLoading(false) - setError(message.error) + setActionError(message.error) setNotice(message.error) } }) @@ -81,7 +84,9 @@ const HostedSessionList: Component = (props) => { function refresh() { setLoading(true) - setError("") + setProviderError("") + setSessionError("") + setActionError("") vscode.postMessage({ type: "requestHostedProviders" }) vscode.postMessage({ type: "requestHostedSessions", limit: 50 }) } diff --git a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx index 033e08673f19..71884d62527e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx @@ -189,6 +189,9 @@ export const LocalTabsProvider: ParentComponent = (props) => { const openHosted = (id: string) => { if (!id.startsWith(HOSTED_TAB_PREFIX)) return setCloud(undefined) + // Complete local session lists never contain hosted keys. Preserve this + // tab until the user closes it instead of reconciling it away. + fresh.add(id) apply(openSessionTab(current(), id)) focus(id) persist()