diff --git a/.changeset/kiloclaw-kilo-chat-migration.md b/.changeset/kiloclaw-kilo-chat-migration.md new file mode 100644 index 00000000000..59454053337 --- /dev/null +++ b/.changeset/kiloclaw-kilo-chat-migration.md @@ -0,0 +1,8 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +"@kilocode/kilo-gateway": minor +"@kilocode/sdk": minor +--- + +Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. diff --git a/bun.lock b/bun.lock index 66f76f529e7..e7890dfe9b6 100644 --- a/bun.lock +++ b/bun.lock @@ -251,7 +251,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", @@ -392,7 +391,6 @@ "semver": "^7.6.3", "simple-git": "3.35.2", "solid-js": "catalog:", - "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", @@ -580,7 +578,6 @@ "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.46", @@ -2134,8 +2131,6 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], - "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="], @@ -3404,8 +3399,6 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], - "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -4156,8 +4149,6 @@ "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], - "stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="], - "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/package.json b/package.json index d4e3deb1e06..d1008676a0f 100644 --- a/package.json +++ b/package.json @@ -143,8 +143,7 @@ "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, "version": "7.2.42", "peerDependencies": {} diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 7e38c2c03cc..ef52759b953 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -43,4 +43,6 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # OpenAI docs return 404 to plain GET link checks but resolve in browsers + '^https?://platform\.openai\.com/docs/api-reference/responses/create', ] diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 6c39aa9a1b4..c959cf3f9e1 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -598,7 +598,7 @@ Options: --path directory path to generate the agent file [string] --description what the agent should do [string] --mode agent mode [string] [choices: "all", "primary", "subagent"] - --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, codesearch, lsp, skill" [string] + --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, lsp, skill" [string] -m, --model model to use in the format of provider/model [string] ``` diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 75b518f1b24..0cc4931710d 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -12,6 +12,24 @@ export const DEFAULT_KILO_API_URL = "https://api.kilo.ai" /** Base URL for Kilo API - can be overridden by KILO_API_URL env var */ export const KILO_API_BASE = process.env[ENV_KILO_API_URL] || DEFAULT_KILO_API_URL +/** Environment variable for custom Kilo Chat URL */ +export const KILO_CHAT_URL_ENV = "KILO_CHAT_URL" + +/** Default Kilo Chat URL (REST endpoint for messages, conversations, etc.) */ +export const KILO_DEFAULT_CHAT_URL = "https://chat.kiloapps.io" + +/** Base URL for Kilo Chat - can be overridden by KILO_CHAT_URL env var */ +export const KILO_CHAT_URL = process.env[KILO_CHAT_URL_ENV] || KILO_DEFAULT_CHAT_URL + +/** Environment variable for custom Event Service URL */ +export const KILO_EVENT_SERVICE_URL_ENV = "EVENT_SERVICE_URL" + +/** Default Event Service URL (WebSocket endpoint for kilo-chat events) */ +export const KILO_DEFAULT_EVENT_SERVICE_URL = "wss://events.kiloapps.io" + +/** Base URL for Event Service - can be overridden by EVENT_SERVICE_URL env var */ +export const KILO_EVENT_SERVICE_URL = process.env[KILO_EVENT_SERVICE_URL_ENV] || KILO_DEFAULT_EVENT_SERVICE_URL + /** Default base URL for OpenRouter-compatible endpoint */ export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter` diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 0282e2f94bc..27b4c8b8572 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -8,7 +8,13 @@ import { fetchProfile, fetchBalance } from "../api/profile.js" import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" -import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" +import { + KILO_API_BASE, + KILO_CHAT_URL, + KILO_EVENT_SERVICE_URL, + HEADER_FEATURE, + HEADER_ORGANIZATIONID, +} from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" @@ -524,8 +530,24 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "application/json": { schema: resolver( z.object({ + // `recovering` and `restoring` are transitional states the + // worker reports while it brings an instance back online + // after an unexpected stop or a snapshot restore — see + // cloud `services/kiloclaw/src/index.ts` and the + // `PlatformStatusResponse` type in + // cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in + // the enum so the SDK types stay accurate. status: z - .enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"]) + .enum([ + "provisioned", + "starting", + "restarting", + "recovering", + "running", + "stopped", + "destroying", + "restoring", + ]) .nullable(), sandboxId: z.string().optional(), flyRegion: z.string().optional(), @@ -536,6 +558,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { channelCount: z.number().optional(), secretCount: z.number().optional(), userId: z.string().optional(), + botName: z.string().nullable().optional(), }), ), }, @@ -578,57 +601,52 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "/claw/chat-credentials", describeRoute({ summary: "Get KiloClaw chat credentials", - description: "Fetch Stream Chat credentials for the user's KiloClaw instance", + description: + "Returns the bearer token and endpoint URLs the client uses to talk to the Kilo Chat worker " + + "and the Event Service. The bearer is the user's existing long-lived Kilo JWT — kilo-chat and " + + "event-service both verify it directly with NEXTAUTH_SECRET, so no separate token mint is needed.", operationId: "kilo.claw.chatCredentials", responses: { 200: { - description: "Stream Chat credentials or null", + description: "Kilo Chat credentials or null", content: { "application/json": { schema: resolver( z .object({ - apiKey: z.string(), - userId: z.string(), - userToken: z.string(), - channelId: z.string(), + token: z.string(), + expiresAt: z.string(), + kiloChatUrl: z.string(), + eventServiceUrl: z.string(), }) .nullable(), ), }, }, }, - ...errors(401, 502), + ...errors(401), }, }), async (c: any) => { - try { - const auth = await Auth.get("kilo") - if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) return c.json({ error: "No valid token found" }, 401) - - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - if (organizationId) { - headers[HEADER_ORGANIZATIONID] = organizationId - } - - const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers }) - - if (!response.ok) { - const text = await response.text() - return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) - } - - return c.json(await response.json()) - } catch (err: any) { - console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err) - return c.json({ error: "Failed to reach KiloClaw" }, 502) - } + const auth = await Auth.get("kilo") + if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined + if (!token) return c.json({ error: "No valid token found" }, 401) + + // For OAuth, expires is a millisecond epoch we already track. For + // API tokens we don't have a verified expiry locally — the JWT is + // signed by the cloud and validated by kilo-chat/event-service on + // every request. Use a far-future placeholder so the client cache + // doesn't refetch unnecessarily; on 401 the client clears the + // cache and prompts re-auth. + const expiresAtMs = auth.type === "oauth" ? auth.expires : Date.now() + 365 * 24 * 60 * 60 * 1000 + + return c.json({ + token, + expiresAt: new Date(expiresAtMs).toISOString(), + kiloChatUrl: KILO_CHAT_URL, + eventServiceUrl: KILO_EVENT_SERVICE_URL, + }) }, ) .get( diff --git a/packages/kilo-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index c87f2f13835..ae502e81192 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -51,5 +51,4 @@ background: var(--background-stronger); margin-bottom: 1rem; } - } diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 25eb8875b7a..53485a2738c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -949,7 +949,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index 4def9b0fda7..fb0c4beebfc 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -1,8 +1,13 @@ /** * KiloClaw panel provider for the VS Code extension. * - * Owns the Stream Chat WebSocket connection (in the extension host Node.js runtime) - * and relays messages to/from the webview via postMessage. + * Owns the Kilo Chat HTTP client + event-service WebSocket connection + * (in the extension host Node.js runtime) and relays messages to/from + * the webview via postMessage. + * + * Architecture: extension host owns both clients and reactive state; + * the webview is a stateless renderer that issues commands via + * postMessage and receives state diffs back. */ import * as vscode from "vscode" @@ -11,35 +16,79 @@ import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" import { watchFontSizeConfig } from "../kilo-provider/font-size" -import { connect, history, presence, type ClawChatClient } from "./chat-client" +import { TokenManager } from "./token-manager" +import { KiloChatApiError, KiloChatClient } from "./kilo-chat-client" +import { EventServiceClient, WebSocketAuthError } from "./event-service-client" +import { ulid } from "./ulid" import type { + ActionDeliveryFailedEvent, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationLeftEvent, + ConversationListItem, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, KiloClawInMessage, KiloClawOutMessage, KiloClawState, - ClawStatus, - ChatCredentials, - ChatMessage, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + TypingMember, + TypingEvent, } from "./types" -const MAX_MESSAGES = 500 const STATUS_POLL_MS = 10_000 +const BOT_STATUS_NUDGE_MS = 15_000 +const TYPING_TIMEOUT_MS = 5_000 +const MESSAGES_PAGE = 50 +const CONVERSATIONS_PAGE = 50 export class KiloClawProvider implements vscode.Disposable { static readonly viewType = "kilo-code.new.KiloClawPanel" private panel: vscode.WebviewPanel | null = null - private chat: ClawChatClient | null = null private timer: ReturnType | null = null + private botNudge: ReturnType | null = null private subs: Array<() => void> = [] private chatSubs: Array<() => void> = [] - private messages: ChatMessage[] = [] - private status: ClawStatus | null = null - private online = false - private connected = false private disposed = false private initializing = false private generation = 0 + // Clients (created lazily per init) + private events: EventServiceClient | null = null + private chat: KiloChatClient | null = null + private tokens: TokenManager | null = null + + // Reactive state mirrored to the webview + private status: ClawStatus | null = null + private currentUserId: string | null = null + private sandboxId: string | null = null + private conversations: ConversationListItem[] = [] + private conversationsCursor: string | null = null + private hasMoreConversations = false + private activeConversationId: string | null = null + private messages: Message[] = [] + private hasMoreMessages = false + private botStatus: BotStatusRecord | null = null + private conversationStatus: ConversationStatusRecord | null = null + private typingMembers: TypingMember[] = [] + private typingTimers = new Map>() + private subscribedSandboxContext: string | null = null + private subscribedConversationContext: string | null = null + constructor( private readonly uri: vscode.Uri, private readonly connection: KiloConnectionService, @@ -106,14 +155,18 @@ export class KiloClawProvider implements vscode.Disposable { }) this.subs.push(() => disposeSub.dispose()) - // Pause status polling when the panel is not visible to avoid unnecessary HTTP traffic + // Pause status polling + bot nudge when the panel is not visible const viewSub = panel.onDidChangeViewState(() => { - if (panel.visible) this.startPolling() - else this.stopPolling() + if (panel.visible) { + this.startPolling() + this.startBotNudge() + } else { + this.stopPolling() + this.stopBotNudge() + } }) this.subs.push(() => viewSub.dispose()) - // Subscribe to language changes broadcast by other KiloProvider instances const unsub = this.connection.onLanguageChanged((locale) => { this.post({ type: "kiloclaw.locale", locale }) }) @@ -130,17 +183,59 @@ export class KiloClawProvider implements vscode.Disposable { switch (msg.type) { case "kiloclaw.ready": await this.init() - break - case "kiloclaw.send": - await this.sendChat(msg.text) - break + return case "kiloclaw.openExternal": { const uri = vscode.Uri.parse(msg.url) if (uri.scheme === "https" || uri.scheme === "http") { void vscode.env.openExternal(uri) } - break + return } + case "kiloclaw.selectConversation": + await this.selectConversation(msg.conversationId) + return + case "kiloclaw.createConversation": + await this.createConversation(msg.title) + return + case "kiloclaw.renameConversation": + await this.renameConversation(msg.conversationId, msg.title) + return + case "kiloclaw.leaveConversation": + await this.leaveConversation(msg.conversationId) + return + case "kiloclaw.loadMoreConversations": + await this.loadMoreConversations() + return + case "kiloclaw.sendMessage": + await this.sendMessage(msg.conversationId, msg.content, msg.inReplyToMessageId) + return + case "kiloclaw.editMessage": + await this.editMessage(msg.conversationId, msg.messageId, msg.content) + return + case "kiloclaw.deleteMessage": + await this.deleteMessage(msg.conversationId, msg.messageId) + return + case "kiloclaw.loadMoreMessages": + await this.loadMoreMessages(msg.conversationId, msg.before) + return + case "kiloclaw.addReaction": + await this.addReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.removeReaction": + await this.removeReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.executeAction": + await this.executeAction(msg.conversationId, msg.messageId, msg.groupId, msg.value) + return + case "kiloclaw.sendTyping": + await this.sendTyping(msg.conversationId) + return + case "kiloclaw.sendTypingStop": + await this.sendTypingStop(msg.conversationId) + return + case "kiloclaw.markRead": + await this.markRead(msg.conversationId) + return } } @@ -153,14 +248,13 @@ export class KiloClawProvider implements vscode.Disposable { return gen !== this.generation || this.disposed } + // ── init / lifecycle ──────────────────────────────────────────────── + private async init(): Promise { if (this.initializing || this.disposed) return this.initializing = true const gen = this.generation - // Track whether we deferred to waitForConnection — if so, keep - // `initializing` true so duplicate kiloclaw.ready messages are - // harmlessly ignored until the connection arrives. let deferred = false try { @@ -174,89 +268,195 @@ export class KiloClawProvider implements vscode.Disposable { return } - const credentials = await this.fetchCreds(client, gen) - if (!credentials) return - - // Connect to Stream Chat - try { - await this.connectChat(credentials, gen) - } catch (err: unknown) { - if (this.stale(gen)) return - const msg = err instanceof Error ? err.message : String(err) - console.error("[Kilo New] KiloClaw chat connect failed:", msg) - this.post({ - type: "kiloclaw.state", - state: { - phase: "ready", - locale: this.locale, - status: this.status, - connected: false, - online: false, - messages: [], - }, - }) - this.post({ type: "kiloclaw.error", error: msg || "Failed to connect to chat" }) - this.startPolling() - return - } - + const ok = await this.bootstrap(client, gen) + if (!ok) return if (this.stale(gen)) return - // Push ready state const state: KiloClawState = { phase: "ready", locale: this.locale, status: this.status, - connected: this.connected, - online: this.online, + currentUserId: this.currentUserId ?? "", + sandboxId: this.sandboxId ?? "", + conversations: this.conversations, + hasMoreConversations: this.hasMoreConversations, + activeConversationId: this.activeConversationId, messages: this.messages, + hasMoreMessages: this.hasMoreMessages, + botStatus: this.botStatus, + conversationStatus: this.conversationStatus, + typingMembers: this.typingMembers, } this.post({ type: "kiloclaw.state", state }) this.startPolling() + this.startBotNudge() } finally { if (!deferred) this.initializing = false } } /** - * Fetch and validate instance status + chat credentials. - * Returns credentials on success, null when stale or after posting a state. - * - * Matches the TUI flow in packages/opencode/src/kilocode/kilo-commands.tsx:67,75 — - * any failure of status() (SDK error, non-2xx from the gateway, missing data, or - * missing userId) funnels to noInstance (SetupView). Any failure of - * chatCredentials() funnels to needsUpgrade (UpgradeView). The upstream Kilo API - * returns a non-2xx when no instance is provisioned, which the gateway mirrors - * and the SDK surfaces as res.error — not a thrown exception. + * Resolve instance status, fetch chat token, and wire up all clients. + * Returns `true` if everything is ready, `false` if a non-ready phase + * was already posted (loading / noInstance / needsUpgrade / error). */ - private async fetchCreds(client: KiloClient, gen: number): Promise { - const res = await client.kilo.claw.status().catch(() => null) - if (this.stale(gen)) return null + private async bootstrap(client: KiloClient, gen: number): Promise { + const ok = await this.resolveStatus(client, gen) + if (!ok) return false + if (this.stale(gen)) return false - const data = res?.data as (ClawStatus & { userId?: string }) | undefined - if (!res || (res as Record).error || !data || !data.userId) { + const envelope = await this.fetchChatToken(gen) + if (!envelope) return false + if (this.stale(gen)) return false + + if (!(await this.openChatStream(envelope, gen))) return false + if (this.stale(gen)) return false + + if (!this.sandboxId) { this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) - return null + return false } - this.status = data + await this.loadInitialSnapshots() + return true + } - const creds = await client.kilo.claw.chatCredentials().catch(() => null) - if (this.stale(gen)) return null + private async resolveStatus(client: KiloClient, gen: number): Promise { + const statusRes = await client.kilo.claw.status().catch(() => null) + if (this.stale(gen)) return false - if (!creds || (creds as Record).error || !creds.data) { + const statusData = statusRes?.data as (ClawStatus & { userId?: string }) | undefined + if (!statusRes || (statusRes as Record).error || !statusData || !statusData.userId) { + this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) + return false + } + this.status = statusData + this.currentUserId = statusData.userId + this.sandboxId = statusData.sandboxId ?? null + return true + } + + private async fetchChatToken(gen: number): Promise { + const tokens = new TokenManager(() => { + try { + return this.connection.getClient() + } catch { + return null + } + }) + try { + const envelope = await tokens.getOrFetch() + this.tokens = tokens + return envelope + } catch (err) { + if (this.stale(gen)) return null + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw chat token fetch failed:", message) + // Token fetch typically fails when the instance hasn't been upgraded + // to support kilo-chat — surface that as the upgrade prompt. this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) return null } + } + + private async openChatStream(envelope: ChatToken, gen: number): Promise { + const tokens = this.tokens! + const events = new EventServiceClient({ + url: envelope.eventServiceUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.events = events + + const chat = new KiloChatClient({ + baseUrl: envelope.kiloChatUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.chat = chat + + try { + await events.connect() + } catch (err) { + if (this.stale(gen)) return false + if (err instanceof WebSocketAuthError) { + this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) + return false + } + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw event-service connect failed:", message) + this.post({ + type: "kiloclaw.state", + state: { phase: "error", locale: this.locale, error: message || "Failed to connect to chat" }, + }) + return false + } - return creds.data as ChatCredentials + this.attachEventHandlers(events, chat) + this.subscribeSandboxContext() + return true } - /** - * Ensure the CLI backend is running and return its SDK client. - * Returns `null` when the backend isn't available yet (caller should defer). - */ - private async resolveClient() { + private async loadInitialSnapshots(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw listConversations failed:", message) + } + + try { + const res = await this.chat.getBotStatus(target) + if (this.sandboxId !== target) return + this.botStatus = res.status ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getBotStatus failed:", message) + } + + // Auto-select the most recent conversation so the panel opens straight + // into the user's ongoing chat instead of the "select a conversation" + // empty state. Only runs on first init (or after the active one was + // explicitly cleared) — preserves the user's selection across reconnects. + if (!this.activeConversationId && this.conversations.length > 0) { + const latest = this.conversations.reduce((best, c) => { + const ax = best.lastActivityAt ?? best.joinedAt + const bx = c.lastActivityAt ?? c.joinedAt + return bx > ax ? c : best + }) + this.activeConversationId = latest.conversationId + this.subscribeConversationContext(latest.conversationId) + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(latest.conversationId) + if (this.activeConversationId === latest.conversationId) { + this.conversationStatus = res.status ?? null + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + } + + void this.markRead(latest.conversationId) + } + } + + private async resolveClient(): Promise { if (this.connection.getConnectionState() !== "connected") { try { const dir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? homedir() @@ -274,95 +474,673 @@ export class KiloClawProvider implements vscode.Disposable { } } - private async connectChat(creds: ChatCredentials, gen: number): Promise { - // Disconnect previous client to avoid duplicate websockets/listeners - this.disconnectChat() + private waitForConnection(): void { + const unsub = this.connection.onStateChange((state) => { + if (state === "connected" && !this.disposed) { + unsub() + this.initializing = false + void this.init() + } + }) + this.subs.push(unsub) + } + + // ── Subscriptions ─────────────────────────────────────────────────── + + private subscribeSandboxContext(): void { + if (!this.events || !this.sandboxId) return + const ctx = `/kiloclaw/${this.sandboxId}` + this.events.subscribe([ctx]) + this.subscribedSandboxContext = ctx + } + + private subscribeConversationContext(conversationId: string): void { + if (!this.events || !this.sandboxId) return + if (this.subscribedConversationContext) { + this.events.unsubscribe([this.subscribedConversationContext]) + } + const ctx = `/kiloclaw/${this.sandboxId}/${conversationId}` + this.events.subscribe([ctx]) + this.subscribedConversationContext = ctx + } + + private unsubscribeConversationContext(): void { + if (!this.events || !this.subscribedConversationContext) return + this.events.unsubscribe([this.subscribedConversationContext]) + this.subscribedConversationContext = null + } - const client = await connect(creds) + private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + // Reset on reconnect — the event stream may have missed events while + // disconnected, so refetch authoritative state. + const offReconnect = events.onReconnect(() => { + void this.refreshOnReconnect() + }) + this.chatSubs.push(offReconnect) - // If the panel was disposed or reinitialized while connect() was in flight, - // tear down the freshly-created client immediately to avoid leaked websockets. - if (this.stale(gen)) { - client.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw stale disconnect failed:", err?.message ?? err) - }) + // ── Sandbox-scoped events ───────────────────────────────────────── + + this.chatSubs.push( + events.on("conversation.created", (ctx, e: ConversationCreatedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + // Newer servers include the full conversation snapshot — splice it in + // immediately so the list updates without a roundtrip. Fall back to a + // refetch when the snapshot is absent (older servers / safety net). + if (e.conversation) { + this.conversations = mergeConversations([e.conversation], this.conversations) + this.broadcastConversations({ replace: true }) + return + } + void this.refreshConversations() + }), + ) + + this.chatSubs.push( + events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, title: e.title } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.left", (ctx, e: ConversationLeftEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.filter((c) => c.conversationId !== e.conversationId) + if (this.activeConversationId === e.conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.hasMoreMessages = false + this.conversationStatus = null + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ + type: "kiloclaw.messages", + conversationId: e.conversationId, + messages: [], + hasMore: false, + replace: true, + }) + this.post({ type: "kiloclaw.conversationStatus", status: null }) + } + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("bot.status", (ctx, e: BotStatusEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + if (e.sandboxId !== this.sandboxId) return + this.botStatus = { online: e.online, at: e.at, updatedAt: Date.now() } + this.post({ type: "kiloclaw.botStatus", status: this.botStatus }) + }), + ) + + // ── Conversation-scoped events ──────────────────────────────────── + + this.chatSubs.push( + events.on("message.created", (ctx, e: MessageCreatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + // Skip if already in cache (race with HTTP response) + if (this.messages.some((m) => m.id === e.messageId)) return + const server = this.toMessageFromCreated(e) + // Reconcile optimistic message via clientId — send the full server + // message so the webview replaces content, not just the id. Without + // this, the webview would display stale client-side content under + // the new id until the next full broadcast. + if (e.clientId) { + const pending = `pending-${e.clientId}` + const idx = this.messages.findIndex((m) => m.id === pending) + if (idx !== -1) { + this.messages = this.messages.map((m, i) => (i === idx ? server : m)) + this.post({ + type: "kiloclaw.messageReplaced", + conversationId: this.activeConversationId ?? "", + pendingId: pending, + message: server, + }) + return + } + } + this.messages = [...this.messages, server] + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.updated", (ctx, e: MessageUpdatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + const idx = this.messages.findIndex((m) => m.id === e.messageId) + if (idx === -1) return + this.messages = this.messages.map((m, i) => + i === idx ? { ...m, content: e.content, clientUpdatedAt: e.clientUpdatedAt } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.deleted", (ctx, e: MessageDeletedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.delivery_failed", (ctx, e: MessageDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deliveryFailed: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("action.delivery_failed", (ctx, e: ActionDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => { + if (m.id !== e.messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== e.groupId) return b + return { ...b, resolved: undefined } + }), + } + }) + this.broadcastMessages({ replace: true }) + this.post({ type: "kiloclaw.error", error: "Couldn't reach the bot — please try again" }) + }), + ) + + this.chatSubs.push( + events.on("reaction.added", (ctx, e: ReactionAddedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionAdded(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("reaction.removed", (ctx, e: ReactionRemovedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("typing", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (this.currentUserId && e.memberId === this.currentUserId) return + this.upsertTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("typing.stop", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.removeTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("conversation.status", (ctx, e: ConversationStatusEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (e.conversationId !== this.activeConversationId) return + this.conversationStatus = { + conversationId: e.conversationId, + contextTokens: e.contextTokens, + contextWindow: e.contextWindow, + model: e.model, + provider: e.provider, + at: e.at, + updatedAt: Date.now(), + } + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + }), + ) + } + + private async refreshOnReconnect(): Promise { + if (!this.chat || !this.sandboxId) return + await this.refreshConversations() + if (this.activeConversationId) { + await this.refreshActiveMessages() + } + } + + // ── Mutations ─────────────────────────────────────────────────────── + + private async selectConversation(conversationId: string): Promise { + if (!this.chat) return + this.activeConversationId = conversationId + this.subscribeConversationContext(conversationId) + this.post({ type: "kiloclaw.activeConversation", conversationId }) + this.typingMembers = [] + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(conversationId) + // The user may have switched conversations while we awaited the fetch; + // only apply the status if it still matches the active conversation. + if (this.activeConversationId !== conversationId) return + this.conversationStatus = res.status ?? null + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) return } - this.chat = client + if (this.activeConversationId !== conversationId) return + void this.markRead(conversationId) + } - // Load history - const bot = `bot-${creds.channelId.replace(/^default-/, "")}` - this.messages = history(this.chat.channel, bot) - this.online = presence(this.chat.channel, bot) - this.connected = true + private async createConversation(title?: string): Promise { + if (!this.chat || !this.sandboxId) return + try { + // The server now returns the full conversation snapshot alongside + // `conversationId`. We rely on `refreshConversations()` to pick up the + // canonical list-item shape rather than mapping the detail payload here. + const res = await this.chat.createConversation({ sandboxId: this.sandboxId, title }) + await this.refreshConversations() + await this.selectConversation(res.conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to create conversation") }) + } + } - // Subscribe to events and relay to webview - const unsub = this.chat.onMessage((msg) => { - // Dedupe: if a message with this id already exists, treat as update - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx !== -1) { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - return + private async renameConversation(conversationId: string, title: string): Promise { + if (!this.chat) return + this.conversations = this.conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c)) + this.broadcastConversations({ replace: true }) + try { + await this.chat.renameConversation(conversationId, title) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to rename conversation") }) + void this.refreshConversations() + } + } + + private async leaveConversation(conversationId: string): Promise { + if (!this.chat) return + try { + await this.chat.leaveConversation(conversationId) + // Optimistic removal — server will also fire conversation.left. + this.conversations = this.conversations.filter((c) => c.conversationId !== conversationId) + if (this.activeConversationId === conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ type: "kiloclaw.messages", conversationId, messages: [], hasMore: false, replace: true }) } - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to leave conversation") }) + } + } + + private async loadMoreConversations(): Promise { + if (!this.chat || !this.sandboxId || !this.hasMoreConversations || !this.conversationsCursor) return + const target = this.sandboxId + const cursor = this.conversationsCursor + try { + const res = await this.chat.listConversations({ + sandboxId: target, + limit: CONVERSATIONS_PAGE, + cursor, + }) + // Sandbox could have changed (reauth / cleanup). Also a newer refresh + // may have already moved the cursor — skip merging stale results. + if (this.sandboxId !== target || this.conversationsCursor !== cursor) return + this.conversations = mergeConversations(this.conversations, res.conversations) + this.conversationsCursor = res.nextCursor + this.hasMoreConversations = res.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load conversations") }) + } + } + + private async sendMessage( + conversationId: string, + content: ContentBlock[], + inReplyToMessageId?: string, + ): Promise { + if (!this.chat) return + if (!this.currentUserId) return + + // kilo-chat validates clientId as a ULID (Crockford Base32); generate + // it here so the webview doesn't need to know the format. + const clientId = ulid() + const pendingId = `pending-${clientId}` + const optimistic: Message = { + id: pendingId, + senderId: this.currentUserId, + content, + inReplyToMessageId: inReplyToMessageId ?? null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + + if (conversationId === this.activeConversationId) { + this.messages = [...this.messages, optimistic] + this.post({ type: "kiloclaw.messageOptimistic", conversationId, message: optimistic }) + } + + try { + await this.chat.sendMessage({ conversationId, content, clientId, inReplyToMessageId }) + // Server will fire `message.created` — reconciliation happens there. + } catch (err) { + console.error("[Kilo New] KiloClaw sendMessage failed:", err instanceof Error ? err.message : err) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to send message") }) + if (conversationId === this.activeConversationId) { + this.messages = this.messages.filter((m) => m.id !== pendingId) + this.post({ type: "kiloclaw.messageRemoved", conversationId, messageId: pendingId }) } - this.post({ type: "kiloclaw.message", message: msg }) - }) - this.chatSubs.push(unsub) - - const unsubUpdated = this.chat.onMessageUpdated((msg) => { - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx === -1) { - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) - } - } else { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) + } + } + + private async editMessage(conversationId: string, messageId: string, content: ContentBlock[]): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, content, clientUpdatedAt: Date.now() } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.editMessage(messageId, { conversationId, content, timestamp: Date.now() }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to edit message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) } - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - }) - this.chatSubs.push(unsubUpdated) + } + } - const unsubPresence = this.chat.onPresence((val) => { - this.online = val - this.post({ type: "kiloclaw.presence", online: val }) - }) - this.chatSubs.push(unsubPresence) + private async deleteMessage(conversationId: string, messageId: string): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.deleteMessage(messageId, conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to delete message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } } - private disconnectChat(): void { - for (const unsub of this.chatSubs) unsub() - this.chatSubs = [] + private async loadMoreMessages(conversationId: string, before: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + const res = await this.chat.listMessages(conversationId, { before, limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only merge if the active conversation is still the same. + if (this.activeConversationId !== conversationId) return + const sorted = sortMessagesAscending(res.messages) + this.messages = mergeMessages(sorted, this.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load messages") }) + } + } + + private async addReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionAdded(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.addReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to add reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async removeReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.removeReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to remove reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } - if (this.chat) { - this.chat.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw disconnect failed:", err?.message ?? err) + private async executeAction( + conversationId: string, + messageId: string, + groupId: string, + value: ExecApprovalDecision, + ): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => { + if (m.id !== messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== groupId) return b + return { ...b, resolved: { value, resolvedBy: this.currentUserId!, resolvedAt: Date.now() } } + }), + } }) - this.chat = null + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.executeAction(conversationId, messageId, { groupId, value }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to execute action") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } } + } - this.connected = false - this.online = false + private async sendTyping(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTyping(conversationId) + } catch (err) { + // Typing is fire-and-forget; don't surface errors. + void err + } } - private async sendChat(text: string): Promise { - if (!this.chat) { - this.post({ type: "kiloclaw.error", error: "Chat not connected" }) - return + private async sendTypingStop(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTypingStop(conversationId) + } catch (err) { + void err } + } + + private async markRead(conversationId: string): Promise { + if (!this.chat) return + if (conversationId !== this.activeConversationId) return + // The mark-read endpoint requires `lastSeenMessageId`. With no messages + // loaded there is nothing to mark — silently skip. + const last = lastNonPendingMessageId(this.messages) + if (!last) return try { - await this.chat.send(text) + await this.chat.markConversationRead(conversationId, { lastSeenMessageId: last }) } catch (err) { - console.error("[Kilo New] KiloClaw send failed:", err instanceof Error ? err.message : err) - this.post({ type: "kiloclaw.error", error: "Failed to send message" }) + void err } } + // ── Helpers ───────────────────────────────────────────────────────── + + private async refreshConversations(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + // Defensive: sandbox could theoretically change during the fetch + // (cleanup or reauth). Skip the write if so. + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshConversations failed:", message) + } + } + + private async refreshActiveMessages(): Promise { + if (!this.chat) return + const target = this.activeConversationId + if (!target) return + try { + const res = await this.chat.listMessages(target, { limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only apply the messages if the active conversation is still `target`. + if (this.activeConversationId !== target) return + this.messages = sortMessagesAscending(res.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshActiveMessages failed:", message) + } + } + + private upsertTypingMember(memberId: string): void { + const now = Date.now() + const idx = this.typingMembers.findIndex((m) => m.memberId === memberId) + if (idx === -1) { + this.typingMembers = [...this.typingMembers, { memberId, at: now }] + } else { + this.typingMembers = this.typingMembers.map((m, i) => (i === idx ? { ...m, at: now } : m)) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typing", conversationId: this.activeConversationId, memberId }) + } + const existing = this.typingTimers.get(memberId) + if (existing) clearTimeout(existing) + this.typingTimers.set( + memberId, + setTimeout(() => this.removeTypingMember(memberId), TYPING_TIMEOUT_MS), + ) + } + + private removeTypingMember(memberId: string): void { + this.typingMembers = this.typingMembers.filter((m) => m.memberId !== memberId) + const t = this.typingTimers.get(memberId) + if (t) { + clearTimeout(t) + this.typingTimers.delete(memberId) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typingStop", conversationId: this.activeConversationId, memberId }) + } + } + + private toMessageFromCreated(e: MessageCreatedEvent): Message { + return { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + } + + private broadcastConversations(opts: { replace: boolean }): void { + this.post({ + type: "kiloclaw.conversations", + conversations: this.conversations, + hasMore: this.hasMoreConversations, + replace: opts.replace, + }) + } + + private broadcastMessages(opts: { replace: boolean }): void { + if (!this.activeConversationId) return + this.post({ + type: "kiloclaw.messages", + conversationId: this.activeConversationId, + messages: this.messages, + hasMore: this.hasMoreMessages, + replace: opts.replace, + }) + } + + private formatError(err: unknown, fallback: string): string { + if (err instanceof KiloChatApiError) { + const body = err.body as Record | null + if (body && typeof body.error === "string") return body.error + } + if (err instanceof Error) return err.message || fallback + return fallback + } + + // ── Polling / nudges / cleanup ────────────────────────────────────── + private startPolling(): void { if (this.timer) return this.timer = setInterval(() => void this.poll(), STATUS_POLL_MS) @@ -374,6 +1152,22 @@ export class KiloClawProvider implements vscode.Disposable { this.timer = null } + private startBotNudge(): void { + if (this.botNudge) return + this.botNudge = setInterval(() => { + if (!this.chat || !this.sandboxId) return + this.chat.requestBotStatus(this.sandboxId).catch((err) => { + console.debug("[Kilo New] KiloClaw requestBotStatus failed:", (err as Error)?.message ?? err) + }) + }, BOT_STATUS_NUDGE_MS) + } + + private stopBotNudge(): void { + if (!this.botNudge) return + clearInterval(this.botNudge) + this.botNudge = null + } + private async poll(): Promise { try { const client = this.connection.getClient() @@ -387,33 +1181,115 @@ export class KiloClawProvider implements vscode.Disposable { } } - /** Subscribe to connection state changes and re-run init() once connected. */ - private waitForConnection(): void { - const unsub = this.connection.onStateChange((state) => { - if (state === "connected" && !this.disposed) { - unsub() - this.initializing = false - void this.init() - } - }) - this.subs.push(unsub) - } - private cleanup(): void { this.generation++ for (const unsub of this.subs) unsub() this.subs = [] + for (const unsub of this.chatSubs) unsub() + this.chatSubs = [] - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } + this.stopPolling() + this.stopBotNudge() + + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() - this.disconnectChat() + this.events?.disconnect() + this.events = null + this.chat = null + this.tokens?.clear() + this.tokens = null + + this.subscribedSandboxContext = null + this.subscribedConversationContext = null this.messages = [] + this.conversations = [] + this.conversationsCursor = null + this.hasMoreConversations = false + this.activeConversationId = null + this.hasMoreMessages = false + this.botStatus = null + this.conversationStatus = null + this.typingMembers = [] this.initializing = false this.status = null + this.currentUserId = null + this.sandboxId = null + } +} + +// ── Pure helpers ────────────────────────────────────────────────────── + +function applyReactionAdded( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + const existing = reactions.find((r) => r.emoji === emoji) + if (existing) { + if (existing.memberIds.includes(memberId)) return reactions + return reactions.map((r) => + r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r, + ) + } + return [...reactions, { emoji, count: 1, memberIds: [memberId] }] +} + +function applyReactionRemoved( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + return reactions + .map((r) => { + if (r.emoji !== emoji) return r + const memberIds = r.memberIds.filter((id) => id !== memberId) + return { ...r, count: memberIds.length, memberIds } + }) + .filter((r) => r.count > 0) +} + +/** Merge two ascending-sorted message arrays by id, keeping the most recent updates. */ +function mergeMessages(older: Message[], newer: Message[]): Message[] { + const seen = new Map() + for (const m of older) seen.set(m.id, m) + for (const m of newer) seen.set(m.id, m) + return [...seen.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +function mergeConversations( + existing: ConversationListItem[], + incoming: ConversationListItem[], +): ConversationListItem[] { + const seen = new Map() + for (const c of existing) seen.set(c.conversationId, c) + for (const c of incoming) seen.set(c.conversationId, c) + return [...seen.values()].sort((a, b) => { + const ax = a.lastActivityAt ?? a.joinedAt + const bx = b.lastActivityAt ?? b.joinedAt + return bx - ax + }) +} + +/** listMessages returns newest-first; the UI renders oldest-first. */ +function sortMessagesAscending(messages: Message[]): Message[] { + return [...messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +/** + * Pick the latest server-confirmed message id from an ascending list. Pending + * (optimistic) messages use `pending-` ids that the server doesn't + * recognise, so they're skipped — the new mark-read contract requires a real + * message id. + */ +function lastNonPendingMessageId(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (!m) continue + if (m.id.startsWith("pending-")) continue + return m.id } + return null } diff --git a/packages/kilo-vscode/src/kiloclaw/chat-client.ts b/packages/kilo-vscode/src/kiloclaw/chat-client.ts deleted file mode 100644 index b1f15e4108a..00000000000 --- a/packages/kilo-vscode/src/kiloclaw/chat-client.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * KiloClaw Stream Chat client wrapper for the VS Code extension host. - * - * Port of packages/opencode/src/kilocode/claw/client.ts adapted for Node.js. - * No Bun patches needed — the extension host runs in standard Node.js. - * stream-chat resolves to its Node.js CJS entry point automatically. - */ - -import type { Channel, Event } from "stream-chat" -import type { ChatCredentials, ChatMessage } from "./types" - -export type ClawChatClient = { - channel: Channel - disconnect: () => Promise - send: (text: string) => Promise - onMessage: (cb: (msg: ChatMessage) => void) => () => void - onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void - onPresence: (cb: (online: boolean) => void) => () => void -} - -function botId(creds: ChatCredentials): string { - return `bot-${creds.channelId.replace(/^default-/, "")}` -} - -function toMessage(raw: Record, bot: string): ChatMessage { - const user = raw.user as Record | undefined - const uid = (user?.id as string) ?? (raw.user_id as string) ?? "" - return { - id: (raw.id as string) ?? "", - text: (raw.text as string) ?? "", - user: uid, - created: raw.created_at ? new Date(raw.created_at as string).toISOString() : new Date().toISOString(), - bot: uid === bot, - } -} - -export async function connect(creds: ChatCredentials): Promise { - const { StreamChat } = await import("stream-chat") - // Use a fresh instance instead of the singleton to avoid stale state - // (cached channels, event listeners) when credentials rotate. - const client = new StreamChat(creds.apiKey) - - await client.connectUser({ id: creds.userId }, creds.userToken) - - const channel = client.channel("messaging", creds.channelId) - try { - await channel.watch({ presence: true }) - } catch (err) { - // Disconnect the user to avoid leaking a partial connection - await client.disconnectUser().catch(() => {}) - throw err - } - - const bot = botId(creds) - - return { - channel, - async disconnect() { - await client.disconnectUser() - }, - async send(text: string) { - await channel.sendMessage({ text }) - }, - onMessage(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.new", handler) - return () => channel.off("message.new", handler) - }, - onMessageUpdated(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.updated", handler) - return () => channel.off("message.updated", handler) - }, - onPresence(cb) { - const handler = (event: Event) => { - if (event.user?.id === bot) { - cb(event.user.online ?? false) - } - } - client.on("user.presence.changed", handler) - return () => client.off("user.presence.changed", handler) - }, - } -} - -export function history(channel: Channel, bot: string): ChatMessage[] { - const state = channel.state.messages - return state.map((raw) => toMessage(raw as unknown as Record, bot)) -} - -export function presence(channel: Channel, bot: string): boolean { - const member = channel.state.members?.[bot] - return !!member?.user?.online -} diff --git a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts new file mode 100644 index 00000000000..fea08acbc43 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts @@ -0,0 +1,384 @@ +/** + * Event Service WebSocket client for the VS Code extension host. + * + * Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects + * to the kilo events Cloudflare Worker using a two-step ticket flow: + * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a + * single-use ticket (30 s TTL). + * 2. Open WebSocket to `/connect?ticket=` with subprotocol + * `kilo.events.v1`. + * + * Runs in Node.js (the VS Code extension host). `WebSocket` is available in + * Node 22+ without any import, matching the environment used elsewhere in + * this extension (see `src/services/cli-backend/sdk-sse-adapter.ts`). + */ + +import type { KiloChatEventMap, KiloChatEventName } from "./types" + +const WS_SUBPROTOCOL = "kilo.events.v1" +const HANDSHAKE_TIMEOUT_MS = 10_000 +const PING_INTERVAL_MS = 15_000 +const TICKET_FETCH_TIMEOUT_MS = 10_000 + +export class WebSocketAuthError extends Error { + constructor(message = "WebSocket authentication failed") { + super(message) + this.name = "WebSocketAuthError" + } +} + +export class WebSocketConnectError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message) + this.name = "WebSocketConnectError" + } +} + +export class HandshakeTimeoutError extends Error { + constructor() { + super("WebSocket handshake timed out") + this.name = "HandshakeTimeoutError" + } +} + +// Close codes that signal the server rejected us for auth/policy reasons +// and reconnecting with the same token is pointless. Everything else +// (including 1006 "abnormal closure" from flaky networks) is transient. +function isAuthCloseCode(code: number): boolean { + if (code === 1008) return true // Policy Violation + if (code === 4401 || code === 4403) return true // Custom auth rejection + return false +} + +export type EventHandler = (context: string, payload: unknown) => void + +export type EventServiceConfig = { + url: string + getToken: () => Promise + onUnauthorized?: () => void +} + +/** + * The event-service base URL is configured as a WebSocket URL (`wss://…` / + * `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip + * the trailing slash and swap the protocol so `fetch()` accepts the URL. + */ +function toHttpBase(wsBase: string): string { + const trimmed = wsBase.replace(/\/$/, "") + if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) + if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) + return trimmed +} + +export class EventServiceClient { + private readonly url: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + + private ws: WebSocket | null = null + private connected = false + private destroyed = false + private reconnectAttempts = 0 + private hasConnectedBefore = false + private reconnectTimer: ReturnType | null = null + private pingTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + private abortHandshake: ((err: Error) => void) | null = null + + private eventHandlers = new Map>() + private activeContexts = new Set() + private reconnectHandlers = new Set<() => void>() + + constructor(config: EventServiceConfig) { + this.url = config.url + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + async connect(): Promise { + this.destroyed = false + this.reconnectAttempts = 0 + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + try { + await this.connectOnce() + } catch (err) { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + } + } + + disconnect(): void { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.clearHandshakeTimer() + if (this.abortHandshake) { + this.abortHandshake(new Error("disconnected")) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.stopPing() + this.connected = false + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + subscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.add(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.subscribe", contexts }) + } + } + + unsubscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.delete(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.unsubscribe", contexts }) + } + } + + on(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void { + const set = this.eventHandlers.get(event) ?? new Set() + // The raw dispatcher receives `unknown` payloads; the caller supplied a + // typed handler. We trust server payloads here — they're validated at the + // kilo-chat worker edge before broadcast. + const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N]) + set.add(wrapped) + this.eventHandlers.set(event, set) + return () => { + set.delete(wrapped) + if (set.size === 0) this.eventHandlers.delete(event) + } + } + + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler) + return () => this.reconnectHandlers.delete(handler) + } + + // ── private ──────────────────────────────────────────────────────── + + private handleAuthFailure(err: unknown): boolean { + if (err instanceof WebSocketAuthError) { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.onUnauthorized?.() + return true + } + return false + } + + private async connectOnce(): Promise { + if (this.ws) { + const old = this.ws + this.ws = null + old.close() + } + + const token = await this.getToken() + const ticket = await this.fetchTicket(token) + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) + this.ws = ws + + let settled = false + const settleResolve = () => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + resolve() + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + reject(err) + } + this.abortHandshake = settleReject + + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.ws === ws) ws.close(1000, "handshake-timeout") + settleReject(new HandshakeTimeoutError()) + }, HANDSHAKE_TIMEOUT_MS) + + ws.addEventListener("open", () => { + const isReconnect = this.hasConnectedBefore + this.connected = true + this.hasConnectedBefore = true + this.reconnectAttempts = 0 + this.resubscribeContexts() + if (isReconnect) { + for (const h of this.reconnectHandlers) h() + } + settleResolve() + this.startPing() + }) + + ws.addEventListener("message", (event: MessageEvent) => { + this.handleMessage(String(event.data)) + }) + + ws.addEventListener("close", (event: CloseEvent) => { + if (this.ws !== ws) return + const wasConnected = this.connected + this.connected = false + this.stopPing() + this.clearHandshakeTimer() + // A handshake failure always fires `close` after `error`, so we + // settle here with a classification based on the close code: + // explicit auth/policy codes → fatal; anything else → transient + // and the caller (`connect`) will schedule a reconnect. + if (!wasConnected) { + if (isAuthCloseCode(event.code)) { + settleReject(new WebSocketAuthError()) + } else { + settleReject( + new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), + ) + } + return + } + if (!this.destroyed) this.scheduleReconnect() + }) + + ws.addEventListener("error", () => { + // Swallowed: the `close` event fires right after and carries the + // close code we need to distinguish auth failures from network + // blips. Settling here loses that context. + }) + }) + } + + /** + * Mint a single-use connection ticket. The event-service issues a 30 s ticket + * scoped to the bearer JWT; the WebSocket upgrade then consumes it. We + * surface 401/403 as `WebSocketAuthError` so the caller can drop the cached + * token and prompt re-auth. + * + * `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only + * accepts `http(s)`, so we rewrite the protocol before the HTTP call. + */ + private async fetchTicket(token: string): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) + try { + const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal, + }) + if (res.status === 401 || res.status === 403) { + throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) + } + if (!res.ok) { + throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) + } + const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null + if (!body || typeof body.ticket !== "string" || !body.ticket) { + throw new WebSocketConnectError("Malformed event-service ticket response", 0) + } + return body.ticket + } catch (err) { + if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err + if ((err as { name?: string })?.name === "AbortError") { + throw new HandshakeTimeoutError() + } + throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) + } finally { + clearTimeout(timer) + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer !== null) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private sendJson(msg: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)) + } + } + + private handleMessage(data: string): void { + if (data === "pong") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + if (!parsed || typeof parsed !== "object") return + const m = parsed as Record + if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { + const handlers = this.eventHandlers.get(m.event) + if (handlers) { + for (const h of handlers) h(m.context, m.payload) + } + return + } + if (m.type === "error") { + console.warn("[Kilo New] event-service server error", m) + } + } + + private startPing(): void { + this.stopPing() + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send("ping") + } + }, PING_INTERVAL_MS) + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + } + + private resubscribeContexts(): void { + if (this.activeContexts.size > 0) { + this.sendJson({ + type: "context.subscribe", + contexts: Array.from(this.activeContexts), + }) + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) + const delay = base * (0.5 + Math.random() * 0.5) + this.reconnectAttempts++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connectOnce().catch((err) => { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + }) + }, delay) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts new file mode 100644 index 00000000000..5583b32af51 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -0,0 +1,270 @@ +/** + * HTTP client for the kilo-chat Cloudflare Worker. + * + * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) tailored + * to what the VS Code extension needs: conversation list + details, message + * CRUD, reactions, typing, and action execution. No zod runtime validation — + * the kilo-chat worker is the source of truth and validates at its edge. + */ + +import type { + BotStatusRecord, + ContentBlock, + ConversationDetail, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, +} from "./types" + +export type KiloChatClientConfig = { + baseUrl: string + getToken: () => Promise + onUnauthorized?: () => void +} + +export class KiloChatApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) + this.name = "KiloChatApiError" + } +} + +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + // Fall back to a compact JSON dump so validation errors (zod issues, etc.) + // show up in the extension's Output channel without a separate logging hop. + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + +type HttpOpts = { + method?: string + body?: unknown + query?: Record +} + +// Per-conversation send queues. sendMessage chains onto the tail of its +// conversation's queue so concurrent callers can't race ahead and get a lower +// server-assigned ULID than a later send. +type SendQueue = Map> + +export class KiloChatClient { + private readonly baseUrl: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly sendQueues: SendQueue = new Map() + + constructor(config: KiloChatClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + // ── Conversations ──────────────────────────────────────────────── + + listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ + conversations: ConversationListItem[] + hasMore: boolean + nextCursor: string | null + }> { + return this.request("/v1/conversations", { + query: { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor ?? undefined, + }, + }) + } + + getConversation(conversationId: string): Promise { + return this.request(`/v1/conversations/${conversationId}`) + } + + createConversation(req: { + sandboxId: string + title?: string + }): Promise<{ conversationId: string; conversation?: ConversationDetail }> { + return this.request("/v1/conversations", { method: "POST", body: req }) + } + + renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { + return this.request(`/v1/conversations/${conversationId}`, { + method: "PATCH", + body: { title }, + }) + } + + async leaveConversation(conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) + } + + /** + * Mark messages up to `lastSeenMessageId` as read for the current user. + * The server enforces monotonic `lastReadAt` and returns whether the read + * pointer advanced plus whether the badge bucket was cleared. + */ + markConversationRead( + conversationId: string, + req: { lastSeenMessageId: string }, + ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { + return this.request(`/v1/conversations/${conversationId}/mark-read`, { + method: "POST", + body: req, + }) + } + + // ── Messages ───────────────────────────────────────────────────── + + sendMessage(req: { + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + clientId?: string + }): Promise<{ messageId: string; clientId?: string; message?: Message }> { + const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() + const send = () => + this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { + method: "POST", + body: req, + }) + const next = prev.then(send, send) + this.sendQueues.set(req.conversationId, next) + const cleanup = () => { + if (this.sendQueues.get(req.conversationId) === next) { + this.sendQueues.delete(req.conversationId) + } + } + void next.then(cleanup, cleanup) + return next + } + + editMessage( + messageId: string, + req: { conversationId: string; content: ContentBlock[]; timestamp: number }, + ): Promise<{ messageId?: string; message?: Message }> { + return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) + } + + async deleteMessage(messageId: string, conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/messages/${messageId}`, { + method: "DELETE", + query: { conversationId }, + }) + } + + listMessages( + conversationId: string, + opts?: { before?: string; limit?: number }, + ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { + return this.request(`/v1/conversations/${conversationId}/messages`, { + query: { before: opts?.before, limit: opts?.limit }, + }) + } + + executeAction( + conversationId: string, + messageId: string, + req: { groupId: string; value: ExecApprovalDecision }, + ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { + return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { + method: "POST", + body: req, + }) + } + + // ── Reactions ──────────────────────────────────────────────────── + + addReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ id: string; operationId?: string }> { + return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) + } + + async removeReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { + return this.request<{ removed: boolean; id: string | null; operationId?: string }>( + `/v1/messages/${messageId}/reactions`, + { + method: "DELETE", + query: req, + }, + ) + } + + // ── Typing ─────────────────────────────────────────────────────── + + async sendTyping(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) + } + + async sendTypingStop(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) + } + + // ── Bot / conversation status ──────────────────────────────────── + + getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { + return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) + } + + async requestBotStatus(sandboxId: string): Promise { + await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) + } + + getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { + return this.request(`/v1/conversations/${conversationId}/conversation-status`) + } + + // ── private ────────────────────────────────────────────────────── + + private async request(path: string, opts: HttpOpts = {}): Promise { + const token = await this.getToken() + let url = `${this.baseUrl}${path}` + + if (opts.query) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue + params.set(k, String(v)) + } + const qs = params.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { Authorization: `Bearer ${token}` } + if (opts.body !== undefined) headers["Content-Type"] = "application/json" + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }) + + if (!res.ok) { + if (res.status === 401 || res.status === 403) this.onUnauthorized?.() + const body: unknown = await res.json().catch(() => null) + throw new KiloChatApiError(res.status, body) + } + + if (res.status === 204) return undefined as unknown as T + return (await res.json()) as T + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts new file mode 100644 index 00000000000..745b9c7c3f8 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -0,0 +1,112 @@ +/** + * KiloChat access-token cache. + * + * Mirrors the web client pattern (see `apps/web/src/app/(app)/claw/kilo-chat/token.ts` + * in the cloud monorepo). The token is minted by the Kilo gateway + * (`kilo.claw.chatCredentials`) and kept in memory with a 5-minute freshness + * buffer. The gateway operation is a historical name — it used to return + * Stream Chat credentials and was repurposed when Kilo migrated to its own + * kilo-chat service. + * + * Concurrent callers share the same inflight fetch so we never double-issue. + * A short retry cooldown prevents tight loops when the gateway is flaky. + */ + +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { ChatToken } from "./types" + +const FRESHNESS_BUFFER_MS = 5 * 60 * 1000 +const RETRY_BACKOFF_MS = 5_000 + +export class TokenManager { + private cached: ChatToken | null = null + private expiresAtMs = 0 + private inflight: Promise | null = null + private lastFailedAt = 0 + + constructor(private readonly getClient: () => KiloClient | null) {} + + /** Latest resolved token info (may be stale). Used for URL extraction. */ + peek(): ChatToken | null { + return this.cached + } + + /** Drop the cached token; next `get` will refetch. */ + clear(): void { + this.cached = null + this.expiresAtMs = 0 + this.lastFailedAt = 0 + this.inflight = null + } + + async get(): Promise { + const info = await this.getOrFetch() + return info.token + } + + /** Resolve the full token envelope (URLs + token + expiry). */ + async getOrFetch(): Promise { + if (this.cached && Date.now() < this.expiresAtMs - FRESHNESS_BUFFER_MS) { + return this.cached + } + if (this.lastFailedAt && Date.now() - this.lastFailedAt < RETRY_BACKOFF_MS) { + throw new Error("Kilo chat token fetch on cooldown after recent failure") + } + if (!this.inflight) { + this.inflight = this.fetch() + .then((info) => { + this.cached = info + this.expiresAtMs = new Date(info.expiresAt).getTime() + this.lastFailedAt = 0 + this.inflight = null + return info + }) + .catch((err) => { + this.lastFailedAt = Date.now() + this.inflight = null + throw err + }) + } + return this.inflight + } + + private async fetch(): Promise { + const client = this.getClient() + if (!client) throw new Error("Kilo backend not connected") + const res = await client.kilo.claw.chatCredentials() + const errResponse = (res as Record | null)?.error + if (!res || errResponse || !res.data) { + // Propagate the server's error detail when present so the extension's + // Output channel makes it obvious whether this is an auth problem, + // "no active instance" (404), or a transient 5xx. + const detail = this.formatErrorDetail(errResponse) + throw new Error(`kilo-chat credentials fetch failed${detail ? `: ${detail}` : ""}`) + } + const data = res.data as Partial + const missing: string[] = [] + if (!data.token) missing.push("token") + if (!data.expiresAt) missing.push("expiresAt") + if (!data.kiloChatUrl) missing.push("kiloChatUrl") + if (!data.eventServiceUrl) missing.push("eventServiceUrl") + if (missing.length > 0) { + throw new Error( + `Malformed kilo-chat credentials response: missing ${missing.join(", ")} (received keys: ${Object.keys(data).join(", ") || ""})`, + ) + } + return { + token: data.token!, + expiresAt: data.expiresAt!, + kiloChatUrl: data.kiloChatUrl!, + eventServiceUrl: data.eventServiceUrl!, + } + } + + private formatErrorDetail(err: unknown): string { + if (!err) return "" + if (typeof err === "string") return err + if (typeof err === "object" && err && "error" in err && typeof err.error === "string") { + return err.error + } + return JSON.stringify(err) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 51f4e28631f..20b796a67d4 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -3,14 +3,29 @@ * * Defines the postMessage protocol between the extension host (Node.js) * and the KiloClaw webview (SolidJS). The extension host owns all network - * connections (SDK + Stream Chat) and relays data to the webview. + * connections (Kilo Chat HTTP + event-service WebSocket) and relays data + * to the webview. * - * SYNC: Shared types (ClawStatus, ChatMessage, KiloClawState, KiloClawOutMessage) - * are mirrored in webview-ui/kiloclaw/lib/types.ts — keep both in sync. + * SYNC: Shared types are mirrored in webview-ui/kiloclaw/lib/types.ts — + * keep both in sync. */ +// ── Instance status (KiloClaw worker) ─────────────────────────────── + export type ClawStatus = { - status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null + // `recovering` and `restoring` are transitional states the worker reports + // while bringing an instance back from an unexpected stop or a snapshot + // restore (cloud: `services/kiloclaw/src/index.ts`). + status: + | "provisioned" + | "starting" + | "restarting" + | "recovering" + | "running" + | "stopped" + | "destroying" + | "restoring" + | null sandboxId?: string flyRegion?: string machineSize?: { cpus: number; memory_mb: number } @@ -19,23 +34,207 @@ export type ClawStatus = { lastStoppedAt?: string | null channelCount?: number secretCount?: number + userId?: string + botName?: string | null +} + +// ── Kilo Chat token envelope (gateway response) ───────────────────── + +export type ChatToken = { + token: string + expiresAt: string // ISO timestamp + kiloChatUrl: string + eventServiceUrl: string } -export type ChatCredentials = { - apiKey: string - userId: string - userToken: string - channelId: string +// ── Kilo Chat content blocks ──────────────────────────────────────── +// Mirrors `@kilocode/kilo-chat` schemas. See cloud/packages/kilo-chat/src/schemas.ts. + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" + +export type TextBlock = { type: "text"; text: string } + +export type ActionItem = { + label: string + style: "primary" | "danger" | "secondary" + value: ExecApprovalDecision } -export type ChatMessage = { +export type ActionsBlock = { + type: "actions" + groupId: string + actions: ActionItem[] + resolved?: { + value: ExecApprovalDecision + resolvedBy: string + resolvedAt: number + } +} + +export type ContentBlock = TextBlock | ActionsBlock + +// ── Kilo Chat reactions ───────────────────────────────────────────── + +export type ReactionSummary = { + emoji: string + count: number + memberIds: string[] +} + +// ── Kilo Chat message ─────────────────────────────────────────────── + +export type Message = { id: string - text: string - user: string - created: string // ISO string (serializable via postMessage) - bot: boolean + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + updatedAt: number | null + clientUpdatedAt: number | null + deleted: boolean + deliveryFailed: boolean + reactions: ReactionSummary[] +} + +// ── Conversations ─────────────────────────────────────────────────── + +export type ConversationListItem = { + conversationId: string + title: string | null + lastActivityAt: number | null + lastReadAt: number | null + joinedAt: number +} + +export type ConversationMember = { id: string; kind: "user" | "bot" } + +export type ConversationDetail = { + id: string + title: string | null + createdBy: string + createdAt: number + members: ConversationMember[] +} + +// ── Bot / conversation status (telemetry) ─────────────────────────── + +export type BotStatusRecord = { + online: boolean + at: number + updatedAt: number +} + +export type ConversationStatusRecord = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number + updatedAt: number +} + +// ── Typed Kilo Chat events (server → client) ─────────────────────── +// Event names mirror `@kilocode/kilo-chat/events`. + +/** + * Snapshot of the message that was replied to. Server includes this on + * `message.created` so clients can render a reply preview without a follow-up + * fetch. `deleted` mirrors the soft-deletion state at the time of replying. + */ +export type ReplyToSnapshot = { + messageId: string + senderId: string + content: ContentBlock[] + deleted?: boolean +} + +export type MessageCreatedEvent = { + messageId: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + clientId?: string + replyTo?: ReplyToSnapshot | null } +export type MessageUpdatedEvent = { + messageId: string + content: ContentBlock[] + clientUpdatedAt: number | null +} + +export type MessageDeletedEvent = { messageId: string } +export type MessageDeliveryFailedEvent = { messageId: string } + +export type TypingEvent = { memberId: string } +export type TypingStopEvent = { memberId: string } + +export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } +export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } + +/** + * Server fans out the full conversation snapshot on `conversation.created` so + * clients can append to their list without a follow-up fetch. Older servers may + * still send only the `conversationId`, so the snapshot is optional. + */ +export type ConversationCreatedEvent = { + conversationId: string + conversation?: ConversationListItem +} +export type ConversationRenamedEvent = { conversationId: string; title: string } +export type ConversationLeftEvent = { conversationId: string } +export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } +export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } + +export type ActionExecutedEvent = { + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + executedBy: string +} +export type ActionDeliveryFailedEvent = { + conversationId: string + messageId: string + groupId: string +} + +export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } +export type ConversationStatusEvent = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number +} + +export type KiloChatEventMap = { + "message.created": MessageCreatedEvent + "message.updated": MessageUpdatedEvent + "message.deleted": MessageDeletedEvent + "message.delivery_failed": MessageDeliveryFailedEvent + typing: TypingEvent + "typing.stop": TypingStopEvent + "reaction.added": ReactionAddedEvent + "reaction.removed": ReactionRemovedEvent + "conversation.created": ConversationCreatedEvent + "conversation.renamed": ConversationRenamedEvent + "conversation.left": ConversationLeftEvent + "conversation.read": ConversationReadEvent + "conversation.activity": ConversationActivityEvent + "action.executed": ActionExecutedEvent + "action.delivery_failed": ActionDeliveryFailedEvent + "bot.status": BotStatusEvent + "conversation.status": ConversationStatusEvent +} + +export type KiloChatEventName = keyof KiloChatEventMap + +// ── Webview ↔ extension state ─────────────────────────────────────── + +export type TypingMember = { memberId: string; at: number } + // Full state snapshot pushed to the webview // Every phase carries `locale` so the webview can resolve translations immediately. export type KiloClawState = @@ -47,24 +246,65 @@ export type KiloClawState = phase: "ready" locale: string status: ClawStatus | null - connected: boolean - online: boolean - messages: ChatMessage[] + currentUserId: string + sandboxId: string + conversations: ConversationListItem[] + hasMoreConversations: boolean + activeConversationId: string | null + messages: Message[] + hasMoreMessages: boolean + botStatus: BotStatusRecord | null + conversationStatus: ConversationStatusRecord | null + typingMembers: TypingMember[] } -// Messages: Webview → Extension Host +// ── Messages: Webview → Extension Host ────────────────────────────── + export type KiloClawInMessage = | { type: "kiloclaw.ready" } - | { type: "kiloclaw.send"; text: string } | { type: "kiloclaw.openExternal"; url: string } + | { type: "kiloclaw.selectConversation"; conversationId: string } + | { type: "kiloclaw.createConversation"; title?: string } + | { type: "kiloclaw.renameConversation"; conversationId: string; title: string } + | { type: "kiloclaw.leaveConversation"; conversationId: string } + | { type: "kiloclaw.loadMoreConversations" } + | { + type: "kiloclaw.sendMessage" + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + } + | { type: "kiloclaw.editMessage"; conversationId: string; messageId: string; content: ContentBlock[] } + | { type: "kiloclaw.deleteMessage"; conversationId: string; messageId: string } + | { type: "kiloclaw.loadMoreMessages"; conversationId: string; before: string } + | { type: "kiloclaw.addReaction"; conversationId: string; messageId: string; emoji: string } + | { type: "kiloclaw.removeReaction"; conversationId: string; messageId: string; emoji: string } + | { + type: "kiloclaw.executeAction" + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + } + | { type: "kiloclaw.sendTyping"; conversationId: string } + | { type: "kiloclaw.sendTypingStop"; conversationId: string } + | { type: "kiloclaw.markRead"; conversationId: string } + +// ── Messages: Extension Host → Webview ────────────────────────────── -// Messages: Extension Host → Webview export type KiloClawOutMessage = | { type: "kiloclaw.state"; state: KiloClawState } - | { type: "kiloclaw.message"; message: ChatMessage } - | { type: "kiloclaw.messageUpdated"; message: ChatMessage } - | { type: "kiloclaw.presence"; online: boolean } | { type: "kiloclaw.status"; data: ClawStatus | null } | { type: "kiloclaw.locale"; locale: string } | { type: "kiloclaw.error"; error: string } + | { type: "kiloclaw.conversations"; conversations: ConversationListItem[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.activeConversation"; conversationId: string | null } + | { type: "kiloclaw.messages"; conversationId: string; messages: Message[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.messageOptimistic"; conversationId: string; message: Message } + | { type: "kiloclaw.messageReplaced"; conversationId: string; pendingId: string; message: Message } + | { type: "kiloclaw.messageRemoved"; conversationId: string; messageId: string } + | { type: "kiloclaw.botStatus"; status: BotStatusRecord | null } + | { type: "kiloclaw.conversationStatus"; status: ConversationStatusRecord | null } + | { type: "kiloclaw.typing"; conversationId: string; memberId: string } + | { type: "kiloclaw.typingStop"; conversationId: string; memberId: string } | { type: "fontSizeChanged"; fontSize: number } diff --git a/packages/kilo-vscode/src/kiloclaw/ulid.ts b/packages/kilo-vscode/src/kiloclaw/ulid.ts new file mode 100644 index 00000000000..a55bda569d3 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/ulid.ts @@ -0,0 +1,41 @@ +/** + * Minimal ULID generator for the extension host. + * + * Produces a 26-character Crockford Base32 identifier: 10 time chars + * followed by 16 random chars. The kilo-chat worker validates clientId + * as a ULID, so `generateClientId` must emit only Crockford-legal + * characters — `toString(36)` is NOT safe because base36 includes + * I, L, O, and U which are excluded from Crockford Base32. + */ + +// Crockford Base32 — no I, L, O, U (reduces transcription ambiguity). +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const ENCODING_LEN = ENCODING.length +const TIME_LEN = 10 +const RANDOM_LEN = 16 + +function encodeTime(ts: number): string { + let out = "" + let n = ts + for (let i = 0; i < TIME_LEN; i++) { + const mod = n % ENCODING_LEN + out = ENCODING[mod] + out + n = (n - mod) / ENCODING_LEN + } + return out +} + +function encodeRandom(): string { + const bytes = new Uint8Array(RANDOM_LEN) + globalThis.crypto.getRandomValues(bytes) + let out = "" + for (let i = 0; i < RANDOM_LEN; i++) { + out += ENCODING[bytes[i]! % ENCODING_LEN] + } + return out +} + +/** Generate a ULID at the current epoch. */ +export function ulid(): string { + return encodeTime(Date.now()) + encodeRandom() +} diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx index 161ed4ebff3..aa4cff2f128 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx @@ -8,7 +8,8 @@ import { Spinner } from "@kilocode/kilo-ui/spinner" import { Toast } from "@kilocode/kilo-ui/toast" import { ClawProvider, useClaw } from "./context/claw" import { KiloClawLanguageProvider, useKiloClawLanguage } from "./context/language" -import { ChatPanel } from "./components/ChatPanel" +import { ConversationList } from "./components/ConversationList" +import { MessageArea } from "./components/MessageArea" import { StatusSidebar } from "./components/StatusSidebar" import { SetupView } from "./components/SetupView" import { UpgradeView } from "./components/UpgradeView" @@ -46,7 +47,8 @@ function Content() {
- + +
diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx deleted file mode 100644 index 42b52c085f6..00000000000 --- a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -// KiloClaw chat panel — message list + input - -import { createSignal, createEffect, For, Show, createMemo, onMount } from "solid-js" -import { Button } from "@kilocode/kilo-ui/button" -import { useClaw } from "../context/claw" -import { useKiloClawLanguage } from "../context/language" -import { MessageBubble } from "./MessageBubble" - -export function ChatPanel() { - const claw = useClaw() - const { t } = useKiloClawLanguage() - const [text, setText] = createSignal("") - let list!: HTMLDivElement - let input!: HTMLTextAreaElement - - const disabled = createMemo(() => { - const s = claw.status() - return !s || s.status !== "running" || !claw.connected() - }) - - const placeholder = createMemo(() => { - if (!claw.connected()) return t("kiloClaw.chat.connecting") - const s = claw.status() - if (!s || s.status !== "running") return t("kiloClaw.chat.notRunning") - return t("kiloClaw.chat.placeholder") - }) - - // Auto-scroll to bottom when messages change - createEffect(() => { - claw.messages() - if (list) { - requestAnimationFrame(() => { - list.scrollTop = list.scrollHeight - }) - } - }) - - // Focus input on mount - onMount(() => { - if (input && !disabled()) input.focus() - }) - - const submit = () => { - const val = text().trim() - if (!val || disabled()) return - claw.send(val) - setText("") - if (input) { - input.style.height = "auto" - } - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - submit() - } - } - - const onInput = (e: InputEvent) => { - const target = e.target as HTMLTextAreaElement - setText(target.value) - // Auto-resize - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 120) + "px" - } - - return ( -
- {/* Header */} -
-
- - - KiloClaw {claw.online() ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")} - -
-
- - {/* Messages */} -
- -
{t("kiloClaw.chat.empty")}
-
- {(msg) => } -
- - {/* Input */} -
-