Skip to content

Commit 7bb9914

Browse files
committed
refactor: update implementation
1 parent 622e126 commit 7bb9914

13 files changed

Lines changed: 393 additions & 71 deletions

File tree

packages/kilo-gateway/src/server/routes.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,24 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
530530
"application/json": {
531531
schema: resolver(
532532
z.object({
533+
// `recovering` and `restoring` are transitional states the
534+
// worker reports while it brings an instance back online
535+
// after an unexpected stop or a snapshot restore — see
536+
// cloud `services/kiloclaw/src/index.ts` and the
537+
// `PlatformStatusResponse` type in
538+
// cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in
539+
// the enum so the SDK types stay accurate.
533540
status: z
534-
.enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"])
541+
.enum([
542+
"provisioned",
543+
"starting",
544+
"restarting",
545+
"recovering",
546+
"running",
547+
"stopped",
548+
"destroying",
549+
"restoring",
550+
])
535551
.nullable(),
536552
sandboxId: z.string().optional(),
537553
flyRegion: z.string().optional(),

packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,15 @@ export class KiloClawProvider implements vscode.Disposable {
523523
this.chatSubs.push(
524524
events.on("conversation.created", (ctx, e: ConversationCreatedEvent) => {
525525
if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return
526-
// Refetch so we capture title/joinedAt/etc.
526+
// Newer servers include the full conversation snapshot — splice it in
527+
// immediately so the list updates without a roundtrip. Fall back to a
528+
// refetch when the snapshot is absent (older servers / safety net).
529+
if (e.conversation) {
530+
this.conversations = mergeConversations([e.conversation], this.conversations)
531+
this.broadcastConversations({ replace: true })
532+
return
533+
}
527534
void this.refreshConversations()
528-
void e
529535
}),
530536
)
531537

@@ -754,6 +760,9 @@ export class KiloClawProvider implements vscode.Disposable {
754760
private async createConversation(title?: string): Promise<void> {
755761
if (!this.chat || !this.sandboxId) return
756762
try {
763+
// The server now returns the full conversation snapshot alongside
764+
// `conversationId`. We rely on `refreshConversations()` to pick up the
765+
// canonical list-item shape rather than mapping the detail payload here.
757766
const res = await this.chat.createConversation({ sandboxId: this.sandboxId, title })
758767
await this.refreshConversations()
759768
await this.selectConversation(res.conversationId)
@@ -904,7 +913,7 @@ export class KiloClawProvider implements vscode.Disposable {
904913
if (this.activeConversationId !== conversationId) return
905914
const sorted = sortMessagesAscending(res.messages)
906915
this.messages = mergeMessages(sorted, this.messages)
907-
this.hasMoreMessages = res.messages.length >= MESSAGES_PAGE
916+
this.hasMoreMessages = res.hasMore
908917
this.broadcastMessages({ replace: true })
909918
} catch (err) {
910919
this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load messages") })
@@ -1005,8 +1014,13 @@ export class KiloClawProvider implements vscode.Disposable {
10051014

10061015
private async markRead(conversationId: string): Promise<void> {
10071016
if (!this.chat) return
1017+
if (conversationId !== this.activeConversationId) return
1018+
// The mark-read endpoint requires `lastSeenMessageId`. With no messages
1019+
// loaded there is nothing to mark — silently skip.
1020+
const last = lastNonPendingMessageId(this.messages)
1021+
if (!last) return
10081022
try {
1009-
await this.chat.markConversationRead(conversationId)
1023+
await this.chat.markConversationRead(conversationId, { lastSeenMessageId: last })
10101024
} catch (err) {
10111025
void err
10121026
}
@@ -1043,7 +1057,7 @@ export class KiloClawProvider implements vscode.Disposable {
10431057
// only apply the messages if the active conversation is still `target`.
10441058
if (this.activeConversationId !== target) return
10451059
this.messages = sortMessagesAscending(res.messages)
1046-
this.hasMoreMessages = res.messages.length >= MESSAGES_PAGE
1060+
this.hasMoreMessages = res.hasMore
10471061
this.broadcastMessages({ replace: true })
10481062
} catch (err) {
10491063
const message = err instanceof Error ? err.message : String(err)
@@ -1263,3 +1277,19 @@ function mergeConversations(
12631277
function sortMessagesAscending(messages: Message[]): Message[] {
12641278
return [...messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
12651279
}
1280+
1281+
/**
1282+
* Pick the latest server-confirmed message id from an ascending list. Pending
1283+
* (optimistic) messages use `pending-<clientId>` ids that the server doesn't
1284+
* recognise, so they're skipped — the new mark-read contract requires a real
1285+
* message id.
1286+
*/
1287+
function lastNonPendingMessageId(messages: Message[]): string | null {
1288+
for (let i = messages.length - 1; i >= 0; i--) {
1289+
const m = messages[i]
1290+
if (!m) continue
1291+
if (m.id.startsWith("pending-")) continue
1292+
return m.id
1293+
}
1294+
return null
1295+
}

packages/kilo-vscode/src/kiloclaw/event-service-client.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* Event Service WebSocket client for the VS Code extension host.
33
*
44
* Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects
5-
* to the kilo events Cloudflare Worker over WebSocket, authenticates with a
6-
* JWT carried in the `kilo.jwt.<base64url>` subprotocol header, subscribes to
7-
* per-conversation contexts, and dispatches server events to typed handlers.
5+
* to the kilo events Cloudflare Worker using a two-step ticket flow:
6+
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
7+
* single-use ticket (30 s TTL).
8+
* 2. Open WebSocket to `/connect?ticket=<ticket>` with subprotocol
9+
* `kilo.events.v1`.
810
*
911
* Runs in Node.js (the VS Code extension host). `WebSocket` is available in
1012
* Node 22+ without any import, matching the environment used elsewhere in
@@ -13,9 +15,10 @@
1315

1416
import type { KiloChatEventMap, KiloChatEventName } from "./types"
1517

16-
const SUBPROTOCOL_PREFIX = "kilo.jwt."
18+
const WS_SUBPROTOCOL = "kilo.events.v1"
1719
const HANDSHAKE_TIMEOUT_MS = 10_000
1820
const PING_INTERVAL_MS = 15_000
21+
const TICKET_FETCH_TIMEOUT_MS = 10_000
1922

2023
export class WebSocketAuthError extends Error {
2124
constructor(message = "WebSocket authentication failed") {
@@ -58,10 +61,16 @@ export type EventServiceConfig = {
5861
onUnauthorized?: () => void
5962
}
6063

61-
function encodeBase64Url(input: string): string {
62-
// In the Node extension host btoa is available and JWTs are ASCII.
63-
const b64 = typeof btoa === "function" ? btoa(input) : Buffer.from(input, "utf8").toString("base64")
64-
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
64+
/**
65+
* The event-service base URL is configured as a WebSocket URL (`wss://…` /
66+
* `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip
67+
* the trailing slash and swap the protocol so `fetch()` accepts the URL.
68+
*/
69+
function toHttpBase(wsBase: string): string {
70+
const trimmed = wsBase.replace(/\/$/, "")
71+
if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6)
72+
if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5)
73+
return trimmed
6574
}
6675

6776
export class EventServiceClient {
@@ -182,10 +191,10 @@ export class EventServiceClient {
182191
}
183192

184193
const token = await this.getToken()
185-
const subprotocol = `${SUBPROTOCOL_PREFIX}${encodeBase64Url(token)}`
194+
const ticket = await this.fetchTicket(token)
186195

187196
return new Promise<void>((resolve, reject) => {
188-
const ws = new WebSocket(`${this.url}/connect`, [subprotocol])
197+
const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
189198
this.ws = ws
190199

191200
let settled = false
@@ -259,6 +268,46 @@ export class EventServiceClient {
259268
})
260269
}
261270

271+
/**
272+
* Mint a single-use connection ticket. The event-service issues a 30 s ticket
273+
* scoped to the bearer JWT; the WebSocket upgrade then consumes it. We
274+
* surface 401/403 as `WebSocketAuthError` so the caller can drop the cached
275+
* token and prompt re-auth.
276+
*
277+
* `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only
278+
* accepts `http(s)`, so we rewrite the protocol before the HTTP call.
279+
*/
280+
private async fetchTicket(token: string): Promise<string> {
281+
const ctrl = new AbortController()
282+
const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS)
283+
try {
284+
const res = await fetch(toHttpBase(this.url) + "/connect-ticket", {
285+
method: "POST",
286+
headers: { Authorization: `Bearer ${token}` },
287+
signal: ctrl.signal,
288+
})
289+
if (res.status === 401 || res.status === 403) {
290+
throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`)
291+
}
292+
if (!res.ok) {
293+
throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status)
294+
}
295+
const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null
296+
if (!body || typeof body.ticket !== "string" || !body.ticket) {
297+
throw new WebSocketConnectError("Malformed event-service ticket response", 0)
298+
}
299+
return body.ticket
300+
} catch (err) {
301+
if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err
302+
if ((err as { name?: string })?.name === "AbortError") {
303+
throw new HandshakeTimeoutError()
304+
}
305+
throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0)
306+
} finally {
307+
clearTimeout(timer)
308+
}
309+
}
310+
262311
private clearHandshakeTimer(): void {
263312
if (this.handshakeTimer !== null) {
264313
clearTimeout(this.handshakeTimer)

packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ export class KiloChatClient {
9393
return this.request(`/v1/conversations/${conversationId}`)
9494
}
9595

96-
createConversation(req: { sandboxId: string; title?: string }): Promise<{ conversationId: string }> {
96+
createConversation(req: {
97+
sandboxId: string
98+
title?: string
99+
}): Promise<{ conversationId: string; conversation?: ConversationDetail }> {
97100
return this.request("/v1/conversations", { method: "POST", body: req })
98101
}
99102

@@ -105,11 +108,23 @@ export class KiloChatClient {
105108
}
106109

107110
async leaveConversation(conversationId: string): Promise<void> {
111+
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
108112
await this.request<unknown>(`/v1/conversations/${conversationId}/leave`, { method: "POST" })
109113
}
110114

111-
async markConversationRead(conversationId: string): Promise<void> {
112-
await this.request<unknown>(`/v1/conversations/${conversationId}/mark-read`, { method: "POST" })
115+
/**
116+
* Mark messages up to `lastSeenMessageId` as read for the current user.
117+
* The server enforces monotonic `lastReadAt` and returns whether the read
118+
* pointer advanced plus whether the badge bucket was cleared.
119+
*/
120+
markConversationRead(
121+
conversationId: string,
122+
req: { lastSeenMessageId: string },
123+
): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> {
124+
return this.request(`/v1/conversations/${conversationId}/mark-read`, {
125+
method: "POST",
126+
body: req,
127+
})
113128
}
114129

115130
// ── Messages ─────────────────────────────────────────────────────
@@ -119,10 +134,10 @@ export class KiloChatClient {
119134
content: ContentBlock[]
120135
inReplyToMessageId?: string
121136
clientId?: string
122-
}): Promise<{ messageId: string; clientId?: string }> {
137+
}): Promise<{ messageId: string; clientId?: string; message?: Message }> {
123138
const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve()
124139
const send = () =>
125-
this.request<{ messageId: string; clientId?: string }>("/v1/messages", {
140+
this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", {
126141
method: "POST",
127142
body: req,
128143
})
@@ -140,18 +155,22 @@ export class KiloChatClient {
140155
editMessage(
141156
messageId: string,
142157
req: { conversationId: string; content: ContentBlock[]; timestamp: number },
143-
): Promise<{ messageId?: string }> {
158+
): Promise<{ messageId?: string; message?: Message }> {
144159
return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req })
145160
}
146161

147162
async deleteMessage(messageId: string, conversationId: string): Promise<void> {
163+
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
148164
await this.request<unknown>(`/v1/messages/${messageId}`, {
149165
method: "DELETE",
150166
query: { conversationId },
151167
})
152168
}
153169

154-
listMessages(conversationId: string, opts?: { before?: string; limit?: number }): Promise<{ messages: Message[] }> {
170+
listMessages(
171+
conversationId: string,
172+
opts?: { before?: string; limit?: number },
173+
): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> {
155174
return this.request(`/v1/conversations/${conversationId}/messages`, {
156175
query: { before: opts?.before, limit: opts?.limit },
157176
})
@@ -161,7 +180,7 @@ export class KiloChatClient {
161180
conversationId: string,
162181
messageId: string,
163182
req: { groupId: string; value: ExecApprovalDecision },
164-
): Promise<{ ok: true }> {
183+
): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> {
165184
return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, {
166185
method: "POST",
167186
body: req,
@@ -170,15 +189,24 @@ export class KiloChatClient {
170189

171190
// ── Reactions ────────────────────────────────────────────────────
172191

173-
addReaction(messageId: string, req: { conversationId: string; emoji: string }): Promise<{ id: string }> {
192+
addReaction(
193+
messageId: string,
194+
req: { conversationId: string; emoji: string },
195+
): Promise<{ id: string; operationId?: string }> {
174196
return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req })
175197
}
176198

177-
async removeReaction(messageId: string, req: { conversationId: string; emoji: string }): Promise<void> {
178-
await this.request<unknown>(`/v1/messages/${messageId}/reactions`, {
179-
method: "DELETE",
180-
query: req,
181-
})
199+
async removeReaction(
200+
messageId: string,
201+
req: { conversationId: string; emoji: string },
202+
): Promise<{ removed: boolean; id: string | null; operationId?: string }> {
203+
return this.request<{ removed: boolean; id: string | null; operationId?: string }>(
204+
`/v1/messages/${messageId}/reactions`,
205+
{
206+
method: "DELETE",
207+
query: req,
208+
},
209+
)
182210
}
183211

184212
// ── Typing ───────────────────────────────────────────────────────

packages/kilo-vscode/src/kiloclaw/types.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,19 @@
1313
// ── Instance status (KiloClaw worker) ───────────────────────────────
1414

1515
export type ClawStatus = {
16-
status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
16+
// `recovering` and `restoring` are transitional states the worker reports
17+
// while bringing an instance back from an unexpected stop or a snapshot
18+
// restore (cloud: `services/kiloclaw/src/index.ts`).
19+
status:
20+
| "provisioned"
21+
| "starting"
22+
| "restarting"
23+
| "recovering"
24+
| "running"
25+
| "stopped"
26+
| "destroying"
27+
| "restoring"
28+
| null
1729
sandboxId?: string
1830
flyRegion?: string
1931
machineSize?: { cpus: number; memory_mb: number }
@@ -124,12 +136,25 @@ export type ConversationStatusRecord = {
124136
// ── Typed Kilo Chat events (server → client) ───────────────────────
125137
// Event names mirror `@kilocode/kilo-chat/events`.
126138

139+
/**
140+
* Snapshot of the message that was replied to. Server includes this on
141+
* `message.created` so clients can render a reply preview without a follow-up
142+
* fetch. `deleted` mirrors the soft-deletion state at the time of replying.
143+
*/
144+
export type ReplyToSnapshot = {
145+
messageId: string
146+
senderId: string
147+
content: ContentBlock[]
148+
deleted?: boolean
149+
}
150+
127151
export type MessageCreatedEvent = {
128152
messageId: string
129153
senderId: string
130154
content: ContentBlock[]
131155
inReplyToMessageId: string | null
132156
clientId?: string
157+
replyTo?: ReplyToSnapshot | null
133158
}
134159

135160
export type MessageUpdatedEvent = {
@@ -144,10 +169,18 @@ export type MessageDeliveryFailedEvent = { messageId: string }
144169
export type TypingEvent = { memberId: string }
145170
export type TypingStopEvent = { memberId: string }
146171

147-
export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string }
148-
export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string }
172+
export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
173+
export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
149174

150-
export type ConversationCreatedEvent = { conversationId: string }
175+
/**
176+
* Server fans out the full conversation snapshot on `conversation.created` so
177+
* clients can append to their list without a follow-up fetch. Older servers may
178+
* still send only the `conversationId`, so the snapshot is optional.
179+
*/
180+
export type ConversationCreatedEvent = {
181+
conversationId: string
182+
conversation?: ConversationListItem
183+
}
151184
export type ConversationRenamedEvent = { conversationId: string; title: string }
152185
export type ConversationLeftEvent = { conversationId: string }
153186
export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number }

0 commit comments

Comments
 (0)