From c264515773376d0ce4e0f179aafbdf29e2d50734 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 14:51:16 +0200 Subject: [PATCH 01/29] feat(vscode): filter marketplace by workspace relevance --- .changeset/filter-relevant-marketplace.md | 5 ++ packages/kilo-i18n/src/ar.ts | 2 + packages/kilo-i18n/src/br.ts | 2 + packages/kilo-i18n/src/bs.ts | 2 + packages/kilo-i18n/src/da.ts | 2 + packages/kilo-i18n/src/de.ts | 2 + packages/kilo-i18n/src/en.ts | 2 + packages/kilo-i18n/src/es.ts | 2 + packages/kilo-i18n/src/fr.ts | 2 + packages/kilo-i18n/src/it.ts | 2 + packages/kilo-i18n/src/ja.ts | 2 + packages/kilo-i18n/src/ko.ts | 2 + packages/kilo-i18n/src/nl.ts | 2 + packages/kilo-i18n/src/no.ts | 2 + packages/kilo-i18n/src/pl.ts | 2 + packages/kilo-i18n/src/ru.ts | 2 + packages/kilo-i18n/src/th.ts | 2 + packages/kilo-i18n/src/tr.ts | 2 + packages/kilo-i18n/src/uk.ts | 2 + packages/kilo-i18n/src/zh.ts | 2 + packages/kilo-i18n/src/zht.ts | 2 + .../src/MarketplacePanelProvider.ts | 7 ++ .../src/services/marketplace/api.ts | 1 + .../src/services/marketplace/index.ts | 26 ++++++- .../src/services/marketplace/relevance.ts | 63 ++++++++++++++++ .../src/services/marketplace/types.ts | 15 ++++ .../tests/unit/marketplace-actions.test.ts | 11 +++ .../tests/unit/marketplace-relevance.test.ts | 75 +++++++++++++++++++ .../marketplace/MarketplaceListView.tsx | 35 +++++++-- .../marketplace/MarketplaceView.tsx | 11 ++- .../components/marketplace/marketplace.css | 5 ++ .../src/components/marketplace/utils.ts | 9 ++- .../src/stories/marketplace.stories.tsx | 32 ++++++++ .../webview-ui/src/types/marketplace.ts | 13 ++++ .../src/types/messages/extension-messages.ts | 3 +- 35 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 .changeset/filter-relevant-marketplace.md create mode 100644 packages/kilo-vscode/src/services/marketplace/relevance.ts create mode 100644 packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts diff --git a/.changeset/filter-relevant-marketplace.md b/.changeset/filter-relevant-marketplace.md new file mode 100644 index 00000000000..65d1fb644b8 --- /dev/null +++ b/.changeset/filter-relevant-marketplace.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Filter marketplace items to those relevant to the current workspace's files and installed VS Code extensions. diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 6a90119a1eb..d9e3bcfa0b4 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "بحث...", "marketplace.filter.all": "جميع العناصر", "marketplace.filter.notInstalled": "غير مثبت", + "marketplace.filter.relevant": "ذات صلة بمساحة العمل الخاصة بي", "marketplace.empty": "لم يتم العثور على عناصر", + "marketplace.empty.relevant": "لم يتم العثور على عناصر سوق ذات صلة بمساحة العمل هذه.", "marketplace.badge.mcpServer": "خادم MCP", "marketplace.badge.mode": "وضع", "marketplace.card.by": "بواسطة {{author}}", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 916f93b0dbe..6f388cba3aa 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Pesquisar...", "marketplace.filter.all": "Todos os Itens", "marketplace.filter.notInstalled": "Não Instalado", + "marketplace.filter.relevant": "Relevantes para meu espaço de trabalho", "marketplace.empty": "Nenhum item encontrado", + "marketplace.empty.relevant": "Nenhum item relevante do marketplace foi encontrado para este espaço de trabalho.", "marketplace.badge.mcpServer": "Servidor MCP", "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 6cbbb36e1d3..174956f59ee 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -64,7 +64,9 @@ export const dict = { "marketplace.search": "Pretraži...", "marketplace.filter.all": "Sve stavke", "marketplace.filter.notInstalled": "Nije instalirano", + "marketplace.filter.relevant": "Relevantno za moj radni prostor", "marketplace.empty": "Nema pronađenih stavki", + "marketplace.empty.relevant": "Nisu pronađene relevantne stavke marketplacea za ovaj radni prostor.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Režim", "marketplace.card.by": "od {{author}}", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index c4a176c2b3d..1c76d9c2df2 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Søg...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installeret", + "marketplace.filter.relevant": "Relevante for mit arbejdsområde", "marketplace.empty": "Ingen elementer fundet", + "marketplace.empty.relevant": "Der blev ikke fundet relevante marketplace-elementer for dette arbejdsområde.", "marketplace.badge.mcpServer": "MCP-server", "marketplace.badge.mode": "Tilstand", "marketplace.card.by": "af {{author}}", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index aa77ae3035b..0a127f11d15 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Suchen...", "marketplace.filter.all": "Alle Elemente", "marketplace.filter.notInstalled": "Nicht installiert", + "marketplace.filter.relevant": "Relevant für meinen Arbeitsbereich", "marketplace.empty": "Keine Elemente gefunden", + "marketplace.empty.relevant": "Keine relevanten Marketplace-Elemente für diesen Arbeitsbereich gefunden.", "marketplace.badge.mcpServer": "MCP-Server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "von {{author}}", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 459ec98a10d..312c76d545d 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -61,7 +61,9 @@ export const dict = { "marketplace.search": "Search...", "marketplace.filter.all": "All Items", "marketplace.filter.notInstalled": "Not Installed", + "marketplace.filter.relevant": "Relevant to my workspace", "marketplace.empty": "No items found", + "marketplace.empty.relevant": "No relevant marketplace items found for this workspace.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Mode", "marketplace.card.by": "by {{author}}", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 8f572f79637..f777cd9b4a8 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Buscar...", "marketplace.filter.all": "Todos los elementos", "marketplace.filter.notInstalled": "No instalado", + "marketplace.filter.relevant": "Relevantes para mi espacio de trabajo", "marketplace.empty": "No se encontraron elementos", + "marketplace.empty.relevant": "No se encontraron elementos relevantes del marketplace para este espacio de trabajo.", "marketplace.badge.mcpServer": "Servidor MCP", "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 9d23df435d9..715ff06bfc1 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Rechercher...", "marketplace.filter.all": "Tous les éléments", "marketplace.filter.notInstalled": "Non installé", + "marketplace.filter.relevant": "Pertinents pour mon espace de travail", "marketplace.empty": "Aucun élément trouvé", + "marketplace.empty.relevant": "Aucun élément pertinent du marketplace trouvé pour cet espace de travail.", "marketplace.badge.mcpServer": "Serveur MCP", "marketplace.badge.mode": "Mode", "marketplace.card.by": "par {{author}}", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index adff571865d..757c4a5caac 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -60,7 +60,9 @@ export const dict = { "marketplace.search": "Cerca...", "marketplace.filter.all": "Tutti gli elementi", "marketplace.filter.notInstalled": "Non installati", + "marketplace.filter.relevant": "Rilevanti per il mio spazio di lavoro", "marketplace.empty": "Nessun elemento trovato", + "marketplace.empty.relevant": "Nessun elemento rilevante del marketplace trovato per questo spazio di lavoro.", "marketplace.badge.mcpServer": "Server MCP", "marketplace.badge.mode": "Modalità", "marketplace.card.by": "di {{author}}", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 25e4263ef3b..6b6ecc44edd 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -57,7 +57,9 @@ export const dict = { "marketplace.search": "検索...", "marketplace.filter.all": "すべてのアイテム", "marketplace.filter.notInstalled": "未インストール", + "marketplace.filter.relevant": "自分のワークスペースに関連", "marketplace.empty": "アイテムが見つかりません", + "marketplace.empty.relevant": "このワークスペースに関連するマーケットプレイスのアイテムが見つかりませんでした。", "marketplace.badge.mcpServer": "MCPサーバー", "marketplace.badge.mode": "モード", "marketplace.card.by": "作成者: {{author}}", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 52c674ceb57..e905fbdfbbe 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -57,7 +57,9 @@ export const dict = { "marketplace.search": "검색...", "marketplace.filter.all": "모든 항목", "marketplace.filter.notInstalled": "설치되지 않음", + "marketplace.filter.relevant": "내 작업 영역과 관련됨", "marketplace.empty": "항목을 찾을 수 없음", + "marketplace.empty.relevant": "이 작업 영역과 관련된 마켓플레이스 항목을 찾을 수 없습니다.", "marketplace.badge.mcpServer": "MCP 서버", "marketplace.badge.mode": "모드", "marketplace.card.by": "제작: {{author}}", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index cb602379f6e..4ad67883c99 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -61,7 +61,9 @@ export const dict = { "marketplace.search": "Zoeken...", "marketplace.filter.all": "Alle items", "marketplace.filter.notInstalled": "Niet geïnstalleerd", + "marketplace.filter.relevant": "Relevant voor mijn werkruimte", "marketplace.empty": "Geen items gevonden", + "marketplace.empty.relevant": "Geen relevante marketplace-items gevonden voor deze werkruimte.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "door {{author}}", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 7a14230d3ba..d6d2d33d465 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Søk...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installert", + "marketplace.filter.relevant": "Relevant for arbeidsområdet mitt", "marketplace.empty": "Ingen elementer funnet", + "marketplace.empty.relevant": "Ingen relevante marketplace-elementer funnet for dette arbeidsområdet.", "marketplace.badge.mcpServer": "MCP-server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "av {{author}}", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 90244403794..6a9a617cbe8 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Szukaj...", "marketplace.filter.all": "Wszystkie elementy", "marketplace.filter.notInstalled": "Nie zainstalowano", + "marketplace.filter.relevant": "Istotne dla mojego obszaru roboczego", "marketplace.empty": "Nie znaleziono elementów", + "marketplace.empty.relevant": "Nie znaleziono elementów marketplace istotnych dla tego obszaru roboczego.", "marketplace.badge.mcpServer": "Serwer MCP", "marketplace.badge.mode": "Tryb", "marketplace.card.by": "autor: {{author}}", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 53ed2cd440e..04e3fc2fb57 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Поиск...", "marketplace.filter.all": "Все элементы", "marketplace.filter.notInstalled": "Не установлено", + "marketplace.filter.relevant": "Подходящие для моего рабочего пространства", "marketplace.empty": "Элементы не найдены", + "marketplace.empty.relevant": "Для этого рабочего пространства не найдено подходящих элементов маркетплейса.", "marketplace.badge.mcpServer": "MCP-сервер", "marketplace.badge.mode": "Режим", "marketplace.card.by": "от {{author}}", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 48448e548ec..130ec6ba84d 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "ค้นหา...", "marketplace.filter.all": "รายการทั้งหมด", "marketplace.filter.notInstalled": "ยังไม่ได้ติดตั้ง", + "marketplace.filter.relevant": "เกี่ยวข้องกับพื้นที่ทำงานของฉัน", "marketplace.empty": "ไม่พบรายการ", + "marketplace.empty.relevant": "ไม่พบรายการในมาร์เก็ตเพลสที่เกี่ยวข้องกับพื้นที่ทำงานนี้", "marketplace.badge.mcpServer": "เซิร์ฟเวอร์ MCP", "marketplace.badge.mode": "โหมด", "marketplace.card.by": "โดย {{author}}", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 5714c919c7b..fc87ffcc3d8 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Ara...", "marketplace.filter.all": "Tüm Öğeler", "marketplace.filter.notInstalled": "Yüklü Değil", + "marketplace.filter.relevant": "Çalışma alanımla ilgili", "marketplace.empty": "Öğe bulunamadı", + "marketplace.empty.relevant": "Bu çalışma alanıyla ilgili marketplace öğesi bulunamadı.", "marketplace.badge.mcpServer": "MCP Sunucusu", "marketplace.badge.mode": "Mod", "marketplace.card.by": "{{author}} tarafından", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 323bd314feb..05c759a9b46 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Пошук...", "marketplace.filter.all": "Усі елементи", "marketplace.filter.notInstalled": "Не встановлено", + "marketplace.filter.relevant": "Відповідні моєму робочому простору", "marketplace.empty": "Елементів не знайдено", + "marketplace.empty.relevant": "Для цього робочого простору не знайдено відповідних елементів маркетплейсу.", "marketplace.badge.mcpServer": "MCP-сервер", "marketplace.badge.mode": "Режим", "marketplace.card.by": "від {{author}}", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index ec0da59e6ed..25fb70a41ae 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -56,7 +56,9 @@ export const dict = { "marketplace.search": "搜索...", "marketplace.filter.all": "所有项目", "marketplace.filter.notInstalled": "未安装", + "marketplace.filter.relevant": "与我的工作区相关", "marketplace.empty": "未找到项目", + "marketplace.empty.relevant": "未找到与此工作区相关的市场项目。", "marketplace.badge.mcpServer": "MCP 服务器", "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index d6f1ccd27e9..dd4ebefaef3 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -56,7 +56,9 @@ export const dict = { "marketplace.search": "搜尋...", "marketplace.filter.all": "所有項目", "marketplace.filter.notInstalled": "未安裝", + "marketplace.filter.relevant": "與我的工作區相關", "marketplace.empty": "找不到項目", + "marketplace.empty.relevant": "找不到與此工作區相關的市集項目。", "marketplace.badge.mcpServer": "MCP 伺服器", "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index cbdd5f53de4..d0eee948a3f 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -33,6 +33,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private project: string | null = null private ready = false + private generation = 0 private statuses = new Map() private disposables: vscode.Disposable[] = [] private subscriptions: Array<() => void> = [] @@ -129,6 +130,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.subscriptions = [] this.panel = undefined this.ready = false + this.generation++ this.statuses.clear() } @@ -199,18 +201,22 @@ export class MarketplacePanelProvider implements vscode.Disposable { } private async fetchData(): Promise { + const generation = ++this.generation try { const project = this.project ?? undefined const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) + if (generation !== this.generation) return const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) } catch (err) { + if (generation !== this.generation) return const error = err instanceof Error ? err.message : String(err) console.warn("[Kilo New] Marketplace data fetch failed:", err) this.post({ type: "marketplaceData", marketplaceItems: [], marketplaceInstalledMetadata: { project: {}, global: {} }, + marketplaceRelevance: {}, errors: [error], }) } @@ -247,6 +253,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private setProjectDirectory(project: string | null): void { if (this.project === project) return + this.generation++ this.project = project this.post({ type: "workspaceDirectoryChanged", directory: project ?? "" }) } diff --git a/packages/kilo-vscode/src/services/marketplace/api.ts b/packages/kilo-vscode/src/services/marketplace/api.ts index 8dbd3c5a9b8..9ee6eb64b04 100644 --- a/packages/kilo-vscode/src/services/marketplace/api.ts +++ b/packages/kilo-vscode/src/services/marketplace/api.ts @@ -38,6 +38,7 @@ function transformSkill(raw: RawSkill): SkillMarketplaceItem { displayCategory: kebabToTitleCase(raw.category), githubUrl: raw.githubUrl, content: raw.content, + suggest_for: raw.suggest_for, } } diff --git a/packages/kilo-vscode/src/services/marketplace/index.ts b/packages/kilo-vscode/src/services/marketplace/index.ts index 148ec9e6d89..e07ca27f42d 100644 --- a/packages/kilo-vscode/src/services/marketplace/index.ts +++ b/packages/kilo-vscode/src/services/marketplace/index.ts @@ -3,10 +3,12 @@ import { MarketplaceApiClient } from "./api" import { MarketplacePaths } from "./paths" import { InstallationDetector, type CliSkill } from "./detection" import { MarketplaceInstaller } from "./installer" +import { detectMarketplaceRelevance } from "./relevance" import type { MarketplaceItem, InstallMarketplaceItemOptions, MarketplaceDataResponse, + MarketplaceRelevanceMetadata, InstallResult, RemoveResult, } from "./types" @@ -16,6 +18,7 @@ export class MarketplaceService { private paths: MarketplacePaths private detector: InstallationDetector private installer: MarketplaceInstaller + private scans = new Map>() constructor() { this.paths = new MarketplacePaths() @@ -25,15 +28,29 @@ export class MarketplaceService { } async fetchData(workspace?: string, skills?: CliSkill[]): Promise { - const [fetched, metadata] = await Promise.all([this.api.fetchAll(), this.detector.detect(workspace, skills)]) + const fetched = this.api.fetchAll() + const metadata = this.detector.detect(workspace, skills) + const relevance = fetched.then((result) => this.relevance(result.items, workspace)) + const [items, installed, matches] = await Promise.all([fetched, metadata, relevance]) return { - marketplaceItems: fetched.items, - marketplaceInstalledMetadata: metadata, - errors: fetched.errors.length > 0 ? fetched.errors : undefined, + marketplaceItems: items.items, + marketplaceInstalledMetadata: installed, + marketplaceRelevance: matches, + errors: items.errors.length > 0 ? items.errors : undefined, } } + private relevance(items: MarketplaceItem[], workspace?: string): Promise { + const key = `${workspace ?? ""}:${items.map((item) => `${item.type}:${item.id}`).join(",")}` + const current = this.scans.get(key) + if (current) return current + + const scan = detectMarketplaceRelevance(items, workspace).finally(() => this.scans.delete(key)) + this.scans.set(key, scan) + return scan + } + async install( item: MarketplaceItem, options: InstallMarketplaceItemOptions, @@ -59,6 +76,7 @@ export class MarketplaceService { } dispose(): void { + this.scans.clear() this.api.dispose() } } diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts new file mode 100644 index 00000000000..75b0713e05e --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -0,0 +1,63 @@ +import * as vscode from "vscode" +import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types" + +const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode}/**" + +interface RelevanceHost { + extensions: readonly string[] + find: (workspace: string, pattern: string) => Promise +} + +function context(): RelevanceHost { + return { + extensions: vscode.extensions.all.map((extension) => extension.id), + find: async (workspace, pattern) => { + const glob = new vscode.RelativePattern(workspace, `**/${pattern}`) + return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0 + }, + } +} + +export async function detectMarketplaceRelevance( + items: MarketplaceItem[], + workspace?: string, + source: RelevanceHost = context(), +): Promise { + const patterns = Array.from(new Set(items.flatMap((item) => item.suggest_for?.filename ?? []))) + const files = new Map() + + if (workspace) { + const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => + patterns.slice(index * 4, index * 4 + 4), + ) + for (const batch of batches) { + await Promise.all( + batch.map(async (pattern) => { + const found = await source.find(workspace, pattern).catch((err: unknown) => { + console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err) + return false + }) + files.set(pattern, found) + }), + ) + } + } + + const extensions = new Set(source.extensions.map((id) => id.toLowerCase())) + return Object.fromEntries( + items.flatMap((item) => { + const filename = item.suggest_for?.filename?.filter((pattern) => files.get(pattern)) + const vscodeExtension = item.suggest_for?.vscode_extension?.filter((id) => extensions.has(id.toLowerCase())) + if (!filename?.length && !vscodeExtension?.length) return [] + return [ + [ + `${item.type}:${item.id}`, + { + ...(filename?.length && { filename }), + ...(vscodeExtension?.length && { vscodeExtension }), + }, + ], + ] + }), + ) +} diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts index 1f544860b01..3e380e50544 100644 --- a/packages/kilo-vscode/src/services/marketplace/types.ts +++ b/packages/kilo-vscode/src/services/marketplace/types.ts @@ -12,6 +12,11 @@ export interface McpInstallationMethod { prerequisites?: string[] } +export interface MarketplaceSuggestFor { + filename?: string[] + vscode_extension?: string[] +} + export interface MarketplaceItemBase { id: string name: string @@ -20,6 +25,7 @@ export interface MarketplaceItemBase { author?: string authorUrl?: string prerequisites?: string[] + suggest_for?: MarketplaceSuggestFor } export interface McpMarketplaceItem extends MarketplaceItemBase { @@ -48,6 +54,7 @@ export interface RawSkill { category: string githubUrl: string content: string + suggest_for?: MarketplaceSuggestFor } export interface SkillMarketplaceItem extends MarketplaceItemBase { @@ -71,9 +78,17 @@ export interface MarketplaceInstalledMetadata { global: Record } +export interface MarketplaceRelevance { + filename?: string[] + vscodeExtension?: string[] +} + +export type MarketplaceRelevanceMetadata = Record + export interface MarketplaceDataResponse { marketplaceItems: MarketplaceItem[] marketplaceInstalledMetadata: MarketplaceInstalledMetadata + marketplaceRelevance: MarketplaceRelevanceMetadata errors?: string[] } diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index f778f9b1f0f..1eb81bfacf6 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -127,6 +127,17 @@ describe("Marketplace installation metadata", () => { ]) expect(filterItems(items, metadata, "", "installed", [], []).map((item) => item.id)).toEqual(["warehouse"]) expect(filterItems(items, metadata, "", "all", [], ["mcp"]).map((item) => item.id)).toEqual(["warehouse"]) + expect( + filterItems(items, metadata, "", "all", [], [], {}, true, { + "agent:reviewer": { filename: ["*.review.ts"] }, + "mcp:warehouse": { vscodeExtension: ["data.warehouse"] }, + }).map((item) => item.id), + ).toEqual(["reviewer", "warehouse"]) + expect( + filterItems(items, metadata, "warehouse", "all", [], [], {}, true, { + "agent:reviewer": { filename: ["*.review.ts"] }, + }), + ).toEqual([]) }) }) diff --git a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts new file mode 100644 index 00000000000..17d2e3b0e39 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, mock } from "bun:test" +import { detectMarketplaceRelevance } from "../../src/services/marketplace/relevance" +import type { MarketplaceItem } from "../../src/services/marketplace/types" + +const items: MarketplaceItem[] = [ + { + type: "agent", + id: "angular", + name: "Angular", + description: "Angular specialist", + category: "development", + content: { mode: "all", description: "Angular specialist", prompt: "Help with Angular" }, + suggest_for: { filename: ["*.component.ts"] }, + }, + { + type: "mcp", + id: "jupyter", + name: "Jupyter", + description: "Jupyter notebooks", + category: "data", + url: "https://example.com", + content: "{}", + suggest_for: { + filename: ["*.component.ts", "*.ipynb"], + vscode_extension: ["ms-toolsai.jupyter"], + }, + }, + { + type: "skill", + id: "unmatched", + name: "Unmatched", + displayName: "Unmatched", + description: "No matching context", + category: "development", + displayCategory: "Development", + githubUrl: "https://example.com", + content: "https://example.com/skill.tar.gz", + suggest_for: { filename: ["*.rs"] }, + }, +] + +describe("Marketplace relevance", () => { + it("matches workspace files and installed extensions with deduplicated bounded searches", async () => { + const find = mock(async (_workspace: string, pattern: string) => pattern === "*.component.ts") + + const relevance = await detectMarketplaceRelevance(items, "/repo", { + extensions: ["MS-ToolsAI.Jupyter"], + find, + }) + + expect(relevance).toEqual({ + "agent:angular": { filename: ["*.component.ts"] }, + "mcp:jupyter": { + filename: ["*.component.ts"], + vscodeExtension: ["ms-toolsai.jupyter"], + }, + }) + expect(find).toHaveBeenCalledTimes(3) + expect(find.mock.calls).toContainEqual(["/repo", "*.component.ts"]) + expect(find.mock.calls).toContainEqual(["/repo", "*.ipynb"]) + expect(find.mock.calls).toContainEqual(["/repo", "*.rs"]) + }) + + it("still matches installed extensions without a workspace", async () => { + const find = mock(async () => true) + + const relevance = await detectMarketplaceRelevance(items, undefined, { + extensions: ["ms-toolsai.jupyter"], + find, + }) + + expect(relevance).toEqual({ "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } }) + expect(find).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index c3fe6136a65..1d5cabc7496 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -3,11 +3,13 @@ import { TextField } from "@kilocode/kilo-ui/text-field" import { Select } from "@kilocode/kilo-ui/select" import { Tag } from "@kilocode/kilo-ui/tag" import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Checkbox } from "@kilocode/kilo-ui/checkbox" import type { MarketplaceItem, McpMarketplaceItem, SkillMarketplaceItem, MarketplaceInstalledMetadata, + MarketplaceRelevanceMetadata, } from "../../types/marketplace" import { useLanguage } from "../../context/language" import { filterItems, retain } from "./utils" @@ -22,9 +24,12 @@ interface StatusOption { interface Props { items: MarketplaceItem[] metadata: MarketplaceInstalledMetadata + relevance: MarketplaceRelevanceMetadata fetching: boolean searchPlaceholder: string emptyMessage: string + relevantEmptyMessage: string + initialRelevant?: boolean onInstall: (item: MarketplaceItem) => void onRemove: (item: MarketplaceItem, scope: "project" | "global") => void } @@ -35,6 +40,7 @@ export const MarketplaceListView = (props: Props) => { const [status, setStatus] = createSignal({ value: "all", label: t("marketplace.filter.all") }) const [types, setTypes] = createSignal([]) const [categories, setCategories] = createSignal([]) + const [relevant, setRelevant] = createSignal(props.initialRelevant ?? false) const options = (): StatusOption[] => [ { value: "all", label: t("marketplace.filter.all") }, @@ -84,11 +90,21 @@ export const MarketplaceListView = (props: Props) => { } const filtered = createMemo(() => - filterItems(props.items, props.metadata, search(), status().value, categories(), types(), { - agent: typeLabel("agent"), - mcp: typeLabel("mcp"), - skill: typeLabel("skill"), - }), + filterItems( + props.items, + props.metadata, + search(), + status().value, + categories(), + types(), + { + agent: typeLabel("agent"), + mcp: typeLabel("mcp"), + skill: typeLabel("skill"), + }, + relevant(), + props.relevance, + ), ) return ( @@ -105,6 +121,11 @@ export const MarketplaceListView = (props: Props) => { onSelect={(v: StatusOption | undefined) => v && setStatus(v)} /> +
+ + {t("marketplace.filter.relevant")} + +
1}>
@@ -149,7 +170,9 @@ export const MarketplaceListView = (props: Props) => { when={filtered().length > 0} fallback={
- {props.emptyMessage} + + {relevant() ? props.relevantEmptyMessage : props.emptyMessage} +
} diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx index 8892ec1aa11..fa6e8b9b031 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -5,7 +5,11 @@ import { useVSCode } from "../../context/vscode" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useDialog } from "@kilocode/kilo-ui/context/dialog" -import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../../types/marketplace" +import type { + MarketplaceItem, + MarketplaceInstalledMetadata, + MarketplaceRelevanceMetadata, +} from "../../types/marketplace" import { TelemetryEventName } from "../../../../src/services/telemetry/types" import { MarketplaceListView } from "./MarketplaceListView" import { InstallModal } from "./InstallModal" @@ -13,6 +17,7 @@ import { RemoveDialog } from "./RemoveDialog" import "./marketplace.css" const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} } +const EMPTY_RELEVANCE: MarketplaceRelevanceMetadata = {} export const MarketplaceView = () => { const vscode = useVSCode() @@ -22,6 +27,7 @@ export const MarketplaceView = () => { const [items, setItems] = createSignal([]) const [metadata, setMetadata] = createSignal(EMPTY_METADATA) + const [relevance, setRelevance] = createSignal(EMPTY_RELEVANCE) const [fetching, setFetching] = createSignal(true) const [errors, setErrors] = createSignal([]) const [pending, setPending] = createSignal<{ item: MarketplaceItem; scope: "project" | "global" } | null>(null) @@ -38,6 +44,7 @@ export const MarketplaceView = () => { if (msg.type === "marketplaceData") { setItems(msg.marketplaceItems ?? []) setMetadata(msg.marketplaceInstalledMetadata ?? EMPTY_METADATA) + setRelevance(msg.marketplaceRelevance ?? EMPTY_RELEVANCE) setErrors(msg.errors ?? []) setFetching(false) setShowMigrationBanner(msg.showAgentMigrationBanner ?? false) @@ -157,9 +164,11 @@ export const MarketplaceView = () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index e5f8cdcb8b8..a22b1f51168 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -27,6 +27,11 @@ flex: 1; } +.marketplace-relevance-filter { + display: flex; + align-items: center; +} + /* Type and category filters */ .marketplace-types, diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 0f616cc0c42..85ca9042e93 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -1,4 +1,8 @@ -import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace" +import type { + MarketplaceInstalledMetadata, + MarketplaceItem, + MarketplaceRelevanceMetadata, +} from "../../types/marketplace" export function retain(selected: T[], available: T[]): T[] { const values = new Set(available) @@ -48,6 +52,8 @@ export function filterItems( categories: string[], types: MarketplaceItem["type"][], labels: Partial> = {}, + relevant = false, + relevance: MarketplaceRelevanceMetadata = {}, ): MarketplaceItem[] { const query = search.trim().toLowerCase() return items @@ -56,6 +62,7 @@ export function filterItems( if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false if (types.length > 0 && !types.includes(item.type)) return false if (categories.length > 0 && !categories.includes(item.category)) return false + if (relevant && !relevance[`${item.type}:${item.id}`]) return false if (!query) return true return matches(item, query, labels) }) diff --git a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx index b8c0d692b67..2c2a4b55881 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx @@ -114,6 +114,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } }', parameters: [{ name: "GitHub Token", key: "GITHUB_TOKEN", placeholder: "ghp_xxxxxxxxxxxx" }], + suggest_for: { vscode_extension: ["github.vscode-pull-request-github"] }, author: "Anthropic", category: "development", }, @@ -197,6 +198,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ name: "Architect", description: "High-level system design and planning. Focuses on architecture decisions, component boundaries, and technical specifications without writing implementation code.", + suggest_for: { filename: ["*.architecture.md"] }, content: { mode: "primary", description: "Stress-test technical designs and produce implementation-ready plans", @@ -270,6 +272,10 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ ] const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} } +const RELEVANCE = { + "agent:architect": { filename: ["*.architecture.md"] }, + "mcp:github-mcp": { vscodeExtension: ["github.vscode-pull-request-github"] }, +} const PARTIAL_INSTALLED_SKILLS: MarketplaceInstalledMetadata = { project: { "skill:nextjs-developer": { type: "skill" } }, @@ -308,9 +314,33 @@ export const MixedListWithItems: Story = { +
+ + ), +} + +export const RelevantItems: Story = { + name: "Mixed list — relevant to workspace", + render: () => ( + +
+ @@ -327,9 +357,11 @@ export const EmptyList: Story = { diff --git a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts index 954c4b6c159..a56afcfd226 100644 --- a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts +++ b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts @@ -12,6 +12,11 @@ export interface McpInstallationMethod { prerequisites?: string[] } +export interface MarketplaceSuggestFor { + filename?: string[] + vscode_extension?: string[] +} + export interface MarketplaceItemBase { id: string name: string @@ -20,6 +25,7 @@ export interface MarketplaceItemBase { author?: string authorUrl?: string prerequisites?: string[] + suggest_for?: MarketplaceSuggestFor } export interface McpMarketplaceItem extends MarketplaceItemBase { @@ -62,6 +68,13 @@ export interface MarketplaceInstalledMetadata { global: Record } +export interface MarketplaceRelevance { + filename?: string[] + vscodeExtension?: string[] +} + +export type MarketplaceRelevanceMetadata = Record + export interface MarketplaceFilters { type?: string search?: string diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index ac59d37caaa..5864786a05c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -2,7 +2,7 @@ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sd import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../../../src/diff/sources/types" import type { PartBatch, PartRemove, PartUpdate } from "../../../../src/shared/stream-messages" import type { SessionMode } from "../../context/worktree-mode" -import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace" +import type { MarketplaceItem, MarketplaceInstalledMetadata, MarketplaceRelevanceMetadata } from "../marketplace" import type { ConnectionState, ServerInfo, SessionStatus } from "./connection" import type { FileAttachment, Part } from "./parts" import type { @@ -888,6 +888,7 @@ export interface MarketplaceDataMessage { type: "marketplaceData" marketplaceItems: MarketplaceItem[] marketplaceInstalledMetadata: MarketplaceInstalledMetadata + marketplaceRelevance: MarketplaceRelevanceMetadata errors?: string[] showAgentMigrationBanner?: boolean } From 95eda826cbcc3d2f522a6dd900f6b82eb6cabdba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 12:56:52 +0000 Subject: [PATCH 02/29] chore: update kilo-vscode visual regression baselines --- .../marketplace/empty-list-chromium-linux.png | 4 ++-- .../marketplace/mixed-list-with-items-chromium-linux.png | 4 ++-- .../marketplace/relevant-items-chromium-linux.png | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png index 9bf8b91cdb8..fe7821d7e80 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f -size 9851 +oid sha256:3545994fd7a126ea860b31e2ac07f9cbeaa03e872f805e3fd2ec7012556c6ea8 +size 12665 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png index 9556137ca56..0e841eadf3e 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c -size 54735 +oid sha256:10adc41427bfb41fb25fb25e14c771443c1b3cf23a546b05665370974fbc2209 +size 54839 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png new file mode 100644 index 00000000000..b842681355b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:572b5a2bfd530b743a8ebaaed2ddacc9611aaa31f450df0f2904f314d8cfff6d +size 52660 From 44b8f2ff833323a306ed42e3ed0ce65ebc716342 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:06:29 +0000 Subject: [PATCH 03/29] fix(vscode): scan relevance across workspace URIs Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/MarketplacePanelProvider.ts | 8 +++- .../src/services/marketplace/actions.ts | 3 +- .../src/services/marketplace/index.ts | 10 ++--- .../src/services/marketplace/relevance.ts | 38 ++++++++++--------- .../tests/unit/marketplace-relevance.test.ts | 25 +++++++++--- 5 files changed, 53 insertions(+), 31 deletions(-) diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index d0eee948a3f..90a6519e1b8 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -204,7 +204,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { const generation = ++this.generation try { const project = this.project ?? undefined - const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) + const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory(), this.relevanceRoots()) if (generation !== this.generation) return const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) @@ -271,6 +271,12 @@ export class MarketplacePanelProvider implements vscode.Disposable { return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir() } + private relevanceRoots(): vscode.Uri[] { + if (!this.project) return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? [] + const folder = vscode.workspace.workspaceFolders?.find((item) => item.uri.fsPath === this.project) + return [folder?.uri ?? vscode.Uri.file(this.project)] + } + private openExternal(raw: unknown): void { if (typeof raw !== "string") return const uri = vscode.Uri.parse(raw) diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 84b1d34fc5c..9237d54ecac 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -28,9 +28,10 @@ export async function fetchMarketplaceData( ctx: MarketplaceActionContext, project: string | undefined, dir: string | undefined, + roots: readonly vscode.Uri[], ): Promise { const skills = dir ? await fetchSkills(ctx, dir) : undefined - return ctx.marketplace.fetchData(project, skills) + return ctx.marketplace.fetchData(project, skills, roots) } export async function installMarketplaceItem( diff --git a/packages/kilo-vscode/src/services/marketplace/index.ts b/packages/kilo-vscode/src/services/marketplace/index.ts index e07ca27f42d..67522658612 100644 --- a/packages/kilo-vscode/src/services/marketplace/index.ts +++ b/packages/kilo-vscode/src/services/marketplace/index.ts @@ -27,10 +27,10 @@ export class MarketplaceService { this.installer = new MarketplaceInstaller(this.paths) } - async fetchData(workspace?: string, skills?: CliSkill[]): Promise { + async fetchData(workspace: string | undefined, skills: CliSkill[] | undefined, roots: readonly vscode.Uri[]) { const fetched = this.api.fetchAll() const metadata = this.detector.detect(workspace, skills) - const relevance = fetched.then((result) => this.relevance(result.items, workspace)) + const relevance = fetched.then((result) => this.relevance(result.items, roots)) const [items, installed, matches] = await Promise.all([fetched, metadata, relevance]) return { @@ -41,12 +41,12 @@ export class MarketplaceService { } } - private relevance(items: MarketplaceItem[], workspace?: string): Promise { - const key = `${workspace ?? ""}:${items.map((item) => `${item.type}:${item.id}`).join(",")}` + private relevance(items: MarketplaceItem[], roots: readonly vscode.Uri[]): Promise { + const key = `${roots.map((root) => root.toString()).join(",")}:${items.map((item) => `${item.type}:${item.id}`).join(",")}` const current = this.scans.get(key) if (current) return current - const scan = detectMarketplaceRelevance(items, workspace).finally(() => this.scans.delete(key)) + const scan = detectMarketplaceRelevance(items, roots).finally(() => this.scans.delete(key)) this.scans.set(key, scan) return scan } diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts index 75b0713e05e..9586ebfea5b 100644 --- a/packages/kilo-vscode/src/services/marketplace/relevance.ts +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -5,14 +5,14 @@ const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode} interface RelevanceHost { extensions: readonly string[] - find: (workspace: string, pattern: string) => Promise + find: (root: vscode.Uri, pattern: string) => Promise } function context(): RelevanceHost { return { extensions: vscode.extensions.all.map((extension) => extension.id), - find: async (workspace, pattern) => { - const glob = new vscode.RelativePattern(workspace, `**/${pattern}`) + find: async (root, pattern) => { + const glob = new vscode.RelativePattern(root, `**/${pattern}`) return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0 }, } @@ -20,27 +20,29 @@ function context(): RelevanceHost { export async function detectMarketplaceRelevance( items: MarketplaceItem[], - workspace?: string, + roots: readonly vscode.Uri[], source: RelevanceHost = context(), ): Promise { const patterns = Array.from(new Set(items.flatMap((item) => item.suggest_for?.filename ?? []))) const files = new Map() - if (workspace) { - const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => - patterns.slice(index * 4, index * 4 + 4), + const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => + patterns.slice(index * 4, index * 4 + 4), + ) + for (const batch of batches) { + await Promise.all( + batch.map(async (pattern) => { + const found = await Promise.all( + roots.map((root) => + source.find(root, pattern).catch((err: unknown) => { + console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err) + return false + }), + ), + ) + files.set(pattern, found.some(Boolean)) + }), ) - for (const batch of batches) { - await Promise.all( - batch.map(async (pattern) => { - const found = await source.find(workspace, pattern).catch((err: unknown) => { - console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err) - return false - }) - files.set(pattern, found) - }), - ) - } } const extensions = new Set(source.extensions.map((id) => id.toLowerCase())) diff --git a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts index 17d2e3b0e39..79a1246242b 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" import { detectMarketplaceRelevance } from "../../src/services/marketplace/relevance" import type { MarketplaceItem } from "../../src/services/marketplace/types" @@ -41,9 +42,10 @@ const items: MarketplaceItem[] = [ describe("Marketplace relevance", () => { it("matches workspace files and installed extensions with deduplicated bounded searches", async () => { - const find = mock(async (_workspace: string, pattern: string) => pattern === "*.component.ts") + const root = vscode.Uri.file("/repo") + const find = mock(async (_root: vscode.Uri, pattern: string) => pattern === "*.component.ts") - const relevance = await detectMarketplaceRelevance(items, "/repo", { + const relevance = await detectMarketplaceRelevance(items, [root], { extensions: ["MS-ToolsAI.Jupyter"], find, }) @@ -56,15 +58,26 @@ describe("Marketplace relevance", () => { }, }) expect(find).toHaveBeenCalledTimes(3) - expect(find.mock.calls).toContainEqual(["/repo", "*.component.ts"]) - expect(find.mock.calls).toContainEqual(["/repo", "*.ipynb"]) - expect(find.mock.calls).toContainEqual(["/repo", "*.rs"]) + expect(find.mock.calls).toContainEqual([root, "*.component.ts"]) + expect(find.mock.calls).toContainEqual([root, "*.ipynb"]) + expect(find.mock.calls).toContainEqual([root, "*.rs"]) + }) + + it("searches every workspace root and preserves remote URIs", async () => { + const local = vscode.Uri.file("/repo") + const remote = vscode.Uri.parse("vscode-remote://ssh-remote+host/workspace") + const find = mock(async (root: vscode.Uri, pattern: string) => root === remote && pattern === "*.ipynb") + + const relevance = await detectMarketplaceRelevance(items, [local, remote], { extensions: [], find }) + + expect(relevance).toEqual({ "mcp:jupyter": { filename: ["*.ipynb"] } }) + expect(find.mock.calls).toContainEqual([remote, "*.ipynb"]) }) it("still matches installed extensions without a workspace", async () => { const find = mock(async () => true) - const relevance = await detectMarketplaceRelevance(items, undefined, { + const relevance = await detectMarketplaceRelevance(items, [], { extensions: ["ms-toolsai.jupyter"], find, }) From a56e817d9b8b1533b527e2a6507ecd0cc8b8a88d Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:07:41 +0000 Subject: [PATCH 04/29] fix(vscode): refresh marketplace relevance when context changes Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../kilo-vscode/src/MarketplacePanelProvider.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index 90a6519e1b8..bd36dfd8454 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -34,6 +34,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private project: string | null = null private ready = false private generation = 0 + private refresh: ReturnType | undefined private statuses = new Map() private disposables: vscode.Disposable[] = [] private subscriptions: Array<() => void> = [] @@ -61,6 +62,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { if (this.panel) { this.setProjectDirectory(project) this.panel.reveal(vscode.ViewColumn.One) + this.scheduleRefresh() return } @@ -106,6 +108,10 @@ export class MarketplacePanelProvider implements vscode.Disposable { panel.webview.onDidReceiveMessage((msg) => void this.handle(msg as MarketplaceMessage)), panel.onDidDispose(() => this.cleanup()), watchFontSizeConfig((msg) => this.post(msg)), + vscode.extensions.onDidChange(() => this.scheduleRefresh()), + vscode.workspace.onDidCreateFiles(() => this.scheduleRefresh()), + vscode.workspace.onDidDeleteFiles(() => this.scheduleRefresh()), + vscode.workspace.onDidRenameFiles(() => this.scheduleRefresh()), ) this.subscriptions.push( this.connection.onStateChange((state, err) => { @@ -124,6 +130,8 @@ export class MarketplacePanelProvider implements vscode.Disposable { } private cleanup(): void { + if (this.refresh) clearTimeout(this.refresh) + this.refresh = undefined for (const disposable of this.disposables) disposable.dispose() for (const unsubscribe of this.subscriptions) unsubscribe() this.disposables = [] @@ -200,6 +208,15 @@ export class MarketplacePanelProvider implements vscode.Disposable { } } + private scheduleRefresh(): void { + if (!this.ready) return + if (this.refresh) clearTimeout(this.refresh) + this.refresh = setTimeout(() => { + this.refresh = undefined + void this.fetchData() + }, 250) + } + private async fetchData(): Promise { const generation = ++this.generation try { From 04d454b5b3c82289783375bcbeb64ed37583c09d Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:08:22 +0000 Subject: [PATCH 05/29] fix(vscode): ignore malformed relevance metadata Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/services/marketplace/relevance.ts | 11 ++++++++--- .../tests/unit/marketplace-relevance.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts index 9586ebfea5b..62644149f3f 100644 --- a/packages/kilo-vscode/src/services/marketplace/relevance.ts +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -3,6 +3,11 @@ import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types" const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode}/**" +function strings(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === "string") +} + interface RelevanceHost { extensions: readonly string[] find: (root: vscode.Uri, pattern: string) => Promise @@ -23,7 +28,7 @@ export async function detectMarketplaceRelevance( roots: readonly vscode.Uri[], source: RelevanceHost = context(), ): Promise { - const patterns = Array.from(new Set(items.flatMap((item) => item.suggest_for?.filename ?? []))) + const patterns = Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename)))) const files = new Map() const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => @@ -48,8 +53,8 @@ export async function detectMarketplaceRelevance( const extensions = new Set(source.extensions.map((id) => id.toLowerCase())) return Object.fromEntries( items.flatMap((item) => { - const filename = item.suggest_for?.filename?.filter((pattern) => files.get(pattern)) - const vscodeExtension = item.suggest_for?.vscode_extension?.filter((id) => extensions.has(id.toLowerCase())) + const filename = strings(item.suggest_for?.filename).filter((pattern) => files.get(pattern)) + const vscodeExtension = strings(item.suggest_for?.vscode_extension).filter((id) => extensions.has(id.toLowerCase())) if (!filename?.length && !vscodeExtension?.length) return [] return [ [ diff --git a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts index 79a1246242b..c0ef1168683 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts @@ -74,6 +74,22 @@ describe("Marketplace relevance", () => { expect(find.mock.calls).toContainEqual([remote, "*.ipynb"]) }) + it("ignores malformed suggestion metadata", async () => { + const malformed = { + ...items[0], + suggest_for: { filename: "*.component.ts", vscode_extension: [42] }, + } as unknown as MarketplaceItem + const find = mock(async () => true) + + const relevance = await detectMarketplaceRelevance([malformed], [vscode.Uri.file("/repo")], { + extensions: ["test.extension"], + find, + }) + + expect(relevance).toEqual({}) + expect(find).not.toHaveBeenCalled() + }) + it("still matches installed extensions without a workspace", async () => { const find = mock(async () => true) From 5fe01e71c7e95bd86259c75e4cba278ccc2916d1 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:09:12 +0000 Subject: [PATCH 06/29] fix(vscode): distinguish filtered marketplace empty states Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../tests/unit/marketplace-actions.test.ts | 16 ++++++++++------ .../marketplace/MarketplaceListView.tsx | 6 ++++-- .../src/components/marketplace/utils.ts | 4 ++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index 1eb81bfacf6..fc47330c300 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -7,7 +7,12 @@ import { type MarketplaceRemoveContext, } from "../../src/services/marketplace/actions" import type { McpMarketplaceItem } from "../../src/services/marketplace/types" -import { filterItems, installedScopes, retain } from "../../webview-ui/src/components/marketplace/utils" +import { + filterItems, + hasRelevantItems, + installedScopes, + retain, +} from "../../webview-ui/src/components/marketplace/utils" import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace" const project = "/repo" @@ -133,11 +138,10 @@ describe("Marketplace installation metadata", () => { "mcp:warehouse": { vscodeExtension: ["data.warehouse"] }, }).map((item) => item.id), ).toEqual(["reviewer", "warehouse"]) - expect( - filterItems(items, metadata, "warehouse", "all", [], [], {}, true, { - "agent:reviewer": { filename: ["*.review.ts"] }, - }), - ).toEqual([]) + const relevance = { "agent:reviewer": { filename: ["*.review.ts"] } } + expect(filterItems(items, metadata, "warehouse", "all", [], [], {}, true, relevance)).toEqual([]) + expect(hasRelevantItems(items, relevance)).toBe(true) + expect(hasRelevantItems(items, {})).toBe(false) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 1d5cabc7496..4735d7c6b16 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -12,7 +12,7 @@ import type { MarketplaceRelevanceMetadata, } from "../../types/marketplace" import { useLanguage } from "../../context/language" -import { filterItems, retain } from "./utils" +import { filterItems, hasRelevantItems, retain } from "./utils" import { ItemCard } from "./ItemCard" import { MarketplaceContribute } from "./MarketplaceContribute" @@ -171,7 +171,9 @@ export const MarketplaceListView = (props: Props) => { fallback={
- {relevant() ? props.relevantEmptyMessage : props.emptyMessage} + {relevant() && !hasRelevantItems(props.items, props.relevance) + ? props.relevantEmptyMessage + : props.emptyMessage}
diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 85ca9042e93..e8f63e82225 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -4,6 +4,10 @@ import type { MarketplaceRelevanceMetadata, } from "../../types/marketplace" +export function hasRelevantItems(items: MarketplaceItem[], relevance: MarketplaceRelevanceMetadata): boolean { + return items.some((item) => !!relevance[`${item.type}:${item.id}`]) +} + export function retain(selected: T[], available: T[]): T[] { const values = new Set(available) const next = selected.filter((value) => values.has(value)) From 53c41c109f65ccabcc018e1f606f55394b229a4f Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:10:08 +0000 Subject: [PATCH 07/29] chore(vscode): format relevance metadata handling Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-vscode/src/services/marketplace/relevance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts index 62644149f3f..2807d4821aa 100644 --- a/packages/kilo-vscode/src/services/marketplace/relevance.ts +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -54,7 +54,9 @@ export async function detectMarketplaceRelevance( return Object.fromEntries( items.flatMap((item) => { const filename = strings(item.suggest_for?.filename).filter((pattern) => files.get(pattern)) - const vscodeExtension = strings(item.suggest_for?.vscode_extension).filter((id) => extensions.has(id.toLowerCase())) + const vscodeExtension = strings(item.suggest_for?.vscode_extension).filter((id) => + extensions.has(id.toLowerCase()), + ) if (!filename?.length && !vscodeExtension?.length) return [] return [ [ From c193ee856118c1315ef01b497b38614846e7cd73 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 17:11:23 +0200 Subject: [PATCH 08/29] feat(vscode): add native notebook tools --- .changeset/native-notebook-tools.md | 5 + packages/kilo-vscode/src/extension.ts | 3 + .../cli-backend/connection-service.ts | 27 +- .../src/services/notebook/adapter.ts | 328 ++++ .../src/services/notebook/bridge.ts | 302 +++ .../src/services/notebook/index.ts | 5 + .../src/services/notebook/output.ts | 91 + .../kilo-vscode/src/services/notebook/path.ts | 55 + .../src/services/notebook/types.ts | 122 ++ .../tests/unit/notebook-bridge.test.ts | 233 +++ .../tests/unit/notebook-core.test.ts | 260 +++ packages/opencode/src/config/permission.ts | 5 + packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/kilocode/agent/index.ts | 9 +- .../src/kilocode/notebook/protocol.ts | 166 ++ .../opencode/src/kilocode/notebook/service.ts | 150 ++ .../server/httpapi/groups/kilocode.ts | 48 + .../server/httpapi/handlers/kilocode.ts | 33 +- .../src/kilocode/tool/notebook-host.ts | 189 ++ .../opencode/src/kilocode/tool/registry.ts | 34 +- .../server/routes/instance/httpapi/server.ts | 2 + packages/opencode/src/tool/registry.ts | 3 +- .../test/kilocode/notebook-service.test.ts | 176 ++ .../test/kilocode/notebook-tools.test.ts | 106 + ...l-registry-indexing-import-failure.test.ts | 3 + .../kilocode/tool-registry-indexing.test.ts | 17 +- ...l-registry-semantic-import-failure.test.ts | 3 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 136 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 565 ++++-- packages/sdk/openapi.json | 1744 ++++++++++++----- 30 files changed, 4209 insertions(+), 613 deletions(-) create mode 100644 .changeset/native-notebook-tools.md create mode 100644 packages/kilo-vscode/src/services/notebook/adapter.ts create mode 100644 packages/kilo-vscode/src/services/notebook/bridge.ts create mode 100644 packages/kilo-vscode/src/services/notebook/index.ts create mode 100644 packages/kilo-vscode/src/services/notebook/output.ts create mode 100644 packages/kilo-vscode/src/services/notebook/path.ts create mode 100644 packages/kilo-vscode/src/services/notebook/types.ts create mode 100644 packages/kilo-vscode/tests/unit/notebook-bridge.test.ts create mode 100644 packages/kilo-vscode/tests/unit/notebook-core.test.ts create mode 100644 packages/opencode/src/kilocode/notebook/protocol.ts create mode 100644 packages/opencode/src/kilocode/notebook/service.ts create mode 100644 packages/opencode/src/kilocode/tool/notebook-host.ts create mode 100644 packages/opencode/test/kilocode/notebook-service.test.ts create mode 100644 packages/opencode/test/kilocode/notebook-tools.test.ts diff --git a/.changeset/native-notebook-tools.md b/.changeset/native-notebook-tools.md new file mode 100644 index 00000000000..9dc1f661299 --- /dev/null +++ b/.changeset/native-notebook-tools.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code. diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 55cd1480d91..1a22505abd7 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -23,6 +23,7 @@ import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" +import { createNotebookBridge } from "./services/notebook" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -49,6 +50,7 @@ export function activate(context: vscode.ExtensionContext) { // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService(context) + const notebookBridge = createNotebookBridge(connectionService) let restore = context.workspaceState.get(RESTORE_KEY) ?? {} const remember = (patch: RestoreState) => { const next = { ...restore, ...patch } @@ -541,6 +543,7 @@ export function activate(context: vscode.ExtensionContext) { attention.dispose() browserAutomationService.dispose() provider.dispose() + notebookBridge.dispose() connectionService.dispose() }, }) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index eff52fb64cc..95417b6514f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -70,6 +70,8 @@ export class KiloConnectionService { private readonly favoritesChangeListeners: Set = new Set() private readonly clearPendingPromptsListeners: Set = new Set() private readonly directoryProviders: Set = new Set() + private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + private currentDirectory: string | undefined private readonly permissionDirectories: Map = new Map() private readonly questionDirectories: Map = new Map() private questionRevision = 0 @@ -97,6 +99,7 @@ export class KiloConnectionService { * Lazily start server + SSE. Multiple callers share the same promise. */ async connect(workspaceDir: string): Promise { + this.trackDirectory(workspaceDir) if (this.connectPromise) { return this.connectPromise } @@ -136,13 +139,27 @@ export class KiloConnectionService { * or if the connection fails. */ async getClientAsync(dir?: string): Promise { - if (this.client && this.state === "connected") return this.client const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error("No workspace folder open") + this.trackDirectory(root) + if (this.client && this.state === "connected") return this.client await this.connect(root) return this.getClient() } + /** Directories that may own directory-scoped requests on the shared backend. */ + getKnownDirectories(): string[] { + const dirs = new Set() + if (this.rootDirectory) dirs.add(this.rootDirectory) + if (this.currentDirectory) dirs.add(this.currentDirectory) + for (const provider of this.directoryProviders) { + for (const dir of provider()) { + if (dir) dirs.add(dir) + } + } + return [...dirs] + } + /** * Get server info (port). Returns null if not connected. */ @@ -425,6 +442,12 @@ export class KiloConnectionService { } } + private trackDirectory(dir: string): void { + if (!dir) return + this.rootDirectory ??= dir + this.currentDirectory = dir + } + /** * Reject all pending permission requests and questions across every * directory known to any currently-mounted KiloProvider. @@ -571,6 +594,8 @@ export class KiloConnectionService { this.favoritesChangeListeners.clear() this.clearPendingPromptsListeners.clear() this.directoryProviders.clear() + this.rootDirectory = undefined + this.currentDirectory = undefined this.messageSessionIdsByMessageId.clear() this.permissionDirectories.clear() this.questionDirectories.clear() diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts new file mode 100644 index 00000000000..bb34caa8190 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -0,0 +1,328 @@ +import path from "node:path" +import * as vscode from "vscode" +import { normalizeOutputs, normalizeSource } from "./output" +import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +import { + NOTEBOOK_LIMITS, + type NotebookAccess, + type NotebookAdapterDeps, + type NotebookCell, + type NotebookEditRequest, + type NotebookEditResult, + type NotebookExecuteRequest, + type NotebookExecuteResult, + type NotebookExecution, + type NotebookReadRequest, + type NotebookReadResult, +} from "./types" + +export interface NotebookAdapterOptions { + deps?: NotebookAdapterDeps + paths?: NotebookPathDeps + timeout?: number +} + +function execution(summary: vscode.NotebookCellExecutionSummary | undefined): NotebookExecution | undefined { + if (!summary) { + return undefined + } + return { + order: summary.executionOrder, + success: summary.success, + started: summary.timing?.startTime, + ended: summary.timing?.endTime, + } +} + +function changed( + base: vscode.NotebookCellExecutionSummary | undefined, + summary: vscode.NotebookCellExecutionSummary | undefined, +): boolean { + if (!summary) return false + return ( + summary.executionOrder !== base?.executionOrder || + summary.success !== base?.success || + summary.timing?.startTime !== base?.timing?.startTime || + summary.timing?.endTime !== base?.timing?.endTime + ) +} + +function defaults(): NotebookAdapterDeps { + return { + documents: () => vscode.workspace.notebookDocuments, + open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)), + apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)), + execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)), + change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener), + close: (listener) => vscode.workspace.onDidCloseNotebookDocument(listener), + uri: vscode.Uri.file, + edit: (uri, edits) => { + const edit = new vscode.WorkspaceEdit() + edit.set(uri, edits) + return edit + }, + insert: (index, cells) => vscode.NotebookEdit.insertCells(index, cells), + replace: (index, cells) => vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(index, index + 1), cells), + delete: (index) => vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(index, index + 1)), + cell: (input) => + new vscode.NotebookCellData( + input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + input.source, + input.language ?? (input.kind === "code" ? "plaintext" : "markdown"), + ), + } +} + +export class NotebookAdapter { + private readonly deps: NotebookAdapterDeps + private readonly timeout: number + + constructor( + private readonly access: NotebookAccess, + private readonly options: NotebookAdapterOptions = {}, + ) { + this.deps = options.deps ?? defaults() + this.timeout = options.timeout ?? 120_000 + } + + private async document( + directory: string, + relative: string, + ): Promise<{ document: vscode.NotebookDocument; path: string }> { + const target = await resolveNotebookPath(directory, relative, this.access, this.options.paths) + const open = this.deps.documents().find((document) => path.resolve(document.uri.fsPath) === path.resolve(target)) + const document = open ?? (await this.deps.open(this.deps.uri(target))) + if (document.isClosed) { + throw new NotebookError("closed", "Notebook document is closed") + } + return { document, path: target } + } + + async read(request: NotebookReadRequest): Promise { + const loaded = await this.document(request.directory, request.path) + const cells: NotebookCell[] = [] + const budget = { sources: 0, outputs: 0 } + const flags = { sources: false, outputs: false } + + const source = loaded.document.getCells() + if (source.length > 2_000) { + flags.sources = true + } + for (const [index, cell] of source.slice(0, 2_000).entries()) { + const source = normalizeSource( + cell.document.getText(), + Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)), + ) + budget.sources += Math.min(source.bytes, NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources) + flags.sources ||= source.truncated === true + const value: NotebookCell = { + index, + kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + language: cell.document.languageId.slice(0, 200), + source: source.text, + execution: execution(cell.executionSummary), + } + if (request.includeOutputs) { + const normalized = normalizeOutputs( + cell.outputs, + Math.max(0, Math.min(NOTEBOOK_LIMITS.output, NOTEBOOK_LIMITS.outputs - budget.outputs)), + ) + value.outputs = normalized.outputs + budget.outputs += normalized.bytes + if (normalized.truncated) { + flags.outputs = true + } + } + cells.push(value) + } + + return { + operation: "read", + path: request.path, + version: loaded.document.version, + cells, + ...(flags.sources || flags.outputs ? { truncated: true } : {}), + } + } + + async edit(request: NotebookEditRequest): Promise { + const loaded = await this.document(request.directory, request.path) + this.version(loaded.document, request.version) + const count = loaded.document.cellCount + const max = request.edit.action === "insert" ? count : count - 1 + if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) { + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`) + } + + const edits = (() => { + if (request.edit.action === "delete") { + return [this.deps.delete(request.index)] + } + const cell = this.deps.cell({ + kind: request.edit.kind, + language: request.edit.language, + source: request.edit.source, + }) + if (request.edit.action === "insert") { + return [this.deps.insert(request.index, [cell])] + } + return [this.deps.replace(request.index, [cell])] + })() + this.version(loaded.document, request.version) + if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) { + throw new NotebookError("unsupported", "VS Code rejected the notebook edit") + } + return { + operation: "edit", + path: request.path, + version: loaded.document.version, + index: request.index, + action: request.edit.action, + } + } + + async execute(request: NotebookExecuteRequest): Promise { + const loaded = await this.document(request.directory, request.path) + this.version(loaded.document, request.version) + if (!Number.isInteger(request.index) || request.index < 0 || request.index >= loaded.document.cellCount) { + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`) + } + const cell = loaded.document.cellAt(request.index) + if (cell.kind !== vscode.NotebookCellKind.Code) { + throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`) + } + + const result = this.wait(loaded.document, cell, request) + void this.deps + .execute("notebook.cell.execute", { + ranges: [{ start: request.index, end: request.index + 1 }], + document: loaded.document.uri, + }) + .catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + result.reject(new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`)) + }) + return result.promise + } + + private wait(document: vscode.NotebookDocument, cell: vscode.NotebookCell, request: NotebookExecuteRequest) { + const state: { + done: boolean + timer?: ReturnType + startup?: ReturnType + } = { done: false } + const base = cell.executionSummary + const disposables: vscode.Disposable[] = [] + const cleanup = () => { + if (state.done) { + return false + } + state.done = true + if (state.timer) { + clearTimeout(state.timer) + } + if (state.startup) { + clearTimeout(state.startup) + } + for (const disposable of disposables) { + disposable.dispose() + } + request.signal?.removeEventListener("abort", abort) + return true + } + const holder: { + resolve?: (value: NotebookExecuteResult) => void + reject?: (error: Error) => void + } = {} + const promise = new Promise((resolve, reject) => { + holder.resolve = resolve + holder.reject = reject + }) + const reject = (error: Error) => { + if (cleanup()) { + holder.reject?.(error) + } + } + const resolve = () => { + if (!cleanup()) { + return + } + const normalized = normalizeOutputs(cell.outputs) + holder.resolve?.({ + operation: "execute", + path: request.path, + version: document.version, + index: request.index, + status: cell.executionSummary?.success === false ? "error" : "success", + outputs: normalized.outputs, + ...(normalized.truncated ? { truncated: true } : {}), + }) + } + const stop = () => + void this.deps.execute("notebook.cell.cancelExecution", { + ranges: [{ start: request.index, end: request.index + 1 }], + document: document.uri, + }) + const abort = () => { + stop() + reject(new NotebookError("cancelled", "Notebook execution cancellation was requested")) + } + + disposables.push( + this.deps.change((event) => { + if (event.notebook !== document) { + return + } + if (document.isClosed || document.cellCount <= request.index || document.cellAt(request.index) !== cell) { + reject(new NotebookError("stale_version", "Notebook cell changed during execution")) + return + } + const change = event.cellChanges.find((item) => item.cell === cell) + if (!change) { + return + } + const summary = change.executionSummary + if (!summary || !changed(base, summary)) return + if (state.startup) clearTimeout(state.startup) + if (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve() + }), + this.deps.close((closed) => { + if (closed === document) { + reject(new NotebookError("closed", "Notebook closed during execution")) + } + }), + ) + const timeout = request.timeout ?? this.timeout + state.startup = setTimeout( + () => { + stop() + reject( + new NotebookError( + "no_kernel", + "Notebook execution did not start; open the notebook in VS Code and select a kernel", + ), + ) + }, + Math.min(timeout, 10_000), + ) + state.timer = setTimeout(() => { + stop() + reject(new NotebookError("timeout", "Notebook execution timed out")) + }, timeout) + if (request.signal?.aborted) { + abort() + } else { + request.signal?.addEventListener("abort", abort, { once: true }) + } + return { promise, reject } + } + + private version(document: vscode.NotebookDocument, expected: number): void { + if (document.version !== expected) { + throw new NotebookError( + "stale_version", + `Notebook version changed (expected ${expected}, current ${document.version})`, + ) + } + } +} diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts new file mode 100644 index 00000000000..374494eeef9 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -0,0 +1,302 @@ +import type { + EventKilocodeNotebookCancelled, + EventKilocodeNotebookRequested, + KiloClient, + NotebookFailure, + NotebookRequest, + NotebookResult, +} from "@kilocode/sdk/v2/client" +import { FileIgnoreController } from "../autocomplete/shims/FileIgnoreController" +import type { ConnectionState, KiloConnectionService } from "../cli-backend/connection-service" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import { NotebookAdapter } from "./adapter" +import { NotebookError } from "./path" + +const RETAINED_REQUESTS = 1_000 +const CODES = new Set([ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_version", + "timeout", + "unsupported", +]) + +type NotebookAdapterLike = Pick + +export interface NotebookBridgeContext { + adapter: NotebookAdapterLike + refresh?(): Promise + dispose(): void +} + +export interface NotebookBridgeOptions { + create?: (directory: string) => Promise +} + +interface NotebookConnection { + onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void + onStateChange(listener: (state: ConnectionState, error?: Error) => void): () => void + getClient(): KiloClient + getKnownDirectories(): string[] +} + +interface ActiveRequest { + controller: AbortController + cancelled: boolean +} + +type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure } + +async function createContext(directory: string): Promise { + const controller = new FileIgnoreController(directory) + await controller.initialize() + return { + adapter: new NotebookAdapter(controller), + refresh: () => controller.initialize(), + dispose: () => controller.dispose(), + } +} + +function failure(error: unknown): NotebookFailure { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof NotebookError && CODES.has(error.code as NotebookFailure["code"])) { + return { code: error.code as NotebookFailure["code"], message } + } + return { code: "execution_failed", message } +} + +export class NotebookBridge { + private readonly contexts = new Map>() + private readonly active = new Map() + private readonly origins = new Map() + private readonly outcomes = new Map() + private readonly settled = new Set() + private readonly unsubscribeEvent: () => void + private readonly unsubscribeState: () => void + private readonly create: (directory: string) => Promise + private disposed = false + private revision = 0 + private backend: KiloClient | undefined + + constructor( + private readonly connection: NotebookConnection, + options: NotebookBridgeOptions = {}, + ) { + this.create = options.create ?? createContext + this.unsubscribeEvent = connection.onEvent((event, directory) => this.event(event, directory)) + this.unsubscribeState = connection.onStateChange((state) => { + if (state !== "connected") return + const backend = connection.getClient() + if (this.backend && this.backend !== backend) this.reset() + this.backend = backend + const revision = ++this.revision + void this.recover(revision).catch((error: unknown) => { + console.error("[Kilo New] NotebookBridge: pending request recovery failed:", error) + }) + }) + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.revision += 1 + this.unsubscribeEvent() + this.unsubscribeState() + for (const request of this.active.values()) { + request.cancelled = true + request.controller.abort() + } + this.active.clear() + for (const context of this.contexts.values()) { + void context + .then((value) => value.dispose()) + .catch((error: unknown) => console.error("[Kilo New] NotebookBridge: context disposal failed:", error)) + } + this.contexts.clear() + this.origins.clear() + this.outcomes.clear() + this.settled.clear() + } + + private reset(): void { + for (const request of this.active.values()) { + request.cancelled = true + request.controller.abort() + } + this.active.clear() + this.origins.clear() + this.outcomes.clear() + this.settled.clear() + } + + private event(event: SSEPayload, directory?: string): void { + if (event.type === "kilocode.notebook.requested") { + this.request(event as EventKilocodeNotebookRequested, directory) + return + } + if (event.type === "kilocode.notebook.cancelled") { + this.cancel(event as EventKilocodeNotebookCancelled, directory) + } + } + + private request(event: EventKilocodeNotebookRequested, directory?: string): void { + const request = event.properties + const dir = this.origins.get(request.id) ?? directory + if (!dir || this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return + this.remember(this.origins, request.id, dir) + const active = { controller: new AbortController(), cancelled: false } + this.active.set(request.id, active) + void this.run(request, dir, active).catch((error: unknown) => { + console.error(`[Kilo New] NotebookBridge: request ${request.id} failed:`, error) + }) + } + + private cancel(event: EventKilocodeNotebookCancelled, directory?: string): void { + const id = event.properties.requestID + const dir = this.origins.get(id) ?? directory + if (dir) this.remember(this.origins, id, dir) + this.remember(this.settled, id) + const active = this.active.get(id) + if (!active) return + active.cancelled = true + active.controller.abort() + } + + private async run(request: NotebookRequest, directory: string, active: ActiveRequest): Promise { + try { + const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, directory, active)) + if (!outcome || this.disposed || active.cancelled) return + this.rememberOutcome(request.id, outcome) + const accepted = + "result" in outcome + ? await this.reply(request.id, directory, outcome.result) + : await this.reject(request.id, directory, outcome.error) + if (accepted) this.remember(this.settled, request.id) + } finally { + if (this.active.get(request.id) === active) this.active.delete(request.id) + } + } + + private async execute( + request: NotebookRequest, + directory: string, + active: ActiveRequest, + ): Promise { + try { + const context = await this.context(directory) + if (this.disposed || active.cancelled) return undefined + await context.refresh?.() + if (this.disposed || active.cancelled) return undefined + const result = await this.dispatch(context.adapter, request, directory, active.controller.signal) + return { result } + } catch (error) { + if (this.disposed || active.cancelled) return undefined + return { error: failure(error) } + } + } + + private dispatch( + adapter: NotebookAdapterLike, + request: NotebookRequest, + directory: string, + signal: AbortSignal, + ): Promise { + if (request.operation === "read") { + return adapter.read({ path: request.path, directory, includeOutputs: request.includeOutputs }) + } + if (request.operation === "edit") { + return adapter.edit({ + path: request.path, + directory, + version: request.version, + index: request.index, + edit: request.edit, + }) + } + return adapter.execute({ + path: request.path, + directory, + version: request.version, + index: request.index, + signal, + }) + } + + private context(directory: string): Promise { + const existing = this.contexts.get(directory) + if (existing) return existing + const context = this.create(directory) + this.contexts.set(directory, context) + return context + } + + private async reply(requestID: string, directory: string, result: NotebookResult): Promise { + try { + const response = await this.connection.getClient().kilocode.notebook.reply({ requestID, directory, result }) + if (!response.error) return true + console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, response.error) + return false + } catch (error) { + console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, error) + return false + } + } + + private async reject(requestID: string, directory: string, error: NotebookFailure): Promise { + try { + const response = await this.connection.getClient().kilocode.notebook.reject({ requestID, directory, error }) + if (!response.error) return true + console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, response.error) + return false + } catch (cause) { + console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, cause) + return false + } + } + + private async recover(revision: number): Promise { + const client = this.connection.getClient() + for (const directory of this.connection.getKnownDirectories()) { + try { + const response = await client.kilocode.notebook.list({ directory }) + if (this.disposed || revision !== this.revision) return + if (response.error) { + console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, response.error) + continue + } + for (const request of response.data ?? []) { + this.request({ id: request.id, type: "kilocode.notebook.requested", properties: request }, directory) + } + } catch (error) { + console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, error) + } + } + } + + private rememberOutcome(id: string, outcome: NotebookOutcome): void { + this.outcomes.set(id, outcome) + if (this.outcomes.size <= RETAINED_REQUESTS) return + const oldest = this.outcomes.keys().next().value + if (oldest !== undefined) this.outcomes.delete(oldest) + } + + private remember(map: Map, id: string, value: string): void + private remember(set: Set, id: string): void + private remember(target: Map | Set, id: string, value?: string): void { + if (target instanceof Map && value !== undefined) target.set(id, value) + if (target instanceof Set) target.add(id) + if (target.size <= RETAINED_REQUESTS) return + const oldest = target.keys().next().value + if (oldest !== undefined) target.delete(oldest) + } +} + +export function createNotebookBridge(connection: KiloConnectionService): NotebookBridge { + return new NotebookBridge(connection) +} diff --git a/packages/kilo-vscode/src/services/notebook/index.ts b/packages/kilo-vscode/src/services/notebook/index.ts new file mode 100644 index 00000000000..76bdf10c7a5 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/index.ts @@ -0,0 +1,5 @@ +export { NotebookBridge, createNotebookBridge, type NotebookBridgeContext, type NotebookBridgeOptions } from "./bridge" +export { NotebookAdapter, type NotebookAdapterOptions } from "./adapter" +export { normalizeOutputs, normalizeSource } from "./output" +export { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +export * from "./types" diff --git a/packages/kilo-vscode/src/services/notebook/output.ts b/packages/kilo-vscode/src/services/notebook/output.ts new file mode 100644 index 00000000000..39d983552df --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/output.ts @@ -0,0 +1,91 @@ +import type * as vscode from "vscode" +import { NOTEBOOK_LIMITS, type NotebookOutput, type NotebookText } from "./types" + +const decoder = new TextDecoder() +const encoder = new TextEncoder() +const ERROR_MIME = "application/vnd.code.notebook.error" +const TEXT_MIMES = new Set([ + "text/plain", + "text/markdown", + "application/json", + "application/vnd.code.notebook.stdout", + "application/vnd.code.notebook.stderr", +]) + +function slice(data: Uint8Array, limit: number): NotebookText { + const bytes = data.byteLength + const body = bytes > limit ? data.subarray(0, limit) : data + const text = decoder.decode(body) + return bytes > limit ? { text, bytes, truncated: true } : { text, bytes } +} + +export function normalizeSource(source: string, limit = NOTEBOOK_LIMITS.source): NotebookText { + return slice(encoder.encode(source), limit) +} + +function field(value: unknown, limit: number): string | undefined { + if (typeof value !== "string") return undefined + return value.slice(0, limit) +} + +function error(item: vscode.NotebookCellOutputItem, limit: number): NotebookOutput { + const value = slice(item.data, limit) + const parsed = (() => { + try { + return JSON.parse(value.text) as unknown + } catch (err) { + void err + return undefined + } + })() + const data = typeof parsed === "object" && parsed !== null ? (parsed as Record) : {} + return { + mime: item.mime.slice(0, 200), + text: value.text, + name: field(data.name, 500), + message: field(data.message, 10_000), + stack: field(data.stack, 50_000), + truncated: value.truncated, + } +} + +export function normalizeOutputs( + outputs: readonly vscode.NotebookCellOutput[], + limit = NOTEBOOK_LIMITS.output, +): { outputs: NotebookOutput[]; truncated: boolean; bytes: number } { + const result: NotebookOutput[] = [] + let used = 0 + let truncated = false + + for (const output of outputs) { + for (const item of output.items) { + if (result.length >= 100) { + truncated = true + continue + } + const text = TEXT_MIMES.has(item.mime) || item.mime.startsWith("text/") + if (item.mime !== ERROR_MIME && !text) { + result.push({ mime: item.mime.slice(0, 200), omitted: true }) + continue + } + const available = Math.max(0, Math.min(NOTEBOOK_LIMITS.item, limit - used)) + if (available === 0) { + truncated = true + continue + } + if (item.mime === ERROR_MIME) { + const value = error(item, available) + result.push(value) + used += Math.min(item.data.byteLength, available) + truncated ||= value.truncated === true + continue + } + const value = slice(item.data, available) + result.push({ mime: item.mime.slice(0, 200), text: value.text, truncated: value.truncated }) + used += Math.min(item.data.byteLength, available) + truncated ||= value.truncated === true + } + } + + return { outputs: result, truncated, bytes: used } +} diff --git a/packages/kilo-vscode/src/services/notebook/path.ts b/packages/kilo-vscode/src/services/notebook/path.ts new file mode 100644 index 00000000000..5278a1a1cbc --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/path.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises" +import path from "node:path" +import type { NotebookAccess } from "./types" + +const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[/\\]/ + +export class NotebookError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = "NotebookError" + } +} + +export interface NotebookPathDeps { + realpath(path: string): Promise +} + +const defaults: NotebookPathDeps = { realpath: fs.realpath } + +function contained(root: string, target: string): boolean { + const relative = path.relative(root, target) + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) +} + +export async function resolveNotebookPath( + directory: string, + relative: string, + access: NotebookAccess, + deps: NotebookPathDeps = defaults, +): Promise { + if (!relative || relative.length > 4_096 || path.isAbsolute(relative) || WINDOWS_ABSOLUTE.test(relative)) { + throw new NotebookError("invalid_path", "Notebook path must be workspace-relative") + } + + const root = await deps.realpath(path.resolve(directory)) + const candidate = path.resolve(root, relative) + if (!contained(root, candidate)) { + throw new NotebookError("invalid_path", "Notebook path is outside the request directory") + } + + const target = await deps.realpath(candidate).catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + throw new NotebookError("not_found", `Cannot resolve notebook: ${detail}`) + }) + if (!contained(root, target)) { + throw new NotebookError("invalid_path", "Notebook resolves outside the request directory") + } + if (!(await access.validateAccess(target))) { + throw new NotebookError("invalid_path", "Notebook is excluded by workspace access rules") + } + return target +} diff --git a/packages/kilo-vscode/src/services/notebook/types.ts b/packages/kilo-vscode/src/services/notebook/types.ts new file mode 100644 index 00000000000..e8e2acd4b51 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/types.ts @@ -0,0 +1,122 @@ +import type * as vscode from "vscode" + +export const NOTEBOOK_LIMITS = { + source: 64 * 1024, + sources: 256 * 1024, + item: 16 * 1024, + output: 64 * 1024, + outputs: 256 * 1024, +} as const + +export type NotebookCellKind = "code" | "markdown" + +export interface NotebookText { + text: string + bytes: number + truncated?: true +} + +export interface NotebookOutput { + mime: string + text?: string + name?: string + message?: string + stack?: string + omitted?: boolean + truncated?: boolean +} + +export interface NotebookExecution { + order?: number + success?: boolean + started?: number + ended?: number +} + +export interface NotebookCell { + index: number + kind: NotebookCellKind + language: string + source: string + execution?: NotebookExecution + outputs?: NotebookOutput[] +} + +export interface NotebookReadResult { + operation: "read" + path: string + version: number + cells: NotebookCell[] + truncated?: boolean +} + +export interface NotebookEditResult { + operation: "edit" + path: string + version: number + index: number + action: "insert" | "replace" | "delete" +} + +export interface NotebookExecuteResult { + operation: "execute" + path: string + version: number + index: number + status: "success" | "error" | "cancelled" + outputs: NotebookOutput[] + truncated?: boolean +} + +export interface NotebookCellInput { + kind: NotebookCellKind + language?: string + source: string +} + +export type NotebookEdit = + | ({ action: "insert" } & NotebookCellInput) + | ({ action: "replace" } & NotebookCellInput) + | { action: "delete" } + +export interface NotebookReadRequest { + path: string + directory: string + includeOutputs: boolean +} + +export interface NotebookEditRequest { + path: string + directory: string + version: number + index: number + edit: NotebookEdit +} + +export interface NotebookExecuteRequest { + path: string + directory: string + version: number + index: number + signal?: AbortSignal + timeout?: number +} + +export interface NotebookAccess { + validateAccess(path: string): boolean | Promise +} + +export interface NotebookAdapterDeps { + documents(): readonly vscode.NotebookDocument[] + open(uri: vscode.Uri): Promise + apply(edit: vscode.WorkspaceEdit): Promise + execute(command: string, ...args: unknown[]): Promise + change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable + close(listener: (document: vscode.NotebookDocument) => void): vscode.Disposable + uri(path: string): vscode.Uri + edit(uri: vscode.Uri, edits: vscode.NotebookEdit[]): vscode.WorkspaceEdit + insert(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit + replace(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit + delete(index: number): vscode.NotebookEdit + cell(input: NotebookCellInput): vscode.NotebookCellData +} diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts new file mode 100644 index 00000000000..73ff021e526 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, mock } from "bun:test" +import type { NotebookRequest } from "@kilocode/sdk/v2/client" +import * as vscode from "vscode" +import { KiloConnectionService } from "../../src/services/cli-backend/connection-service" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { NotebookBridge, type NotebookBridgeContext } from "../../src/services/notebook/bridge" +import { NotebookError } from "../../src/services/notebook/path" + +const read: NotebookRequest = { + id: "notebook-1", + sessionID: "session-1", + operation: "read", + path: "book.ipynb", + includeOutputs: true, +} + +function deferred() { + const state: { resolve?: (value: T) => void; reject?: (error: Error) => void } = {} + const promise = new Promise((resolve, reject) => { + state.resolve = resolve + state.reject = reject + }) + return { promise, resolve: state.resolve!, reject: state.reject! } +} + +async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +function harness(context: NotebookBridgeContext, dirs = ["/repo"]) { + const replies: unknown[] = [] + const rejections: unknown[] = [] + const lists = new Map() + const state = { failReply: false } + const handlers: { + event?: (event: SSEPayload, directory?: string) => void + state?: (state: "connecting" | "connected" | "disconnected" | "error") => void + } = {} + const client = { + kilocode: { + notebook: { + list: async ({ directory }: { directory?: string }) => ({ data: lists.get(directory ?? "") ?? [] }), + reply: async (input: unknown) => { + replies.push(input) + return state.failReply ? { error: "offline" } : { data: true } + }, + reject: async (input: unknown) => { + rejections.push(input) + return { data: true } + }, + }, + }, + } + const connection = { + onEvent: (listener: typeof handlers.event) => { + handlers.event = listener + return () => { + handlers.event = undefined + } + }, + onStateChange: (listener: typeof handlers.state) => { + handlers.state = listener + return () => { + handlers.state = undefined + } + }, + getClient: () => client, + getKnownDirectories: () => dirs, + } + const create = mock(async () => context) + const bridge = new NotebookBridge(connection as never, { create }) + const request = (value: NotebookRequest = read, directory = "/repo") => + handlers.event?.( + { id: `event-${value.id}`, type: "kilocode.notebook.requested", properties: value } as SSEPayload, + directory, + ) + const cancel = (id = read.id, directory = "/repo") => + handlers.event?.( + { + id: `cancel-${id}`, + type: "kilocode.notebook.cancelled", + properties: { requestID: id, sessionID: "session-1", reason: "cancelled" }, + } as SSEPayload, + directory, + ) + return { bridge, cancel, client, connection, create, handlers, lists, rejections, replies, request, state } +} + +function context(overrides: Partial = {}) { + const dispose = mock(() => undefined) + const adapter = { + read: mock(async () => ({ operation: "read" as const, path: "book.ipynb", version: 2, cells: [] })), + edit: mock(async () => ({ + operation: "edit" as const, + path: "book.ipynb", + version: 2, + index: 0, + action: "replace" as const, + })), + execute: mock(async () => ({ + operation: "execute" as const, + path: "book.ipynb", + version: 2, + index: 0, + status: "success" as const, + outputs: [], + })), + ...overrides, + } + return { value: { adapter, dispose }, adapter, dispose } +} + +describe("NotebookBridge", () => { + it("deduplicates requests, retains their directory, and posts replies", async () => { + const ctx = context() + const test = harness(ctx.value) + + test.request() + test.request() + await flush() + + expect(test.create).toHaveBeenCalledTimes(1) + expect(test.create).toHaveBeenCalledWith("/repo") + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(ctx.adapter.read).toHaveBeenCalledWith({ path: "book.ipynb", directory: "/repo", includeOutputs: true }) + expect(test.replies).toEqual([ + { + requestID: "notebook-1", + directory: "/repo", + result: { operation: "read", path: "book.ipynb", version: 2, cells: [] }, + }, + ]) + + test.bridge.dispose() + await flush() + expect(ctx.dispose).toHaveBeenCalledTimes(1) + expect(test.handlers.event).toBeUndefined() + expect(test.handlers.state).toBeUndefined() + }) + + it("retries a failed reply without repeating the adapter operation", async () => { + const ctx = context() + const test = harness(ctx.value) + test.state.failReply = true + + test.request() + await flush() + test.state.failReply = false + test.request() + await flush() + + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(test.replies).toHaveLength(2) + test.bridge.dispose() + }) + + it("aborts cancelled execution without posting a late completion", async () => { + const pending = deferred() + const signals: AbortSignal[] = [] + const execute = mock((request: { signal?: AbortSignal }) => { + if (request.signal) { + signals.push(request.signal) + request.signal.addEventListener("abort", () => pending.reject(new NotebookError("cancelled", "cancelled"))) + } + return pending.promise + }) + const ctx = context({ execute: execute as never }) + const test = harness(ctx.value) + test.request({ + id: "execute-1", + sessionID: "session-1", + operation: "execute", + path: "book.ipynb", + version: 1, + index: 0, + }) + await flush() + + test.cancel("execute-1") + await flush() + + expect(signals[0]?.aborted).toBe(true) + expect(test.replies).toEqual([]) + expect(test.rejections).toEqual([]) + test.bridge.dispose() + }) + + it("recovers pending requests for known directories and maps adapter failures", async () => { + const ctx = context({ + read: mock(async () => { + throw new NotebookError("stale_version", "Notebook changed") + }), + }) + const test = harness(ctx.value, ["/root", "/worktree"]) + test.lists.set("/worktree", [read]) + + test.handlers.state?.("connected") + await flush() + + expect(test.rejections).toEqual([ + { + requestID: "notebook-1", + directory: "/worktree", + error: { code: "stale_version", message: "Notebook changed" }, + }, + ]) + test.bridge.dispose() + }) +}) + +describe("KiloConnectionService notebook directories", () => { + it("tracks the workspace root, current request directory, and provider directories", async () => { + const descriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders") + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri: { fsPath: "/root" } }], + }) + const service = new KiloConnectionService({} as vscode.ExtensionContext) + const internals = service as unknown as { client: object; state: string } + internals.client = {} + internals.state = "connected" + const unregister = service.registerDirectoryProvider(() => ["/worktree", "/root"]) + + await service.getClientAsync("/current") + + expect(service.getKnownDirectories()).toEqual(["/root", "/current", "/worktree"]) + unregister() + expect(service.getKnownDirectories()).toEqual(["/root", "/current"]) + service.dispose() + if (descriptor) Object.defineProperty(vscode.workspace, "workspaceFolders", descriptor) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts new file mode 100644 index 00000000000..0c87f45d08a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import { NotebookAdapter } from "../../src/services/notebook/adapter" +import { normalizeOutputs, normalizeSource } from "../../src/services/notebook/output" +import { NotebookError, resolveNotebookPath } from "../../src/services/notebook/path" +import type { NotebookAdapterDeps, NotebookCellInput } from "../../src/services/notebook/types" + +function uri(path: string): vscode.Uri { + return { scheme: "file", fsPath: path, path, toString: () => `file://${path}` } as vscode.Uri +} + +function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code): vscode.NotebookCell { + return { + kind, + document: { getText: () => source, languageId: kind === vscode.NotebookCellKind.Code ? "python" : "markdown" }, + outputs: [], + executionSummary: undefined, + } as unknown as vscode.NotebookCell +} + +function notebook(cells: vscode.NotebookCell[], version = 1): vscode.NotebookDocument { + return { + uri: uri("/repo/book.ipynb"), + version, + isClosed: false, + cellCount: cells.length, + getCells: () => cells, + cellAt: (index: number) => cells[index]!, + } as unknown as vscode.NotebookDocument +} + +function harness(document: vscode.NotebookDocument) { + const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>() + const closes = new Set<(document: vscode.NotebookDocument) => void>() + const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown } + const deps: NotebookAdapterDeps = { + documents: () => [document], + open: async () => { + calls.open++ + return document + }, + apply: async () => { + calls.apply++ + Object.assign(document, { version: document.version + 1 }) + return true + }, + execute: async (...args) => { + calls.command++ + calls.commandArgs = args + }, + change: (listener) => { + changes.add(listener) + return { dispose: () => changes.delete(listener) } + }, + close: (listener) => { + closes.add(listener) + return { dispose: () => closes.delete(listener) } + }, + uri, + edit: (_uri, edits) => { + calls.edit = edits + return { edits } as unknown as vscode.WorkspaceEdit + }, + insert: (index, cells) => ({ type: "insert", index, cells }) as unknown as vscode.NotebookEdit, + replace: (index, cells) => ({ type: "replace", index, cells }) as unknown as vscode.NotebookEdit, + delete: (index) => ({ type: "delete", index }) as unknown as vscode.NotebookEdit, + cell: (input: NotebookCellInput) => ({ input }) as unknown as vscode.NotebookCellData, + } + return { deps, changes, closes, calls } +} + +const paths = { realpath: async (value: string) => value } +const access = { validateAccess: mock(() => true) } + +function adapter(document: vscode.NotebookDocument, deps = harness(document)) { + return { adapter: new NotebookAdapter(access, { deps: deps.deps, paths, timeout: 50 }), ...deps } +} + +describe("notebook path security", () => { + it("rejects traversal and symlink escapes before access checks", async () => { + const guard = { validateAccess: mock(() => true) } + await expect(resolveNotebookPath("/repo", "../secret.ipynb", guard, paths)).rejects.toMatchObject({ + code: "invalid_path", + }) + await expect( + resolveNotebookPath("/repo", "linked.ipynb", guard, { + realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value), + }), + ).rejects.toMatchObject({ code: "invalid_path" }) + expect(guard.validateAccess).not.toHaveBeenCalled() + }) + + it("enforces ignore access on the canonical target", async () => { + const guard = { validateAccess: mock(() => false) } + await expect(resolveNotebookPath("/repo", "book.ipynb", guard, paths)).rejects.toMatchObject({ + code: "invalid_path", + }) + expect(guard.validateAccess).toHaveBeenCalledWith("/repo/book.ipynb") + }) +}) + +describe("notebook normalization", () => { + it("bounds UTF-8 source and text outputs and omits rich bodies", () => { + expect(normalizeSource("abcdef", 3)).toEqual({ text: "abc", bytes: 6, truncated: true }) + const normalized = normalizeOutputs( + [ + { + items: [ + { mime: "text/plain", data: new TextEncoder().encode("abcdef") }, + { mime: "image/png", data: new Uint8Array(40) }, + ], + } as vscode.NotebookCellOutput, + ], + 3, + ) + expect(normalized).toEqual({ + outputs: [ + { mime: "text/plain", text: "abc", truncated: true }, + { mime: "image/png", omitted: true }, + ], + truncated: true, + bytes: 3, + }) + }) + + it("extracts and bounds standard notebook errors", () => { + const data = new TextEncoder().encode( + JSON.stringify({ name: "N".repeat(600), message: "M".repeat(11_000), stack: "trace" }), + ) + const normalized = normalizeOutputs([ + { items: [{ mime: "application/vnd.code.notebook.error", data }] } as vscode.NotebookCellOutput, + ]) + expect(normalized.outputs[0]).toMatchObject({ stack: "trace" }) + expect(normalized.outputs[0]?.name).toHaveLength(500) + expect(normalized.outputs[0]?.message).toHaveLength(10_000) + }) +}) + +describe("notebook adapter", () => { + it("prefers a dirty open document and reads normalized cells", async () => { + const document = notebook([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)], 7) + const ctx = adapter(document) + const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb" }) + expect(ctx.calls.open).toBe(0) + expect(result).toMatchObject({ + path: "book.ipynb", + version: 7, + cells: [ + { index: 0, kind: "code", language: "python", source: "unsaved" }, + { index: 1, kind: "markdown", language: "markdown", source: "# title" }, + ], + }) + }) + + it("opens notebooks in the background when not already open", async () => { + const document = notebook([cell()]) + const ctx = harness(document) + ctx.deps.documents = () => [] + const core = new NotebookAdapter(access, { deps: ctx.deps, paths }) + await core.read({ directory: "/repo", path: "book.ipynb" }) + expect(ctx.calls.open).toBe(1) + }) + + it("constructs insert, replace, and delete edits and rejects stale versions", async () => { + const document = notebook([cell()], 3) + const ctx = adapter(document) + await expect( + ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + version: 2, + edit: { action: "replace", kind: "code", language: "python", source: "next" }, + }), + ).rejects.toMatchObject({ code: "stale_version" }) + await ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + version: 3, + edit: { action: "replace", kind: "code", language: "python", source: "next" }, + }) + expect(ctx.calls.edit).toEqual([ + { type: "replace", index: 0, cells: [{ input: { kind: "code", language: "python", source: "next" } }] }, + ]) + }) + + it("correlates a newly completed execution and cleans up listeners", async () => { + const target = cell() + const document = notebook([target], 4) + const ctx = adapter(document) + ctx.deps.execute = async (...args) => { + ctx.calls.command++ + ctx.calls.commandArgs = args + Object.assign(target, { + outputs: [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] }], + executionSummary: { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } }, + }) + for (const listener of ctx.changes) { + listener({ + notebook: document, + contentChanges: [], + cellChanges: [{ cell: target, executionSummary: target.executionSummary }], + } as unknown as vscode.NotebookDocumentChangeEvent) + } + } + const result = await ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + version: 4, + }) + expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] }) + expect(ctx.calls.commandArgs).toEqual([ + "notebook.cell.execute", + { ranges: [{ start: 0, end: 1 }], document: document.uri }, + ]) + expect(ctx.changes.size).toBe(0) + expect(ctx.closes.size).toBe(0) + }) + + it("fails closed when execution never starts", async () => { + const document = notebook([cell()]) + const ctx = adapter(document) + await expect( + ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + version: 1, + timeout: 5, + }), + ).rejects.toMatchObject({ code: "no_kernel" }) + expect(ctx.calls.commandArgs).toEqual([ + "notebook.cell.cancelExecution", + { ranges: [{ start: 0, end: 1 }], document: document.uri }, + ]) + expect(ctx.changes.size).toBe(0) + expect(ctx.closes.size).toBe(0) + }) + + it("cancels execution and disposes listeners", async () => { + const document = notebook([cell()]) + const ctx = adapter(document) + const controller = new AbortController() + const pending = ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + version: 1, + signal: controller.signal, + }) + controller.abort() + await expect(pending).rejects.toEqual( + new NotebookError("cancelled", "Notebook execution cancellation was requested"), + ) + expect(ctx.changes.size).toBe(0) + expect(ctx.closes.size).toBe(0) + }) +}) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a144a4c5ab4..16f9b1bb16f 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -34,6 +34,11 @@ const InputObject = Schema.StructWithRest( doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), agent_manager: Schema.optional(Rule), // kilocode_change + // kilocode_change start + notebook_read: Schema.optional(Rule), + notebook_edit: Schema.optional(Rule), + notebook_execute: Schema.optional(Rule), + // kilocode_change end }), [Schema.Record(Schema.String, Rule)], ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3f486c59f2e..2bc48e4de67 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -59,6 +59,7 @@ import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change const CoreLayer = Layer.mergeAll( Npm.defaultLayer, @@ -85,6 +86,7 @@ const CoreLayer = Layer.mergeAll( const SessionLayer = Layer.mergeAll( Question.defaultLayer, + Notebook.defaultLayer, // kilocode_change Permission.defaultLayer, Todo.defaultLayer, Session.defaultLayer, diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 71baa481d5c..6f6a911f97f 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -8,6 +8,7 @@ import type { Info as AgentInfo } from "../../agent/agent" import { Schema } from "effect" import path from "path" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import PROMPT_DEBUG from "../../agent/prompt/debug.txt" import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" @@ -228,7 +229,13 @@ export interface KiloData { // Prepare kilo-specific data derived from config. Call once per state initialization. export function prepare(cfg: Config.Info): KiloData { const mcpRules = getMcpRules(cfg) - const defaultsPatch = Permission.fromConfig({ bash, recall: "ask" }) + const defaultsPatch = Permission.fromConfig({ + bash, + recall: "ask", + ...(Flag.KILO_CLIENT === "vscode" + ? { notebook_read: "allow" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const } + : {}), + }) return { mcpRules, defaultsPatch } } diff --git a/packages/opencode/src/kilocode/notebook/protocol.ts b/packages/opencode/src/kilocode/notebook/protocol.ts new file mode 100644 index 00000000000..a98d53f85e1 --- /dev/null +++ b/packages/opencode/src/kilocode/notebook/protocol.ts @@ -0,0 +1,166 @@ +import { BusEvent } from "@/bus/bus-event" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" + +export const RequestID = Schema.String.pipe(Schema.brand("NotebookRequestID")).annotate({ + identifier: "NotebookRequestID", +}) +export type RequestID = Schema.Schema.Type + +export const Path = Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(4096), + Schema.makeFilter((value: string) => { + const parts = value.replaceAll("\\", "/").split("/") + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || parts.includes("..") + ? "Notebook path must be workspace-relative and contained in the workspace" + : undefined + }), +).annotate({ description: "Workspace-relative notebook path" }) +const Source = Schema.String.check(Schema.isMaxLength(200_000)) +const Text = Schema.String.check(Schema.isMaxLength(100_000)) +const Version = NonNegativeInt.annotate({ description: "Expected VS Code notebook document version" }) +const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) + +export const Output = Schema.Struct({ + mime: Schema.String.check(Schema.isMaxLength(200)), + text: Schema.optional(Text), + name: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), + message: Schema.optional(Schema.String.check(Schema.isMaxLength(10_000))), + stack: Schema.optional(Schema.String.check(Schema.isMaxLength(50_000))), + omitted: Schema.optional(Schema.Boolean), + truncated: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "NotebookOutput" }) +export type Output = Schema.Schema.Type + +export const Cell = Schema.Struct({ + index: Index, + kind: Schema.Literals(["code", "markdown"]), + language: Schema.String.check(Schema.isMaxLength(200)), + source: Source, + execution: Schema.optional( + Schema.Struct({ + order: Schema.optional(NonNegativeInt), + success: Schema.optional(Schema.Boolean), + started: Schema.optional(NonNegativeInt), + ended: Schema.optional(NonNegativeInt), + }), + ), + outputs: Schema.optional(Schema.Array(Output).check(Schema.isMaxLength(100))), +}).annotate({ identifier: "NotebookCell" }) +export type Cell = Schema.Schema.Type + +const Base = { id: RequestID, sessionID: SessionID, path: Path } + +export const ReadRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("read"), + includeOutputs: Schema.Boolean, +}).annotate({ identifier: "NotebookReadRequest" }) + +const CellEdit = { + kind: Schema.Literals(["code", "markdown"]), + language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), + source: Source, +} + +export const EditRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("edit"), + version: Version, + index: Index, + edit: Schema.Union([ + Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }), + Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }), + Schema.Struct({ action: Schema.Literal("delete") }), + ]), +}).annotate({ identifier: "NotebookEditRequest" }) + +export const ExecuteRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("execute"), + version: Version, + index: Index, +}).annotate({ identifier: "NotebookExecuteRequest" }) + +export const Request = Schema.Union([ReadRequest, EditRequest, ExecuteRequest]).annotate({ + identifier: "NotebookRequest", +}) +export type Request = Schema.Schema.Type + +export const ReadResult = Schema.Struct({ + operation: Schema.Literal("read"), + path: Path, + version: Version, + cells: Schema.Array(Cell).check(Schema.isMaxLength(2_000)), + truncated: Schema.optional(Schema.Boolean), +}) + .check( + Schema.makeFilter((value) => + JSON.stringify(value).length <= 2_000_000 ? undefined : "Notebook read result exceeds the aggregate output limit", + ), + ) + .annotate({ identifier: "NotebookReadResult" }) + +export const EditResult = Schema.Struct({ + operation: Schema.Literal("edit"), + path: Path, + version: Version, + index: Index, + action: Schema.Literals(["insert", "replace", "delete"]), +}).annotate({ identifier: "NotebookEditResult" }) + +export const ExecuteResult = Schema.Struct({ + operation: Schema.Literal("execute"), + path: Path, + version: Version, + index: Index, + status: Schema.Literals(["success", "error", "cancelled"]), + outputs: Schema.Array(Output).check(Schema.isMaxLength(100)), + truncated: Schema.optional(Schema.Boolean), +}) + .check( + Schema.makeFilter((value) => + JSON.stringify(value).length <= 2_000_000 + ? undefined + : "Notebook execution result exceeds the aggregate output limit", + ), + ) + .annotate({ identifier: "NotebookExecuteResult" }) + +export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).annotate({ identifier: "NotebookResult" }) +export type Result = Schema.Schema.Type + +export const ErrorCode = Schema.Literals([ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_version", + "timeout", + "unsupported", +]) +export type ErrorCode = Schema.Schema.Type + +export const Failure = Schema.Struct({ + code: ErrorCode, + message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10_000)), +}).annotate({ identifier: "NotebookFailure" }) +export type Failure = Schema.Schema.Type + +export const Event = { + Requested: BusEvent.define("kilocode.notebook.requested", Request), + Cancelled: BusEvent.define( + "kilocode.notebook.cancelled", + Schema.Struct({ + requestID: RequestID, + sessionID: SessionID, + reason: Schema.Literals(["cancelled", "disposed", "timeout"]), + }), + ), +} diff --git a/packages/opencode/src/kilocode/notebook/service.ts b/packages/opencode/src/kilocode/notebook/service.ts new file mode 100644 index 00000000000..fffb0aa5cf0 --- /dev/null +++ b/packages/opencode/src/kilocode/notebook/service.ts @@ -0,0 +1,150 @@ +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" +import { Deferred, Duration, Effect, Layer, Schema, Context } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol" + +const log = Log.create({ service: "notebook-host" }) +type WithoutID = T extends unknown ? Omit : never +export type Input = WithoutID + +export class HostError extends Schema.TaggedErrorClass()("NotebookHostError", { + code: ErrorCode, + detail: Schema.String, +}) { + override get message() { + return this.detail + } +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Notebook.NotFoundError", { + requestID: RequestID, +}) {} + +export class InvalidReplyError extends Schema.TaggedErrorClass()("Notebook.InvalidReplyError", { + requestID: RequestID, +}) {} + +interface Entry { + info: Request + deferred: Deferred.Deferred +} +interface State { + pending: Map +} + +function matches(request: Request, result: Result) { + if (request.path !== result.path) return false + if (request.operation === "read") return result.operation === "read" + if (request.operation === "execute") return result.operation === "execute" && request.index === result.index + return result.operation === "edit" && request.index === result.index && request.edit.action === result.action +} + +export interface Interface { + readonly request: (input: Input) => Effect.Effect + readonly list: () => Effect.Effect> + readonly reply: (input: { + requestID: RequestID + result: Result + }) => Effect.Effect + readonly reject: (input: { requestID: RequestID; error: Failure }) => Effect.Effect +} + +export class Service extends Context.Service()("@kilocode/Notebook") {} + +export function layer(timeout: Duration.Input = "10 minutes") { + return Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = yield* InstanceState.make( + Effect.fn("Notebook.state")(function* () { + const state = { pending: new Map() } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const entry of state.pending.values()) { + yield* bus.publish(Event.Cancelled, { + requestID: entry.info.id, + sessionID: entry.info.sessionID, + reason: "disposed", + }) + yield* Deferred.fail( + entry.deferred, + new HostError({ code: "disconnected", detail: "The notebook host disconnected" }), + ) + } + state.pending.clear() + }), + ) + return state + }), + ) + + const cancel = Effect.fn("Notebook.cancel")(function* (id: RequestID, reason: "cancelled" | "timeout") { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(id) + if (!entry) return + pending.delete(id) + yield* bus.publish(Event.Cancelled, { requestID: id, sessionID: entry.info.sessionID, reason }) + yield* Deferred.fail( + entry.deferred, + new HostError({ + code: reason, + detail: + reason === "timeout" ? "The notebook host request timed out" : "The notebook host request was cancelled", + }), + ) + }) + + const request: Interface["request"] = Effect.fn("Notebook.request")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const id = RequestID.make(Identifier.create("nbr", "ascending")) + const deferred = yield* Deferred.make() + const info = { ...input, id } as Request + pending.set(id, { info, deferred }) + return yield* Effect.gen(function* () { + yield* bus.publish(Event.Requested, info) + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => cancel(id, "timeout").pipe(Effect.andThen(Deferred.await(deferred))), + }), + ) + }).pipe(Effect.ensuring(cancel(id, "cancelled"))) + }) + + const list: Interface["list"] = Effect.fn("Notebook.list")(function* () { + return Array.from((yield* InstanceState.get(state)).pending.values(), (entry) => entry.info) + }) + + const reply: Interface["reply"] = Effect.fn("Notebook.reply")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(input.requestID) + if (!entry) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return yield* new NotFoundError({ requestID: input.requestID }) + } + if (!matches(entry.info, input.result)) return yield* new InvalidReplyError({ requestID: input.requestID }) + pending.delete(input.requestID) + yield* Deferred.succeed(entry.deferred, input.result) + }) + + const reject: Interface["reject"] = Effect.fn("Notebook.reject")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(input.requestID) + if (!entry) { + log.warn("rejection for unknown request", { requestID: input.requestID }) + return yield* new NotFoundError({ requestID: input.requestID }) + } + pending.delete(input.requestID) + yield* Deferred.fail(entry.deferred, new HostError({ code: input.error.code, detail: input.error.message })) + }) + + return Service.of({ request, list, reply, reject }) + }), + ) +} + +export const defaultLayer = layer().pipe(Layer.provide(Bus.layer)) +export * as Notebook from "./service" diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 5af99a9c83d..25520335619 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -7,6 +7,12 @@ import { WorkspaceRoutingQuery, } from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" +import { + Failure as NotebookFailure, + Request as NotebookRequest, + RequestID as NotebookRequestID, + Result as NotebookResult, +} from "@/kilocode/notebook/protocol" const root = "/kilocode" @@ -18,10 +24,16 @@ export const RemoveAgentPayload = Schema.Struct({ name: Schema.String, }) +export const NotebookReplyPayload = Schema.Struct({ result: NotebookResult }) +export const NotebookRejectPayload = Schema.Struct({ error: NotebookFailure }) + export const KilocodePaths = { heapSnapshot: `${root}/heap/snapshot`, removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, + notebookList: `${root}/notebook`, + notebookReply: `${root}/notebook/:requestID/reply`, + notebookReject: `${root}/notebook/:requestID/reject`, } as const export const KilocodeApi = HttpApi.make("kilocode") @@ -64,6 +76,42 @@ export const KilocodeApi = HttpApi.make("kilocode") "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", }), ), + HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(NotebookRequest), "Pending notebook host requests"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.list", + summary: "List pending notebook requests", + description: "List pending native notebook requests for the routed workspace.", + }), + ), + HttpApiEndpoint.post("notebookReply", KilocodePaths.notebookReply, { + params: { requestID: NotebookRequestID }, + query: WorkspaceRoutingQuery, + payload: NotebookReplyPayload, + success: described(Schema.Boolean, "Notebook reply accepted"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.reply", + summary: "Reply to a notebook request", + description: "Complete a pending native notebook request with a structured result.", + }), + ), + HttpApiEndpoint.post("notebookReject", KilocodePaths.notebookReject, { + params: { requestID: NotebookRequestID }, + query: WorkspaceRoutingQuery, + payload: NotebookRejectPayload, + success: described(Schema.Boolean, "Notebook rejection accepted"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.reject", + summary: "Reject a notebook request", + description: "Complete a pending native notebook request with a structured host error.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 88e364e51a7..a742c10e408 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -7,10 +7,12 @@ import { Config } from "@/config/config" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot" +import { Notebook } from "@/kilocode/notebook/service" +import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol" import { InstanceStore } from "@/project/instance-store" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { Skill } from "@/skill" -import { RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode" +import { NotebookRejectPayload, NotebookReplyPayload, RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode" export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) => Effect.gen(function* () { @@ -18,6 +20,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const skills = yield* Skill.Service const config = yield* Config.Service const store = yield* InstanceStore.Service + const notebook = yield* Notebook.Service const heapSnapshot = Effect.fn("KilocodeHttpApi.heapSnapshot")(function* () { return yield* Effect.sync(() => HeapSnapshot.write()) @@ -49,9 +52,37 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return true }) + const notebookList = Effect.fn("KilocodeHttpApi.notebookList")(function* () { + return yield* notebook.list() + }) + + const notebookReply = Effect.fn("KilocodeHttpApi.notebookReply")(function* (ctx: { + params: { requestID: NotebookRequestID } + payload: typeof NotebookReplyPayload.Type + }) { + yield* notebook.reply({ requestID: ctx.params.requestID, result: ctx.payload.result }).pipe( + Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({}))), + Effect.catchTag("Notebook.InvalidReplyError", () => Effect.fail(new HttpApiError.BadRequest({}))), + ) + return true + }) + + const notebookReject = Effect.fn("KilocodeHttpApi.notebookReject")(function* (ctx: { + params: { requestID: NotebookRequestID } + payload: typeof NotebookRejectPayload.Type + }) { + yield* notebook + .reject({ requestID: ctx.params.requestID, error: ctx.payload.error }) + .pipe(Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({})))) + return true + }) + return handlers .handle("heapSnapshot", heapSnapshot) .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) + .handle("notebookList", notebookList) + .handle("notebookReply", notebookReply) + .handle("notebookReject", notebookReject) }), ) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts new file mode 100644 index 00000000000..d6e45b97d7f --- /dev/null +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -0,0 +1,189 @@ +import { Notebook, HostError } from "@/kilocode/notebook/service" +import { Path, type Result } from "@/kilocode/notebook/protocol" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import * as Tool from "@/tool/tool" +import { Effect, Schema } from "effect" + +const Source = Schema.String.check(Schema.isMaxLength(200_000)) +const Version = NonNegativeInt.annotate({ description: "Notebook version returned by notebook_read" }) +const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) +const LIMIT = 20_000 + +function render(value: unknown) { + const text = JSON.stringify(value, null, 2) + if (text.length <= LIMIT) return text + return `${text.slice(0, LIMIT)} +... notebook result truncated by CLI (${text.length - LIMIT} characters omitted)` +} + +function abort(signal: AbortSignal) { + return Effect.callback((resume) => { + const err = () => new HostError({ code: "cancelled", detail: "The notebook tool call was cancelled" }) + if (signal.aborted) return resume(Effect.fail(err())) + const handler = () => resume(Effect.fail(err())) + signal.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", handler)) + }) +} + +function run(effect: Effect.Effect, signal: AbortSignal) { + return effect.pipe(Effect.raceFirst(abort(signal)), Effect.orDie) +} + +const ReadParams = Schema.Struct({ + path: Path, + include_outputs: Schema.optional(Schema.Boolean).annotate({ + description: "Include bounded text and error outputs. Defaults to false.", + }), +}) + +export const NotebookReadTool = Tool.define< + typeof ReadParams, + { path: string; version: number }, + Notebook.Service, + "notebook_read" +>( + "notebook_read", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Read the live, possibly unsaved structure and source of one VS Code notebook. Outputs are omitted unless include_outputs is true.", + parameters: ReadParams, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "notebook_read", + patterns: [params.path], + always: [params.path], + metadata: { path: params.path, includeOutputs: params.include_outputs === true }, + }) + const result = yield* run( + notebook.request({ + operation: "read", + sessionID: ctx.sessionID, + path: params.path, + includeOutputs: params.include_outputs === true, + }), + ctx.abort, + ) + if (result.operation !== "read") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `Notebook: ${params.path}`, + output: render(result), + metadata: { path: result.path, version: result.version }, + } + }), + } + }), +) + +const Cell = { + kind: Schema.Literals(["code", "markdown"]), + language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), + source: Source, +} +const EditParams = Schema.Union([ + Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("insert"), ...Cell }), + Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("replace"), ...Cell }), + Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("delete") }), +]) + +export const NotebookEditTool = Tool.define< + typeof EditParams, + { path: string; version: number; index: number }, + Notebook.Service, + "notebook_edit" +>( + "notebook_edit", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Insert, replace, or delete one cell in a live VS Code notebook. Requires the exact notebook version from notebook_read and leaves the document dirty.", + parameters: EditParams, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "notebook_edit", + patterns: [params.path], + always: [params.path], + metadata: { + path: params.path, + action: params.action, + index: params.index, + version: params.expected_version, + }, + }) + const edit = + params.action === "delete" + ? ({ action: params.action } as const) + : { action: params.action, kind: params.kind, language: params.language, source: params.source } + const result = yield* run( + notebook.request({ + operation: "edit", + sessionID: ctx.sessionID, + path: params.path, + version: params.expected_version, + index: params.index, + edit, + }), + ctx.abort, + ) + if (result.operation !== "edit") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `${result.action} notebook cell ${result.index}`, + output: render(result), + metadata: { path: result.path, version: result.version, index: result.index }, + } + }), + } + }), +) + +const ExecuteParams = Schema.Struct({ path: Path, expected_version: Version, index: Index }) + +export const NotebookExecuteTool = Tool.define< + typeof ExecuteParams, + { path: string; version: number; index: number }, + Notebook.Service, + "notebook_execute" +>( + "notebook_execute", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Execute one explicit code cell in a live VS Code notebook without revealing the notebook or opening a kernel picker. Requires the exact notebook version from notebook_read.", + parameters: ExecuteParams, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "notebook_execute", + patterns: [params.path], + always: [params.path], + metadata: { path: params.path, index: params.index, version: params.expected_version }, + }) + const result = yield* run( + notebook.request({ + operation: "execute", + sessionID: ctx.sessionID, + path: params.path, + version: params.expected_version, + index: params.index, + }), + ctx.abort, + ) + if (result.operation !== "execute") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `Executed notebook cell ${result.index}`, + output: render(result), + metadata: { path: result.path, version: result.version, index: result.index }, + } + }), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index fcbbf4ba908..a02b63c16d5 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -3,6 +3,7 @@ import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" import { AgentManagerTool } from "./agent-manager" import { BackgroundProcessTool } from "./background-process" +import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -37,14 +38,25 @@ export namespace KiloToolRegistry { const recall = yield* RecallTool const manager = yield* AgentManagerTool const process = yield* BackgroundProcessTool - return { codebase, recall, manager, process } + const notebookRead = yield* NotebookReadTool + const notebookEdit = yield* NotebookEditTool + const notebookExecute = yield* NotebookExecuteTool + return { codebase, recall, manager, process, notebookRead, notebookEdit, notebookExecute } }) } /** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect — * it has no Service deps beyond what Tool.init itself needs. */ export function build( - tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info }, + tools: { + codebase: Tool.Info + recall: Tool.Info + manager: Tool.Info + process: Tool.Info + notebookRead: Tool.Info + notebookEdit: Tool.Info + notebookExecute: Tool.Info + }, deps: Deps, loaders: Loaders = {}, ) { @@ -54,6 +66,9 @@ export namespace KiloToolRegistry { recall: Tool.init(tools.recall), manager: Tool.init(tools.manager), process: Tool.init(tools.process), + notebookRead: Tool.init(tools.notebookRead), + notebookEdit: Tool.init(tools.notebookEdit), + notebookExecute: Tool.init(tools.notebookExecute), }) const semantic = yield* semanticTool(deps, loaders) return { ...base, semantic } @@ -99,7 +114,16 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def }, + tools: { + codebase: Tool.Def + semantic?: Tool.Def + recall: Tool.Def + manager: Tool.Def + process: Tool.Def + notebookRead: Tool.Def + notebookEdit: Tool.Def + notebookExecute: Tool.Def + }, cfg: { experimental?: { codebase_search?: boolean } }, ): Tool.Def[] { return [ @@ -108,7 +132,9 @@ export namespace KiloToolRegistry { tools.recall, ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), // The extension is the only client that can consume the Agent Manager start event. - ...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []), + ...(Flag.KILO_CLIENT === "vscode" + ? [tools.manager, tools.notebookRead, tools.notebookEdit, tools.notebookExecute] + : []), ] } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index bee7fe31bc5..872d5a3594e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -37,6 +37,7 @@ import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" import { SessionPrompt } from "@/session/prompt" @@ -225,6 +226,7 @@ export function createRoutes( Pty.defaultLayer, PtyTicket.defaultLayer, Question.defaultLayer, + Notebook.defaultLayer, // kilocode_change Ripgrep.defaultLayer, RuntimeFlags.defaultLayer, Session.defaultLayer, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c184b6e77eb..576dc9000ec 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { KiloToolRegistry } from "../kilocode/tool/registry" // kilocode_change +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change import { RepoCloneTool } from "./repo_clone" import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change @@ -150,7 +151,7 @@ export const layer: Layer.Layer< const agent = yield* Agent.Service // kilocode_change start const suggesttool = yield* SuggestTool - const kiloToolInfos = yield* KiloToolRegistry.infos() + const kiloToolInfos = yield* KiloToolRegistry.infos().pipe(Effect.provide(Notebook.defaultLayer)) // kilocode_change end const state = yield* InstanceState.make( diff --git a/packages/opencode/test/kilocode/notebook-service.test.ts b/packages/opencode/test/kilocode/notebook-service.test.ts new file mode 100644 index 00000000000..90552e3396b --- /dev/null +++ b/packages/opencode/test/kilocode/notebook-service.test.ts @@ -0,0 +1,176 @@ +import { expect } from "bun:test" +import { Bus } from "@/bus" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Notebook, HostError } from "@/kilocode/notebook/service" +import { Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol" +import { SessionID } from "@/session/schema" +import { Effect, Fiber, Layer, Queue, Schema } from "effect" +import { TestInstance } from "../fixture/fixture" +import { disposeInstance } from "@/effect/instance-registry" +import { testEffect } from "../lib/effect" + +const it = testEffect(Notebook.layer("20 millis").pipe(Layer.provideMerge(Bus.layer))) +const sessionID = SessionID.make("ses_notebook_test") + +function request(notebook: Notebook.Interface) { + return notebook.request({ operation: "read", sessionID, path: "analysis.ipynb", includeOutputs: false }) +} + +it.instance( + "publishes, lists, and completes a correlated request", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const bus = yield* Bus.Service + const instance = yield* TestInstance + const events = yield* Queue.unbounded<{ properties: Request }>() + const global = yield* Queue.unbounded() + const off = yield* bus.subscribeCallback(Event.Requested, (event) => Queue.offerUnsafe(events, event)) + const handler = (event: GlobalEvent) => { + if (event.payload?.type === Event.Requested.type) Queue.offerUnsafe(global, event) + } + GlobalBus.on("event", handler) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + off() + GlobalBus.off("event", handler) + }), + ) + + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds")) + expect(event.properties.sessionID).toBe(sessionID) + expect(event.properties.path).toBe("analysis.ipynb") + expect((yield* Queue.take(global).pipe(Effect.timeout("2 seconds"))).directory).toBe(instance.directory) + expect(yield* notebook.list()).toEqual([event.properties]) + + yield* notebook.reply({ + requestID: event.properties.id, + result: { operation: "read", path: "analysis.ipynb", version: 3, cells: [] }, + }) + expect(yield* Fiber.join(fiber)).toEqual({ operation: "read", path: "analysis.ipynb", version: 3, cells: [] }) + expect(yield* notebook.list()).toEqual([]) + + const late = yield* notebook + .reply({ + requestID: event.properties.id, + result: { operation: "read", path: "analysis.ipynb", version: 3, cells: [] }, + }) + .pipe(Effect.flip) + expect(late._tag).toBe("Notebook.NotFoundError") + }), + { git: true }, +) + +it.instance( + "propagates structured host rejection and removes pending state", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + yield* notebook.reject({ + requestID: pending[0].id, + error: { code: "stale_version", message: "Expected version 4 but found 5" }, + }) + const err = yield* Fiber.join(fiber).pipe(Effect.flip) + expect(err).toBeInstanceOf(HostError) + expect(err.code).toBe("stale_version") + expect(err.message).toContain("version 4") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "cancels interrupted requests and rejects operation-mismatched replies", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const bus = yield* Bus.Service + const cancelled = yield* Queue.unbounded() + const off = yield* bus.subscribeCallback(Event.Cancelled, (event) => + Queue.offerUnsafe(cancelled, event.properties.reason), + ) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + const mismatch = yield* notebook + .reply({ + requestID: pending[0].id, + result: { operation: "edit", path: "analysis.ipynb", version: 2, index: 0, action: "delete" }, + }) + .pipe(Effect.flip) + expect(mismatch._tag).toBe("Notebook.InvalidReplyError") + const wrongPath = yield* notebook + .reply({ + requestID: pending[0].id, + result: { operation: "read", path: "other.ipynb", version: 2, cells: [] }, + }) + .pipe(Effect.flip) + expect(wrongPath._tag).toBe("Notebook.InvalidReplyError") + + yield* Fiber.interrupt(fiber) + expect(yield* Queue.take(cancelled).pipe(Effect.timeout("2 seconds"))).toBe("cancelled") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "times out pending requests", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const err = yield* request(notebook).pipe(Effect.flip) + expect(err.code).toBe("timeout") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "rejects escaping paths and oversized aggregate results", + () => + Effect.gen(function* () { + const path = yield* Schema.decodeUnknownEffect(ReadRequest)({ + id: "nbr_test", + sessionID, + operation: "read", + path: "../outside.ipynb", + includeOutputs: false, + }).pipe(Effect.flip) + expect(String(path)).toContain("workspace-relative") + + const cells = Array.from({ length: 11 }, (_, index) => ({ + index, + kind: "code" as const, + language: "python", + source: "x".repeat(200_000), + })) + const output = yield* Schema.decodeUnknownEffect(ReadResult)({ + operation: "read", + path: "analysis.ipynb", + version: 1, + cells, + }).pipe(Effect.flip) + expect(String(output)).toContain("aggregate output limit") + }), + { git: true }, +) + +it.instance( + "fails pending requests when the instance is disposed", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const instance = yield* TestInstance + const fiber = yield* request(notebook).pipe(Effect.forkChild) + yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + yield* Effect.promise(() => disposeInstance(instance.directory)) + const err = yield* Fiber.join(fiber).pipe(Effect.flip) + expect(err.code).toBe("disconnected") + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts new file mode 100644 index 00000000000..7f5192ed978 --- /dev/null +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { Agent } from "@/agent/agent" +import { Notebook } from "@/kilocode/notebook/service" +import * as KiloAgent from "@/kilocode/agent" +import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "@/kilocode/tool/notebook-host" +import { MessageID, SessionID } from "@/session/schema" +import * as Tool from "@/tool/tool" +import { Truncate } from "@/tool/truncate" +import { Effect, Layer } from "effect" +import { testEffect } from "../lib/effect" + +const calls: Notebook.Input[] = [] +const notebook = Layer.mock(Notebook.Service, { + request: (input) => { + calls.push(input) + if (input.operation === "read") + return Effect.succeed({ + operation: "read" as const, + path: input.path, + version: 7, + cells: [{ index: 0, kind: "code" as const, language: "python", source: "x".repeat(200_000) }], + }) + if (input.operation === "edit") + return Effect.succeed({ + operation: "edit" as const, + path: input.path, + version: input.version + 1, + index: input.index, + action: input.edit.action, + }) + return Effect.succeed({ + operation: "execute" as const, + path: input.path, + version: input.version, + index: input.index, + status: "success" as const, + outputs: [], + }) + }, +}) +const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer)) + +function context(asks: Parameters[0][]): Tool.Context { + return { + sessionID: SessionID.make("ses_notebook_tools"), + messageID: MessageID.make("msg_notebook_tools"), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: (input) => Effect.sync(() => asks.push(input)), + } +} + +describe("native notebook tools", () => { + it.instance( + "uses dedicated permissions and bounded structured output", + () => + Effect.gen(function* () { + calls.length = 0 + const asks: Parameters[0][] = [] + const read = yield* NotebookReadTool.pipe(Effect.flatMap(Tool.init)) + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const execute = yield* NotebookExecuteTool.pipe(Effect.flatMap(Tool.init)) + const ctx = context(asks) + + const readResult = yield* read.execute({ path: "analysis.ipynb", include_outputs: true }, ctx) + const editResult = yield* edit.execute( + { + path: "analysis.ipynb", + expected_version: 7, + index: 0, + action: "replace", + kind: "code", + language: "python", + source: "print(42)", + }, + ctx, + ) + const executeResult = yield* execute.execute({ path: "analysis.ipynb", expected_version: 8, index: 0 }, ctx) + + expect(asks.map((item) => item.permission)).toEqual(["notebook_read", "notebook_edit", "notebook_execute"]) + expect(asks.every((item) => item.patterns[0] === "analysis.ipynb")).toBe(true) + expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"]) + expect(readResult.output.length).toBeLessThanOrEqual(20_100) + expect(readResult.output).toContain("notebook result truncated") + expect(editResult.metadata.version).toBe(8) + expect(executeResult.metadata.index).toBe(0) + }), + { git: true }, + ) +}) + +test("uses dedicated VS Code notebook permission defaults", () => { + const prev = process.env.KILO_CLIENT + try { + process.env.KILO_CLIENT = "vscode" + const rules = KiloAgent.prepare({}).defaultsPatch + expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("allow") + expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask") + expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask") + } finally { + if (prev === undefined) delete process.env.KILO_CLIENT + if (prev !== undefined) process.env.KILO_CLIENT = prev + } +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 26da3cd254f..4f4446b97fe 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -39,6 +39,9 @@ function infos() { recall: info("recall"), manager: info("agent_manager"), process: info("background_process"), + notebookRead: info("notebook_read"), + notebookEdit: info("notebook_edit"), + notebookExecute: info("notebook_execute"), } } diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index afcd23c1ba4..f80c04366e7 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -203,6 +203,9 @@ describe("kilocode tool registry indexing", () => { recall: def("recall"), manager: def("agent_manager"), process: def("background_process"), + notebookRead: def("notebook_read"), + notebookEdit: def("notebook_edit"), + notebookExecute: def("notebook_execute"), } try { @@ -218,12 +221,24 @@ describe("kilocode tool registry indexing", () => { process.env["KILO_CLIENT"] = "vscode" expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual( - ["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"], + [ + "codebase_search", + "semantic_search", + "recall", + "background_process", + "agent_manager", + "notebook_read", + "notebook_edit", + "notebook_execute", + ], ) expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ "recall", "background_process", "agent_manager", + "notebook_read", + "notebook_edit", + "notebook_execute", ]) process.env["KILO_CLIENT"] = "desktop" diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 252e703b540..dac68266507 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -51,6 +51,9 @@ function infos() { recall: info("recall"), manager: info("agent_manager"), process: info("background_process"), + notebookRead: info("notebook_read"), + notebookEdit: info("notebook_edit"), + notebookExecute: info("notebook_execute"), } } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6cb4f75f0d4..2ca8d90dc8b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -142,6 +142,12 @@ import type { KiloCloudSessionsResponses, KilocodeHeapSnapshotErrors, KilocodeHeapSnapshotResponses, + KilocodeNotebookListErrors, + KilocodeNotebookListResponses, + KilocodeNotebookRejectErrors, + KilocodeNotebookRejectResponses, + KilocodeNotebookReplyErrors, + KilocodeNotebookReplyResponses, KilocodeRemoveAgentErrors, KilocodeRemoveAgentResponses, KilocodeRemoveSkillErrors, @@ -192,6 +198,9 @@ import type { NetworkRejectResponses, NetworkReplyErrors, NetworkReplyResponses, + NotebookFailure, + NotebookRequestId, + NotebookResult, OutputFormat, Part as Part2, PartDeleteErrors, @@ -6962,6 +6971,128 @@ export class Heap extends HeyApiClient { } } +export class Notebook extends HeyApiClient { + /** + * List pending notebook requests + * + * List pending native notebook requests for the routed workspace. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + KilocodeNotebookListResponses, + KilocodeNotebookListErrors, + ThrowOnError + >({ + url: "/kilocode/notebook", + ...options, + ...params, + }) + } + + /** + * Reply to a notebook request + * + * Complete a pending native notebook request with a structured result. + */ + public reply( + parameters: { + requestID: NotebookRequestId + directory?: string + workspace?: string + result?: NotebookResult + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "result" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeNotebookReplyResponses, + KilocodeNotebookReplyErrors, + ThrowOnError + >({ + url: "/kilocode/notebook/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject a notebook request + * + * Complete a pending native notebook request with a structured host error. + */ + public reject( + parameters: { + requestID: NotebookRequestId + directory?: string + workspace?: string + error?: NotebookFailure + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "error" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeNotebookRejectResponses, + KilocodeNotebookRejectErrors, + ThrowOnError + >({ + url: "/kilocode/notebook/{requestID}/reject", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class SessionImport extends HeyApiClient { /** * Insert project for session import @@ -7430,6 +7561,11 @@ export class Kilocode extends HeyApiClient { return (this._heap ??= new Heap({ client: this.client })) } + private _notebook?: Notebook + get notebook(): Notebook { + return (this._notebook ??= new Notebook({ client: this.client })) + } + private _sessionImport?: SessionImport get sessionImport(): SessionImport { return (this._sessionImport ??= new SessionImport({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3015f68a3ee..7d25ba15f55 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,18 +5,10 @@ export type ClientOptions = { } export type Event = + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventSandboxStatusChanged - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventIndexingWarning - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -24,6 +16,10 @@ export type Event = | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -37,6 +33,7 @@ export type Event = | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose + | EventSandboxStatusChanged | EventSessionDiff | EventSessionError | EventTodoUpdated @@ -50,6 +47,9 @@ export type Event = | EventCommandExecuted | EventProjectUpdated | EventSessionCompacted + | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -94,9 +94,11 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventIndexingStatus + | EventIndexingWarning + | EventModelsDevRefreshed | EventPluginAdded | EventCatalogModelUpdated - | EventModelsDevRefreshed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -137,76 +139,6 @@ export type InvalidRequestError = { field?: string } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - -export type IndexingWarning = { - code: "qdrant.version-incompatible" | "qdrant.version-unavailable" - message: string -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -269,6 +201,61 @@ export type QuestionRejected = { requestID: string } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type SessionNetworkWait = { id: string sessionID: string @@ -473,6 +460,64 @@ export type Project = { sandboxes: Array } +export type NotebookRequestId = string + +export type NotebookReadRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "read" + includeOutputs: boolean +} + +export type NotebookEditRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "edit" + /** + * Expected VS Code notebook document version + */ + version: number + /** + * Zero-based cell index + */ + index: number + edit: + | { + action: "insert" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "replace" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "delete" + } +} + +export type NotebookExecuteRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "execute" + /** + * Expected VS Code notebook document version + */ + version: number + /** + * Zero-based cell index + */ + index: number +} + +export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest + export type Pty = { id: string title: string @@ -919,23 +964,30 @@ export type Prompt = { references?: Array } +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + +export type IndexingWarning = { + code: "qdrant.version-incompatible" | "qdrant.version-unavailable" + message: string +} + export type GlobalEvent = { directory: string project?: string workspace?: string payload: + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventSandboxStatusChanged - | EventKilocodeAgentManagerStart - | EventIndexingStatus - | EventIndexingWarning - | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -943,6 +995,10 @@ export type GlobalEvent = { | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -956,6 +1012,7 @@ export type GlobalEvent = { | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose + | EventSandboxStatusChanged | EventSessionDiff | EventSessionError | EventTodoUpdated @@ -969,6 +1026,9 @@ export type GlobalEvent = { | EventCommandExecuted | EventProjectUpdated | EventSessionCompacted + | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -1013,9 +1073,11 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventIndexingStatus + | EventIndexingWarning + | EventModelsDevRefreshed | EventPluginAdded | EventCatalogModelUpdated - | EventModelsDevRefreshed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -1183,6 +1245,9 @@ export type PermissionConfig = doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig agent_manager?: PermissionRuleConfig + notebook_read?: PermissionRuleConfig + notebook_edit?: PermissionRuleConfig + notebook_execute?: PermissionRuleConfig [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } @@ -2422,6 +2487,92 @@ export type EffectHttpApiErrorServiceUnavailable = { _tag: "ServiceUnavailable" } +export type NotebookOutput = { + mime: string + text?: string + name?: string + message?: string + stack?: string + omitted?: boolean + truncated?: boolean +} + +export type NotebookCell = { + /** + * Zero-based cell index + */ + index: number + kind: "code" | "markdown" + language: string + source: string + execution?: { + order?: number + success?: boolean + started?: number + ended?: number + } + outputs?: Array +} + +export type NotebookReadResult = { + operation: "read" + path: string + /** + * Expected VS Code notebook document version + */ + version: number + cells: Array + truncated?: boolean +} + +export type NotebookEditResult = { + operation: "edit" + path: string + /** + * Expected VS Code notebook document version + */ + version: number + /** + * Zero-based cell index + */ + index: number + action: "insert" | "replace" | "delete" +} + +export type NotebookExecuteResult = { + operation: "execute" + path: string + /** + * Expected VS Code notebook document version + */ + version: number + /** + * Zero-based cell index + */ + index: number + status: "success" | "error" | "cancelled" + outputs: Array + truncated?: boolean +} + +export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookExecuteResult + +export type NotebookFailure = { + code: + | "cancelled" + | "closed" + | "disconnected" + | "execution_failed" + | "invalid_cell" + | "invalid_path" + | "no_kernel" + | "not_found" + | "stale_version" + | "timeout" + | "unsupported" + message: string +} + export type KilocodeSessionImportResult = { ok: boolean id: string @@ -2967,6 +3118,14 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventServerConnected = { id: string type: "server.connected" @@ -2991,57 +3150,6 @@ export type EventGlobalConfigUpdated = { } } -export type EventSandboxStatusChanged = { - id: string - type: "sandbox.status.changed" - properties: { - sessionID: string - directory: string - enabled: boolean - available: boolean - reason?: string - version: number - } -} - -export type EventKilocodeAgentManagerStart = { - id: string - type: "kilocode.agent_manager.start" - properties: { - requestID: string - sessionID: string - mode: "worktree" | "local" - versions?: boolean - tasks: Array<{ - prompt?: string - name?: string - branchName?: string - }> - } -} - -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - -export type EventIndexingWarning = { - id: string - type: "indexing.warning" - properties: IndexingWarning -} - -export type EventServerInstanceDisposed = { - id: string - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventFileEdited = { id: string type: "file.edited" @@ -3208,6 +3316,19 @@ export type EventSessionTurnClose = { } } +export type EventSandboxStatusChanged = { + id: string + type: "sandbox.status.changed" + properties: { + sessionID: string + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } +} + export type EventSessionDiff = { id: string type: "session.diff" @@ -3336,6 +3457,38 @@ export type EventSessionCompacted = { } } +export type EventKilocodeAgentManagerStart = { + id: string + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + prompt?: string + name?: string + branchName?: string + }> + } +} + +export type EventKilocodeNotebookRequested = { + id: string + type: "kilocode.notebook.requested" + properties: NotebookRequest +} + +export type EventKilocodeNotebookCancelled = { + id: string + type: "kilocode.notebook.cancelled" + properties: { + requestID: NotebookRequestId + sessionID: string + reason: "cancelled" | "disposed" | "timeout" + } +} + export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -3874,6 +4027,28 @@ export type EventSessionNextCompactionEnded = { } } +export type EventIndexingStatus = { + id: string + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + +export type EventIndexingWarning = { + id: string + type: "indexing.warning" + properties: IndexingWarning +} + +export type EventModelsDevRefreshed = { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } +} + export type EventPluginAdded = { id: string type: "plugin.added" @@ -3988,14 +4163,6 @@ export type EventCatalogModelUpdated = { } } -export type EventModelsDevRefreshed = { - id: string - type: "models-dev.refreshed" - properties: { - [key: string]: unknown - } -} - export type AccountV2oAuthCredential = { type: "oauth" refresh: string @@ -10546,6 +10713,106 @@ export type KilocodeRemoveAgentResponses = { export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses] +export type KilocodeNotebookListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook" +} + +export type KilocodeNotebookListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeNotebookListError = KilocodeNotebookListErrors[keyof KilocodeNotebookListErrors] + +export type KilocodeNotebookListResponses = { + /** + * Pending notebook host requests + */ + 200: Array +} + +export type KilocodeNotebookListResponse = KilocodeNotebookListResponses[keyof KilocodeNotebookListResponses] + +export type KilocodeNotebookReplyData = { + body?: { + result: NotebookResult + } + path: { + requestID: NotebookRequestId + } + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook/{requestID}/reply" +} + +export type KilocodeNotebookReplyErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type KilocodeNotebookReplyError = KilocodeNotebookReplyErrors[keyof KilocodeNotebookReplyErrors] + +export type KilocodeNotebookReplyResponses = { + /** + * Notebook reply accepted + */ + 200: boolean +} + +export type KilocodeNotebookReplyResponse = KilocodeNotebookReplyResponses[keyof KilocodeNotebookReplyResponses] + +export type KilocodeNotebookRejectData = { + body?: { + error: NotebookFailure + } + path: { + requestID: NotebookRequestId + } + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook/{requestID}/reject" +} + +export type KilocodeNotebookRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type KilocodeNotebookRejectError = KilocodeNotebookRejectErrors[keyof KilocodeNotebookRejectErrors] + +export type KilocodeNotebookRejectResponses = { + /** + * Notebook rejection accepted + */ + 200: boolean +} + +export type KilocodeNotebookRejectResponse = KilocodeNotebookRejectResponses[keyof KilocodeNotebookRejectResponses] + export type NetworkListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a7de036e880..b25a7e3dcb8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5713,6 +5713,9 @@ "required": ["id", "providerID"], "additionalProperties": false }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -6040,6 +6043,9 @@ "title": { "type": "string" }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -10708,11 +10714,14 @@ } }, "400": { - "description": "BadRequest | InvalidRequestError", + "description": "WorkspaceCreateError | BadRequest | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceCreateError" + }, { "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" }, @@ -14764,6 +14773,249 @@ ] } }, + "/kilocode/notebook": { + "get": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Pending notebook host requests", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookRequest" + }, + "description": "Pending notebook host requests" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "List pending native notebook requests for the routed workspace.", + "summary": "List pending notebook requests", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.list({\n ...\n})" + } + ] + } + }, + "/kilocode/notebook/{requestID}/reply": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.reply", + "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Notebook reply accepted", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Notebook reply accepted" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete a pending native notebook request with a structured result.", + "summary": "Reply to a notebook request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/NotebookResult" + } + }, + "required": ["result"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.reply({\n ...\n})" + } + ] + } + }, + "/kilocode/notebook/{requestID}/reject": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.reject", + "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Notebook rejection accepted", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Notebook rejection accepted" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete a pending native notebook request with a structured host error.", + "summary": "Reject a notebook request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/NotebookFailure" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.reject({\n ...\n})" + } + ] + } + }, "/network": { "get": { "tags": ["network"], @@ -16589,20 +16841,32 @@ "required": true }, { - "name": "directory", "in": "query", + "name": "directory", "schema": { "type": "string" - }, - "required": false + } }, { - "name": "workspace", "in": "query", + "name": "workspace", "schema": { "type": "string" - }, - "required": false + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } } ], "responses": { @@ -16653,6 +16917,9 @@ "schemas": { "Event": { "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -16662,33 +16929,6 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventSandboxStatusChanged" - }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventIndexingWarning" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -16710,6 +16950,18 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -16749,6 +17001,9 @@ { "$ref": "#/components/schemas/EventSessionTurnClose" }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, { "$ref": "#/components/schemas/EventSessionDiff" }, @@ -16788,6 +17043,15 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -16920,9 +17184,30 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventIndexingStatus" + }, + { + "$ref": "#/components/schemas/EventIndexingWarning" + }, + { + "$ref": "#/components/schemas/EventModels-devRefreshed" + }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, + { + "$ref": "#/components/schemas/EventAccountAdded" + }, + { + "$ref": "#/components/schemas/EventAccountRemoved" + }, + { + "$ref": "#/components/schemas/EventAccountSwitched" + }, { "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, @@ -17000,18 +17285,6 @@ }, { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" - }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" } ] }, @@ -17123,184 +17396,6 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "IndexingStatusState": { - "type": "string", - "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] - }, - "IndexingStatus": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/IndexingStatusState" - }, - "message": { - "type": "string" - }, - "processedFiles": { - "type": "integer", - "minimum": 0 - }, - "totalFiles": { - "type": "integer", - "minimum": 0 - }, - "percent": { - "type": "integer", - "minimum": 0, - "maximum": 100 - } - }, - "required": ["state", "message", "processedFiles", "totalFiles", "percent"], - "additionalProperties": false - }, - "IndexingWarning": { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": ["qdrant.version-incompatible", "qdrant.version-unavailable"] - }, - "message": { - "type": "string" - } - }, - "required": ["code", "message"], - "additionalProperties": false - }, "QuestionOption": { "type": "object", "properties": { @@ -17443,6 +17538,140 @@ "required": ["sessionID", "requestID"], "additionalProperties": false }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionNetworkWait": { "type": "object", "properties": { @@ -18030,6 +18259,175 @@ "required": ["id", "worktree", "time", "sandboxes"], "additionalProperties": false }, + "NotebookRequestID": { + "type": "string" + }, + "NotebookReadRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["read"] + }, + "includeOutputs": { + "type": "boolean" + } + }, + "required": ["id", "sessionID", "path", "operation", "includeOutputs"], + "additionalProperties": false + }, + "NotebookEditRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["edit"] + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Expected VS Code notebook document version" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "edit": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["insert"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["replace"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["delete"] + } + }, + "required": ["action"], + "additionalProperties": false + } + ] + } + }, + "required": ["id", "sessionID", "path", "operation", "version", "index", "edit"], + "additionalProperties": false + }, + "NotebookExecuteRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["execute"] + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Expected VS Code notebook document version" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + } + }, + "required": ["id", "sessionID", "path", "operation", "version", "index"], + "additionalProperties": false + }, + "NotebookRequest": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotebookReadRequest" + }, + { + "$ref": "#/components/schemas/NotebookEditRequest" + }, + { + "$ref": "#/components/schemas/NotebookExecuteRequest" + } + ] + }, "Pty": { "type": "object", "properties": { @@ -18058,7 +18456,7 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0 + "minimum": 0 }, "sessionID": { "anyOf": [ @@ -19347,6 +19745,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -19425,6 +19826,50 @@ "required": ["text"], "additionalProperties": false }, + "IndexingStatusState": { + "type": "string", + "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] + }, + "IndexingStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/IndexingStatusState" + }, + "message": { + "type": "string" + }, + "processedFiles": { + "type": "integer", + "minimum": 0 + }, + "totalFiles": { + "type": "integer", + "minimum": 0 + }, + "percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["state", "message", "processedFiles", "totalFiles", "percent"], + "additionalProperties": false + }, + "IndexingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": ["qdrant.version-incompatible", "qdrant.version-unavailable"] + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + }, "GlobalEvent": { "type": "object", "properties": { @@ -19439,6 +19884,9 @@ }, "payload": { "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -19448,33 +19896,6 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventSandboxStatusChanged" - }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventIndexingWarning" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -19496,6 +19917,18 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -19535,6 +19968,9 @@ { "$ref": "#/components/schemas/EventSessionTurnClose" }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, { "$ref": "#/components/schemas/EventSessionDiff" }, @@ -19574,6 +20010,15 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -19706,9 +20151,30 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventIndexingStatus" + }, + { + "$ref": "#/components/schemas/EventIndexingWarning" + }, + { + "$ref": "#/components/schemas/EventModels-devRefreshed" + }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, + { + "$ref": "#/components/schemas/EventAccountAdded" + }, + { + "$ref": "#/components/schemas/EventAccountRemoved" + }, + { + "$ref": "#/components/schemas/EventAccountSwitched" + }, { "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, @@ -19787,18 +20253,6 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" - }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" - }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -20246,6 +20700,15 @@ }, "agent_manager": { "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_read": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_edit": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_execute": { + "$ref": "#/components/schemas/PermissionRuleConfig" } }, "additionalProperties": { @@ -20380,7 +20843,20 @@ "enum": [false] } ], - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." + "description": "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout." + }, + "headerTimeout": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "boolean", + "enum": [false] + } + ], + "description": "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout." }, "chunkTimeout": { "type": "integer", @@ -20529,7 +21005,6 @@ } } }, - "required": ["input", "output"], "additionalProperties": false }, "experimental": { @@ -21220,16 +21695,20 @@ "type": "boolean" }, "sandbox": { - "type": "boolean", - "description": "Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access" + "type": "boolean" }, "sandbox_restrict_network": { - "type": "boolean", - "description": "Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)" + "type": "boolean" }, "mcp_timeout": { "type": "integer", "exclusiveMinimum": 0 + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigV2ExperimentalPolicy" + } } }, "additionalProperties": false @@ -21905,6 +22384,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -23254,6 +23736,27 @@ "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, + "WorkspaceCreateError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceCreateError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "WorkspaceWarpError": { "type": "object", "properties": { @@ -23858,6 +24361,233 @@ "required": ["_tag"], "additionalProperties": false }, + "NotebookOutput": { + "type": "object", + "properties": { + "mime": { + "type": "string", + "maxLength": 200 + }, + "text": { + "type": "string", + "maxLength": 100000 + }, + "name": { + "type": "string", + "maxLength": 500 + }, + "message": { + "type": "string", + "maxLength": 10000 + }, + "stack": { + "type": "string", + "maxLength": 50000 + }, + "omitted": { + "type": "boolean" + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["mime"], + "additionalProperties": false + }, + "NotebookCell": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + }, + "execution": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "minimum": 0 + }, + "success": { + "type": "boolean" + }, + "started": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookOutput" + }, + "maxItems": 100 + } + }, + "required": ["index", "kind", "language", "source"], + "additionalProperties": false + }, + "NotebookReadResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["read"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Expected VS Code notebook document version" + }, + "cells": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookCell" + }, + "maxItems": 2000 + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["operation", "path", "version", "cells"], + "additionalProperties": false + }, + "NotebookEditResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["edit"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Expected VS Code notebook document version" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "action": { + "type": "string", + "enum": ["insert", "replace", "delete"] + } + }, + "required": ["operation", "path", "version", "index", "action"], + "additionalProperties": false + }, + "NotebookExecuteResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["execute"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Expected VS Code notebook document version" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "status": { + "type": "string", + "enum": ["success", "error", "cancelled"] + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookOutput" + }, + "maxItems": 100 + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["operation", "path", "version", "index", "status", "outputs"], + "additionalProperties": false + }, + "NotebookResult": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotebookReadResult" + }, + { + "$ref": "#/components/schemas/NotebookEditResult" + }, + { + "$ref": "#/components/schemas/NotebookExecuteResult" + } + ] + }, + "NotebookFailure": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_version", + "timeout", + "unsupported" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + } + }, + "required": ["code", "message"], + "additionalProperties": false + }, "KilocodeSessionImportResult": { "type": "object", "properties": { @@ -24338,6 +25068,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, "time": { "type": "object", "properties": { @@ -24561,7 +25301,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -24793,7 +25533,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -25739,6 +26479,30 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerConnected": { "type": "object", "properties": { @@ -25793,166 +26557,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSandboxStatusChanged": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["sandbox.status.changed"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "directory": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "available": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "version": { - "type": "integer" - } - }, - "required": ["sessionID", "directory", "enabled", "available", "version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventKilocodeAgent_managerStart": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilocode.agent_manager.start"] - }, - "properties": { - "type": "object", - "properties": { - "requestID": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "mode": { - "type": "string", - "enum": ["worktree", "local"] - }, - "versions": { - "type": "boolean" - }, - "tasks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "prompt": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branchName": { - "type": "string" - } - }, - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 20 - } - }, - "required": ["requestID", "sessionID", "mode", "tasks"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventIndexingStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["indexing.status"] - }, - "properties": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/IndexingStatus" - } - }, - "required": ["status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventIndexingWarning": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["indexing.warning"] - }, - "properties": { - "$ref": "#/components/schemas/IndexingWarning" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventServerInstanceDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.instance.disposed"] - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventFileEdited": { "type": "object", "properties": { @@ -26458,6 +27062,46 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSandboxStatusChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sandbox.status.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "directory": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": ["sessionID", "directory", "enabled", "available", "version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionDiff": { "type": "object", "properties": { @@ -26844,6 +27488,110 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventKilocodeAgent_managerStart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.agent_manager.start"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "mode": { + "type": "string", + "enum": ["worktree", "local"] + }, + "versions": { + "type": "boolean" + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branchName": { + "type": "string" + } + }, + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": ["requestID", "sessionID", "mode", "tasks"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeNotebookRequested": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.requested"] + }, + "properties": { + "$ref": "#/components/schemas/NotebookRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeNotebookCancelled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.cancelled"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["cancelled", "disposed", "timeout"] + } + }, + "required": ["requestID", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -27394,7 +28142,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -27657,7 +28405,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -28473,6 +29221,89 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventIndexingStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["indexing.status"] + }, + "properties": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IndexingStatus" + } + }, + "required": ["status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventIndexingWarning": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["indexing.warning"] + }, + "properties": { + "$ref": "#/components/schemas/IndexingWarning" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventModels-devRefreshed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -28821,24 +29652,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventModels-devRefreshed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["models-dev.refreshed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "AccountV2OAuthCredential": { "type": "object", "properties": { @@ -28990,6 +29803,27 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "PolicyEffect": { + "type": "string", + "enum": ["allow", "deny"] + }, + "ConfigV2ExperimentalPolicy": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["provider.use"] + }, + "effect": { + "$ref": "#/components/schemas/PolicyEffect" + }, + "resource": { + "type": "string" + } + }, + "required": ["action", "effect", "resource"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { @@ -29027,7 +29861,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "cost": { @@ -29155,7 +29989,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -29534,7 +30368,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "content": { From 5c94fc771c40f3cee5ebcc1acc56f1e03248822f Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 17:27:35 +0200 Subject: [PATCH 09/29] fix(vscode): share notebook request state --- .../src/services/notebook/bridge.ts | 5 ++- .../src/services/notebook/output.ts | 10 +++-- .../tests/unit/notebook-bridge.test.ts | 19 ++++++++ .../tests/unit/notebook-core.test.ts | 1 + .../opencode/src/kilocode/tool/registry.ts | 44 ++++++++++++------- packages/opencode/src/tool/registry.ts | 6 ++- 6 files changed, 62 insertions(+), 23 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts index 374494eeef9..6e9285bec3c 100644 --- a/packages/kilo-vscode/src/services/notebook/bridge.ts +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -231,7 +231,10 @@ export class NotebookBridge { private context(directory: string): Promise { const existing = this.contexts.get(directory) if (existing) return existing - const context = this.create(directory) + const context = this.create(directory).catch((error: unknown) => { + this.contexts.delete(directory) + throw error + }) this.contexts.set(directory, context) return context } diff --git a/packages/kilo-vscode/src/services/notebook/output.ts b/packages/kilo-vscode/src/services/notebook/output.ts index 39d983552df..643748f35d3 100644 --- a/packages/kilo-vscode/src/services/notebook/output.ts +++ b/packages/kilo-vscode/src/services/notebook/output.ts @@ -14,9 +14,13 @@ const TEXT_MIMES = new Set([ function slice(data: Uint8Array, limit: number): NotebookText { const bytes = data.byteLength - const body = bytes > limit ? data.subarray(0, limit) : data - const text = decoder.decode(body) - return bytes > limit ? { text, bytes, truncated: true } : { text, bytes } + if (bytes <= limit) return { text: decoder.decode(data), bytes } + const end = (() => { + let index = limit + while (index > 0 && (data[index] & 0xc0) === 0x80) index-- + return index + })() + return { text: decoder.decode(data.subarray(0, end)), bytes, truncated: true } } export function normalizeSource(source: string, limit = NOTEBOOK_LIMITS.source): NotebookText { diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts index 73ff021e526..f64b3eb5524 100644 --- a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -139,6 +139,25 @@ describe("NotebookBridge", () => { expect(test.handlers.state).toBeUndefined() }) + it("retries context initialization after a transient failure", async () => { + const ctx = context() + const test = harness(ctx.value) + test.create.mockImplementationOnce(async () => { + throw new Error("unreadable ignore file") + }) + + test.request() + await flush() + expect(test.rejections).toHaveLength(1) + + test.request({ ...read, id: "notebook-2" }) + await flush() + expect(test.create).toHaveBeenCalledTimes(2) + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(test.replies).toHaveLength(1) + test.bridge.dispose() + }) + it("retries a failed reply without repeating the adapter operation", async () => { const ctx = context() const test = harness(ctx.value) diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts index 0c87f45d08a..09bfeae2587 100644 --- a/packages/kilo-vscode/tests/unit/notebook-core.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -102,6 +102,7 @@ describe("notebook path security", () => { describe("notebook normalization", () => { it("bounds UTF-8 source and text outputs and omits rich bodies", () => { expect(normalizeSource("abcdef", 3)).toEqual({ text: "abc", bytes: 6, truncated: true }) + expect(normalizeSource("a€b", 3)).toEqual({ text: "a", bytes: 5, truncated: true }) const normalized = normalizeOutputs( [ { diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index a02b63c16d5..e2f96524e95 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -7,6 +7,7 @@ import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./noteb import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" +import { Notebook } from "@/kilocode/notebook/service" import * as Log from "@opencode-ai/core/util/log" import { Agent } from "@/agent/agent" import * as Truncate from "@/tool/truncate" @@ -32,16 +33,19 @@ export namespace KiloToolRegistry { /** Resolve Kilo-specific tool Infos outside any InstanceState, so their Truncate/Agent deps are * satisfied at the outer registry scope instead of leaking into InstanceState's Effect. */ - export function infos() { + export function infos(notebook?: Notebook.Interface) { return Effect.gen(function* () { const codebase = yield* CodebaseSearchTool const recall = yield* RecallTool const manager = yield* AgentManagerTool const process = yield* BackgroundProcessTool - const notebookRead = yield* NotebookReadTool - const notebookEdit = yield* NotebookEditTool - const notebookExecute = yield* NotebookExecuteTool - return { codebase, recall, manager, process, notebookRead, notebookEdit, notebookExecute } + if (!notebook) return { codebase, recall, manager, process } + const tools = yield* Effect.all({ + notebookRead: NotebookReadTool, + notebookEdit: NotebookEditTool, + notebookExecute: NotebookExecuteTool, + }).pipe(Effect.provideService(Notebook.Service, notebook)) + return { codebase, recall, manager, process, ...tools } }) } @@ -53,9 +57,9 @@ export namespace KiloToolRegistry { recall: Tool.Info manager: Tool.Info process: Tool.Info - notebookRead: Tool.Info - notebookEdit: Tool.Info - notebookExecute: Tool.Info + notebookRead?: Tool.Info + notebookEdit?: Tool.Info + notebookExecute?: Tool.Info }, deps: Deps, loaders: Loaders = {}, @@ -66,12 +70,17 @@ export namespace KiloToolRegistry { recall: Tool.init(tools.recall), manager: Tool.init(tools.manager), process: Tool.init(tools.process), - notebookRead: Tool.init(tools.notebookRead), - notebookEdit: Tool.init(tools.notebookEdit), - notebookExecute: Tool.init(tools.notebookExecute), }) + const notebooks = + tools.notebookRead && tools.notebookEdit && tools.notebookExecute + ? yield* Effect.all({ + notebookRead: Tool.init(tools.notebookRead), + notebookEdit: Tool.init(tools.notebookEdit), + notebookExecute: Tool.init(tools.notebookExecute), + }) + : {} const semantic = yield* semanticTool(deps, loaders) - return { ...base, semantic } + return { ...base, ...notebooks, semantic } }) } @@ -120,9 +129,9 @@ export namespace KiloToolRegistry { recall: Tool.Def manager: Tool.Def process: Tool.Def - notebookRead: Tool.Def - notebookEdit: Tool.Def - notebookExecute: Tool.Def + notebookRead?: Tool.Def + notebookEdit?: Tool.Def + notebookExecute?: Tool.Def }, cfg: { experimental?: { codebase_search?: boolean } }, ): Tool.Def[] { @@ -132,8 +141,9 @@ export namespace KiloToolRegistry { tools.recall, ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), // The extension is the only client that can consume the Agent Manager start event. - ...(Flag.KILO_CLIENT === "vscode" - ? [tools.manager, tools.notebookRead, tools.notebookEdit, tools.notebookExecute] + ...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []), + ...(Flag.KILO_CLIENT === "vscode" && tools.notebookRead && tools.notebookEdit && tools.notebookExecute + ? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute] : []), ] } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 576dc9000ec..36c602bf667 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -39,7 +39,7 @@ import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Option } from "effect" import { HttpClient } from "effect/unstable/http" // kilocode_change import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -151,7 +151,8 @@ export const layer: Layer.Layer< const agent = yield* Agent.Service // kilocode_change start const suggesttool = yield* SuggestTool - const kiloToolInfos = yield* KiloToolRegistry.infos().pipe(Effect.provide(Notebook.defaultLayer)) + const notebook = Option.getOrUndefined(yield* Effect.serviceOption(Notebook.Service)) + const kiloToolInfos = yield* KiloToolRegistry.infos(notebook) // kilocode_change end const state = yield* InstanceState.make( @@ -451,6 +452,7 @@ export const defaultLayer = Layer.suspend( // kilocode_change start - provide Kilo-owned registry dependencies .pipe( Layer.provide(Command.defaultLayer), + Layer.provide(Notebook.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(SessionStatus.defaultLayer), ), From 9d6f010ffc751edf3862199130a8f2125db0ca9a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 17:57:17 +0200 Subject: [PATCH 10/29] test(cli): cover notebook HTTP routes --- packages/opencode/src/tool/registry.ts | 2 +- .../test/server/httpapi-exercise/index.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 36c602bf667..709af95a942 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -39,7 +39,7 @@ import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context, Option } from "effect" +import { Effect, Layer, Context, Option } from "effect" // kilocode_change import { HttpClient } from "effect/unstable/http" // kilocode_change import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 407b924b0d6..499bb51202a 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -236,6 +236,25 @@ const scenarios: Scenario[] = [ })) .json(404, object, "status"), http.protected.get("/question", "question.list").json(200, array), + // kilocode_change start + http.protected.get("/kilocode/notebook", "kilocode.notebook.list").json(200, array), + http.protected + .post("/kilocode/notebook/{requestID}/reply", "kilocode.notebook.reply") + .at((ctx) => ({ + path: route("/kilocode/notebook/{requestID}/reply", { requestID: "nbr_httpapi_reply" }), + headers: ctx.headers(), + body: { result: { operation: "read", path: "notebook.ipynb", version: 1, cells: [] } }, + })) + .json(404, object, "status"), + http.protected + .post("/kilocode/notebook/{requestID}/reject", "kilocode.notebook.reject") + .at((ctx) => ({ + path: route("/kilocode/notebook/{requestID}/reject", { requestID: "nbr_httpapi_reject" }), + headers: ctx.headers(), + body: { error: { code: "not_found", message: "Notebook not found" } }, + })) + .json(404, object, "status"), + // kilocode_change end http.protected .post("/question/{requestID}/reply", "question.reply.invalid") .at((ctx) => ({ From 2a17d490502cdf51574e033239323f9c105efea6 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 09:39:04 +0200 Subject: [PATCH 11/29] docs(vscode): add native notebook tools plan --- .../1782310595680-native-notebook-tools.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .kilo/plans/1782310595680-native-notebook-tools.md diff --git a/.kilo/plans/1782310595680-native-notebook-tools.md b/.kilo/plans/1782310595680-native-notebook-tools.md new file mode 100644 index 00000000000..bab76137deb --- /dev/null +++ b/.kilo/plans/1782310595680-native-notebook-tools.md @@ -0,0 +1,119 @@ +# Native VS Code notebook tools + +## Goal + +Integrate live notebook inspection, editing, and execution into the Kilo VS Code extension without installing, configuring, or embedding a second MCP server. Expose the capabilities only to CLI backends started by the VS Code extension, while keeping Microsoft Jupyter optional. + +## Decisions + +- Expose three focused tools: `notebook_read`, `notebook_edit`, and `notebook_execute`. +- Use the existing authenticated CLI server, global SSE stream, and HTTP SDK for a correlated extension-host request/reply bridge. +- Require an explicit workspace-relative notebook path for every targeted operation. +- Resolve paths against the SSE request directory, enforce containment and ignore rules, and never fall back to the globally active notebook. +- Permit notebooks to be opened in the background for reads and edits. +- Never reveal/focus a notebook or invoke the kernel picker. Execution fails with an actionable error if no controller/kernel is selected. +- Keep Jupyter optional and use stable generic VS Code Notebook APIs and commands. +- Use separate `notebook_read`, `notebook_edit`, and `notebook_execute` permissions. +- Leave edits dirty and preserve VS Code undo/redo and existing autosave behavior. +- Require the expected notebook version for index-based mutations/execution and reject stale requests. +- `notebook_read` returns structure and source by default; `include_outputs: true` adds bounded text/error outputs. +- `notebook_edit` supports inserting, replacing, and deleting one cell per call. +- `notebook_execute` runs one explicit code cell per call and returns only the newly completed execution result. +- Enable the tools by default for VS Code sessions; omit them from other clients. +- Defer cell movement, bulk operations, search, outline generation, metadata editing, output clearing, rich MIME/images, variable introspection, and kernel-context inspection. + +## Implementation plan + +1. **Define a Kilo-owned notebook host protocol in the CLI.** + - Add request, result, and structured error schemas under `packages/opencode/src/kilocode/`. + - Include request ID, session ID, operation, relative notebook path, expected notebook version where applicable, cell index, and operation-specific arguments. + - Define a typed bus event for pending notebook requests and ensure it is imported before the global bus/OpenAPI event union is constructed. + +2. **Implement the pending request lifecycle.** + - Follow the `Question.Service` pattern: store a deferred result, publish the request event, and await an authenticated reply. + - Add cancellation, bounded timeout, instance-disposal cleanup, duplicate/late-reply handling, and typed rejection. + - Add a pending-list operation so the extension can recover requests after an SSE/backend reconnect. + - Ensure every terminal path removes pending state and listeners. + +3. **Add authenticated Kilo HTTP endpoints.** + - Extend the existing Kilo-owned API group rather than adding another shared upstream route integration where possible. + - Add endpoints to list pending requests and reply/reject by request ID. + - Preserve directory scoping through the existing SDK request conventions. + - Regenerate `packages/sdk/js/` with `./script/generate.ts`; do not edit generated files manually. + +4. **Register the three CLI tools.** + - Add Kilo-owned tool implementations and register them through `packages/opencode/src/kilocode/tool/registry.ts` only when `Flag.KILO_CLIENT === "vscode"`. + - Give each tool a narrow non-polymorphic schema and its dedicated permission. + - `notebook_read`: list eligible/open notebooks when appropriate, or read one explicit notebook; include outputs only when requested. + - `notebook_edit`: insert, replace, or delete one cell and require the expected notebook version. + - `notebook_execute`: execute one explicit code-cell index and require the expected notebook version. + - Return compact structured text suitable for the model and apply aggregate output limits. + +5. **Implement a singleton extension-host notebook bridge.** + - Add a service under `packages/kilo-vscode/src/services/notebook/` and instantiate it once from extension activation, not once per `KiloProvider`. + - Subscribe through `KiloConnectionService.onEventFiltered()`, retain the SSE directory, deduplicate request IDs, and recover pending requests after reconnect. + - Post results/rejections through the generated authenticated SDK client. + - Dispose subscriptions and reject/stop in-flight work on extension shutdown or backend replacement. + +6. **Implement a narrow VS Code notebook adapter.** + - Resolve the explicit relative path against the request directory and verify containment and ignore/access policy before opening anything. + - Prefer an already-open `NotebookDocument` so dirty in-memory state is authoritative; otherwise use `workspace.openNotebookDocument()` without showing it. + - Never use active-editor fallback for targeted operations. + - Normalize notebook/cell data into protocol-owned values rather than serializing VS Code objects. + - Bound source/output sizes and represent omitted rich MIME/image data by MIME type and omission metadata. + +7. **Implement reading and editing.** + - Read cell index, kind, language, source, notebook version, and execution summary where stable. + - Include only bounded text and error outputs when `include_outputs` is true; omit image/HTML/binary payload bodies in v1. + - Build `WorkspaceEdit`/`NotebookEdit` operations for insert, replacement, and deletion so changes use the notebook model and undo stack. + - Validate notebook version and cell index immediately before applying an edit. + - Do not save automatically. + +8. **Implement deterministic execution.** + - Validate that the indexed cell is a code cell and that the notebook/version still matches. + - Subscribe to notebook execution/output changes before dispatching the standard VS Code notebook execution command. + - Correlate completion to the requested notebook and cell, requiring a new execution cycle rather than accepting stale `executionSummary` or existing outputs. + - Return the resulting bounded text/error outputs and execution status. + - Honor tool cancellation and timeout, clean up listeners in every path, and return actionable errors for no controller/kernel, closed document, changed cell, extension disconnect, or execution failure. + - Do not reveal the notebook or invoke kernel selection. + +9. **Add CLI tests.** + - Verify VS Code-only tool registration and absence in other clients. + - Verify each dedicated permission request. + - Test event publication with session and directory context, reply completion, rejection, cancellation, timeout, disposal, pending recovery, and duplicate/late replies. + - Test schemas, output bounds, stale-version failures, and structured errors using actual service behavior rather than duplicating implementation logic. + +10. **Add extension tests.** + - Unit-test path containment, worktree routing, ignore enforcement, open-document preference, background opening, version conflicts, cell validation, edit construction, output normalization/truncation, deduplication, reconnect recovery, cancellation, and listener cleanup. + - Add an extension-host integration test with a test notebook serializer/controller. Open a temporary notebook, read live unsaved source, edit a cell, execute one cell, publish outputs, and verify the CLI receives the normalized result. + - Include a no-controller test proving execution fails without changing focus. + - Keep Jupyter-specific behavior as an optional manual/smoke validation, not a CI dependency. + +11. **Release and verification.** + - Add a user-facing minor changeset describing native notebook reading, editing, and execution in VS Code. + - If substantial upstream MIT code is copied, retain its copyright and license notice; prefer adapting concepts and small helpers instead of porting the upstream MCP server or monolithic handlers. + - Run targeted CLI tests and `bun run typecheck` from `packages/opencode/`. + - Run extension typecheck, lint, targeted unit/integration tests, compile/package checks as needed, and `bun run knip` from `packages/kilo-vscode/`. + - Run `bun run script/check-opencode-annotations.ts` and `bun run script/check-opencode-promise-facades.ts` when affected. + - Confirm ordinary Kilo activation and static `.ipynb` reads still work with Jupyter absent. + +## Failure and security requirements + +- Do not start a localhost MCP server, allocate an extra port, or write user MCP configuration. +- Do not allow a request from one workspace/worktree to operate on a notebook outside its directory. +- Do not use editor focus as authority. +- Treat notebook outputs as sensitive and potentially unbounded. +- Treat kernel execution as arbitrary code execution with its own permission. +- Fail closed on stale notebook versions, missing kernels/controllers, disconnects, unsupported notebook providers, and malformed replies. +- Avoid fixed sleeps; use VS Code events and correlated request state. + +## Out of scope + +- Support for notebook operations in the standalone CLI/TUI or non-VS Code clients +- Automatic installation or activation of Microsoft Jupyter +- Automatic kernel selection or UI focus changes +- Cell movement or bulk editing +- Search and outline tools +- Rich image, HTML, widget, or arbitrary MIME transport +- Kernel namespace/variable introspection +- Editing notebook metadata or clearing outputs From 75d13ef70137af79716556815903d91ae3336b9a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:24:53 +0200 Subject: [PATCH 12/29] fix(vscode): stabilize native notebook tools --- .changeset/native-notebook-tools.md | 2 +- .../src/services/notebook/adapter.ts | 240 ++- .../src/services/notebook/bridge.ts | 17 +- .../kilo-vscode/src/services/notebook/path.ts | 55 +- .../src/services/notebook/revision.ts | 38 + .../src/services/notebook/types.ts | 14 +- .../tests/unit/notebook-bridge.test.ts | 55 +- .../tests/unit/notebook-core.test.ts | 344 +++- .../src/kilocode/notebook/protocol.ts | 39 +- .../opencode/src/kilocode/notebook/service.ts | 16 +- .../src/kilocode/tool/notebook-host.ts | 52 +- .../test/kilocode/notebook-service.test.ts | 63 +- .../test/kilocode/notebook-tools.test.ts | 28 +- packages/sdk/js/src/v2/gen/types.gen.ts | 481 +++--- packages/sdk/openapi.json | 1399 +++++++++-------- 15 files changed, 1701 insertions(+), 1142 deletions(-) create mode 100644 packages/kilo-vscode/src/services/notebook/revision.ts diff --git a/.changeset/native-notebook-tools.md b/.changeset/native-notebook-tools.md index 9dc1f661299..3d47f0ef1e0 100644 --- a/.changeset/native-notebook-tools.md +++ b/.changeset/native-notebook-tools.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Support reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code. +Support safely reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code, including absolute workspace paths and content-based edit conflict detection. diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index bb34caa8190..8c2f393817e 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -2,6 +2,7 @@ import path from "node:path" import * as vscode from "vscode" import { normalizeOutputs, normalizeSource } from "./output" import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +import { fingerprint, notebookState, sameCell, type NotebookState } from "./revision" import { NOTEBOOK_LIMITS, type NotebookAccess, @@ -16,6 +17,14 @@ import { type NotebookReadResult, } from "./types" +const RETAINED_REVISIONS = 1_000 +const revisions = new Map() +const locks = new Map>() + +function revisionKey(target: string, revision: string): string { + return `${target}\0${revision}` +} + export interface NotebookAdapterOptions { deps?: NotebookAdapterDeps paths?: NotebookPathDeps @@ -87,15 +96,19 @@ export class NotebookAdapter { private async document( directory: string, - relative: string, - ): Promise<{ document: vscode.NotebookDocument; path: string }> { - const target = await resolveNotebookPath(directory, relative, this.access, this.options.paths) - const open = this.deps.documents().find((document) => path.resolve(document.uri.fsPath) === path.resolve(target)) - const document = open ?? (await this.deps.open(this.deps.uri(target))) + input: string, + ): Promise<{ document: vscode.NotebookDocument; path: string; target: string }> { + const resolved = await resolveNotebookPath(directory, input, this.access, this.options.paths) + const open = this.deps + .documents() + .find((document) => path.resolve(document.uri.fsPath) === path.resolve(resolved.target)) + const document = open ?? (await this.deps.open(this.deps.uri(resolved.target))) if (document.isClosed) { - throw new NotebookError("closed", "Notebook document is closed") + throw new NotebookError("closed", `Notebook ${JSON.stringify(resolved.relative)} is closed`, { + path: resolved.relative, + }) } - return { document, path: target } + return { document, path: resolved.relative, target: resolved.target } } async read(request: NotebookReadRequest): Promise { @@ -105,6 +118,7 @@ export class NotebookAdapter { const flags = { sources: false, outputs: false } const source = loaded.document.getCells() + const state = this.remember(loaded.document, loaded.target) if (source.length > 2_000) { flags.sources = true } @@ -138,8 +152,9 @@ export class NotebookAdapter { return { operation: "read", - path: request.path, - version: loaded.document.version, + path: loaded.path, + requestPath: request.path, + revision: state.revision, cells, ...(flags.sources || flags.outputs ? { truncated: true } : {}), } @@ -147,52 +162,86 @@ export class NotebookAdapter { async edit(request: NotebookEditRequest): Promise { const loaded = await this.document(request.directory, request.path) - this.version(loaded.document, request.version) - const count = loaded.document.cellCount - const max = request.edit.action === "insert" ? count : count - 1 - if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) { - throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`) - } + return this.lock(loaded.target, async () => { + const before = this.remember(loaded.document, loaded.target) + this.revision(before, request.expectedRevision, loaded.path, request.index) + const count = loaded.document.cellCount + const max = request.edit.action === "insert" ? count : count - 1 + if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) { + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, { + path: loaded.path, + index: request.index, + }) + } - const edits = (() => { - if (request.edit.action === "delete") { - return [this.deps.delete(request.index)] + const expected = [...before.cells] + const edits = (() => { + if (request.edit.action === "delete") { + expected.splice(request.index, 1) + return [this.deps.delete(request.index)] + } + const language = request.edit.language ?? (request.edit.kind === "code" ? "plaintext" : "markdown") + const cell = this.deps.cell({ + kind: request.edit.kind, + language: request.edit.language, + source: request.edit.source, + }) + const value = fingerprint(request.edit.kind, language, request.edit.source) + if (request.edit.action === "insert") { + expected.splice(request.index, 0, value) + return [this.deps.insert(request.index, [cell])] + } + expected.splice(request.index, 1, value) + return [this.deps.replace(request.index, [cell])] + })() + this.revision(this.remember(loaded.document, loaded.target), request.expectedRevision, loaded.path, request.index) + if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) { + throw new NotebookError("unsupported", "VS Code rejected the notebook edit", { + path: loaded.path, + index: request.index, + }) } - const cell = this.deps.cell({ - kind: request.edit.kind, - language: request.edit.language, - source: request.edit.source, - }) - if (request.edit.action === "insert") { - return [this.deps.insert(request.index, [cell])] + const after = this.remember(loaded.document, loaded.target) + if (after.cells.length !== expected.length || after.cells.some((value, index) => value !== expected[index])) { + throw this.stale(loaded.path, request.index, after.revision, "Notebook content changed while applying the edit") } - return [this.deps.replace(request.index, [cell])] - })() - this.version(loaded.document, request.version) - if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) { - throw new NotebookError("unsupported", "VS Code rejected the notebook edit") - } - return { - operation: "edit", - path: request.path, - version: loaded.document.version, - index: request.index, - action: request.edit.action, - } + const result: NotebookEditResult = { + operation: "edit", + path: loaded.path, + requestPath: request.path, + revision: after.revision, + index: request.index, + action: request.edit.action, + } + if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) { + result.cell = this.cell(loaded.document.cellAt(request.index), request.index) + } + return result + }) } async execute(request: NotebookExecuteRequest): Promise { const loaded = await this.document(request.directory, request.path) - this.version(loaded.document, request.version) + const state = this.remember(loaded.document, loaded.target) if (!Number.isInteger(request.index) || request.index < 0 || request.index >= loaded.document.cellCount) { - throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`) + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, { + path: loaded.path, + index: request.index, + }) + } + const expected = revisions.get(revisionKey(loaded.target, request.expectedRevision)) + if (state.revision !== request.expectedRevision && !sameCell(expected?.cells[request.index], state.cells[request.index])) { + throw this.stale(loaded.path, request.index, state.revision, "The targeted notebook cell changed") } const cell = loaded.document.cellAt(request.index) if (cell.kind !== vscode.NotebookCellKind.Code) { - throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`) + throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`, { + path: loaded.path, + index: request.index, + }) } - const result = this.wait(loaded.document, cell, request) + const result = this.wait(loaded.document, cell, loaded.target, loaded.path, state.cells[request.index]!, request) void this.deps .execute("notebook.cell.execute", { ranges: [{ start: request.index, end: request.index + 1 }], @@ -200,12 +249,24 @@ export class NotebookAdapter { }) .catch((error: unknown) => { const detail = error instanceof Error ? error.message : String(error) - result.reject(new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`)) + result.reject( + new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`, { + path: loaded.path, + index: request.index, + }), + ) }) return result.promise } - private wait(document: vscode.NotebookDocument, cell: vscode.NotebookCell, request: NotebookExecuteRequest) { + private wait( + document: vscode.NotebookDocument, + cell: vscode.NotebookCell, + target: string, + path: string, + fingerprint: NotebookState["cells"][number], + request: NotebookExecuteRequest, + ) { const state: { done: boolean timer?: ReturnType @@ -250,8 +311,9 @@ export class NotebookAdapter { const normalized = normalizeOutputs(cell.outputs) holder.resolve?.({ operation: "execute", - path: request.path, - version: document.version, + path, + requestPath: request.path, + revision: this.remember(document, target).revision, index: request.index, status: cell.executionSummary?.success === false ? "error" : "success", outputs: normalized.outputs, @@ -265,7 +327,12 @@ export class NotebookAdapter { }) const abort = () => { stop() - reject(new NotebookError("cancelled", "Notebook execution cancellation was requested")) + reject( + new NotebookError("cancelled", "Notebook execution cancellation was requested", { + path, + index: request.index, + }), + ) } disposables.push( @@ -273,22 +340,32 @@ export class NotebookAdapter { if (event.notebook !== document) { return } - if (document.isClosed || document.cellCount <= request.index || document.cellAt(request.index) !== cell) { - reject(new NotebookError("stale_version", "Notebook cell changed during execution")) + const current = this.remember(document, target) + if ( + document.isClosed || + document.cellCount <= request.index || + document.cellAt(request.index) !== cell || + !sameCell(fingerprint, current.cells[request.index]) + ) { + reject(this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution")) + stop() return } const change = event.cellChanges.find((item) => item.cell === cell) if (!change) { return } + if (state.startup && (change.outputs !== undefined || change.executionSummary !== undefined)) { + clearTimeout(state.startup) + state.startup = undefined + } const summary = change.executionSummary if (!summary || !changed(base, summary)) return - if (state.startup) clearTimeout(state.startup) if (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve() }), this.deps.close((closed) => { if (closed === document) { - reject(new NotebookError("closed", "Notebook closed during execution")) + reject(new NotebookError("closed", "Notebook closed during execution", { path, index: request.index })) } }), ) @@ -298,8 +375,9 @@ export class NotebookAdapter { stop() reject( new NotebookError( - "no_kernel", - "Notebook execution did not start; open the notebook in VS Code and select a kernel", + "execution_failed", + "The execution command was dispatched, but VS Code reported no execution activity before the startup timeout. Ensure a kernel is selected and try again", + { path, index: request.index }, ), ) }, @@ -307,7 +385,7 @@ export class NotebookAdapter { ) state.timer = setTimeout(() => { stop() - reject(new NotebookError("timeout", "Notebook execution timed out")) + reject(new NotebookError("timeout", "Notebook execution timed out", { path, index: request.index })) }, timeout) if (request.signal?.aborted) { abort() @@ -317,12 +395,54 @@ export class NotebookAdapter { return { promise, reject } } - private version(document: vscode.NotebookDocument, expected: number): void { - if (document.version !== expected) { - throw new NotebookError( - "stale_version", - `Notebook version changed (expected ${expected}, current ${document.version})`, - ) + private cell(cell: vscode.NotebookCell, index: number): NotebookCell { + return { + index, + kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + language: cell.document.languageId.slice(0, 200), + source: cell.document.getText().slice(0, 200_000), + execution: execution(cell.executionSummary), + } + } + + private remember(document: vscode.NotebookDocument, target: string): NotebookState { + const state = notebookState(document) + const key = revisionKey(target, state.revision) + revisions.delete(key) + revisions.set(key, state) + if (revisions.size > RETAINED_REVISIONS) { + const oldest = revisions.keys().next().value + if (oldest !== undefined) revisions.delete(oldest) + } + return state + } + + private revision(state: NotebookState, expected: string, path: string, index: number): void { + if (state.revision !== expected) { + throw this.stale(path, index, state.revision, "Notebook content changed") + } + } + + private stale(path: string, index: number, revision: string, detail: string): NotebookError { + return new NotebookError( + "stale_revision", + `${detail}. Re-read ${JSON.stringify(path)} before retrying; do not blindly replay an index-based edit`, + { path, index, currentRevision: revision }, + ) + } + + private async lock(key: string, task: () => Promise): Promise { + const prior = locks.get(key) ?? Promise.resolve() + const gate: { release?: () => void } = {} + const current = new Promise((resolve) => (gate.release = resolve)) + const next = prior.then(() => current) + locks.set(key, next) + await prior + try { + return await task() + } finally { + gate.release?.() + if (locks.get(key) === next) locks.delete(key) } } } diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts index 6e9285bec3c..adbaeff8c5f 100644 --- a/packages/kilo-vscode/src/services/notebook/bridge.ts +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -22,7 +22,7 @@ const CODES = new Set([ "invalid_path", "no_kernel", "not_found", - "stale_version", + "stale_revision", "timeout", "unsupported", ]) @@ -64,9 +64,16 @@ async function createContext(directory: string): Promise } function failure(error: unknown): NotebookFailure { - const message = error instanceof Error ? error.message : String(error) + const detail = error instanceof Error ? error.message : String(error) + const message = (detail || "Notebook operation failed without an error message").slice(0, 10_000) if (error instanceof NotebookError && CODES.has(error.code as NotebookFailure["code"])) { - return { code: error.code as NotebookFailure["code"], message } + return { + code: error.code as NotebookFailure["code"], + message, + ...(error.path !== undefined ? { path: error.path } : {}), + ...(error.index !== undefined ? { index: error.index } : {}), + ...(error.currentRevision !== undefined ? { currentRevision: error.currentRevision } : {}), + } } return { code: "execution_failed", message } } @@ -214,7 +221,7 @@ export class NotebookBridge { return adapter.edit({ path: request.path, directory, - version: request.version, + expectedRevision: request.expectedRevision, index: request.index, edit: request.edit, }) @@ -222,7 +229,7 @@ export class NotebookBridge { return adapter.execute({ path: request.path, directory, - version: request.version, + expectedRevision: request.expectedRevision, index: request.index, signal, }) diff --git a/packages/kilo-vscode/src/services/notebook/path.ts b/packages/kilo-vscode/src/services/notebook/path.ts index 5278a1a1cbc..f165899dff5 100644 --- a/packages/kilo-vscode/src/services/notebook/path.ts +++ b/packages/kilo-vscode/src/services/notebook/path.ts @@ -4,13 +4,27 @@ import type { NotebookAccess } from "./types" const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[/\\]/ +export interface NotebookErrorDetails { + path?: string + index?: number + currentRevision?: string +} + export class NotebookError extends Error { + readonly path?: string + readonly index?: number + readonly currentRevision?: string + constructor( public readonly code: string, message: string, + details: NotebookErrorDetails = {}, ) { super(message) this.name = "NotebookError" + this.path = details.path + this.index = details.index + this.currentRevision = details.currentRevision } } @@ -18,6 +32,11 @@ export interface NotebookPathDeps { realpath(path: string): Promise } +export interface NotebookPath { + target: string + relative: string +} + const defaults: NotebookPathDeps = { realpath: fs.realpath } function contained(root: string, target: string): boolean { @@ -25,31 +44,43 @@ function contained(root: string, target: string): boolean { return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) } +function invalid(input: string, reason: string): NotebookError { + return new NotebookError( + "invalid_path", + `Invalid notebook path ${JSON.stringify(input)}: ${reason}. Use a path relative to the request directory or an absolute path inside it`, + { path: input }, + ) +} + export async function resolveNotebookPath( directory: string, - relative: string, + input: string, access: NotebookAccess, deps: NotebookPathDeps = defaults, -): Promise { - if (!relative || relative.length > 4_096 || path.isAbsolute(relative) || WINDOWS_ABSOLUTE.test(relative)) { - throw new NotebookError("invalid_path", "Notebook path must be workspace-relative") +): Promise { + if (!input || input.length > 4_096 || input.includes("\0")) { + throw invalid(input, "the path is empty, too long, or malformed") + } + if (WINDOWS_ABSOLUTE.test(input) && !path.win32.isAbsolute(directory)) { + throw invalid(input, "the absolute path uses a different platform format") } - const root = await deps.realpath(path.resolve(directory)) - const candidate = path.resolve(root, relative) - if (!contained(root, candidate)) { - throw new NotebookError("invalid_path", "Notebook path is outside the request directory") + const base = path.resolve(directory) + const root = await deps.realpath(base) + const candidate = path.resolve(base, input) + if (!path.isAbsolute(input) && !contained(base, candidate)) { + throw invalid(input, "it is outside the request directory") } const target = await deps.realpath(candidate).catch((error: unknown) => { const detail = error instanceof Error ? error.message : String(error) - throw new NotebookError("not_found", `Cannot resolve notebook: ${detail}`) + throw new NotebookError("not_found", `Cannot resolve notebook ${JSON.stringify(input)}: ${detail}`, { path: input }) }) if (!contained(root, target)) { - throw new NotebookError("invalid_path", "Notebook resolves outside the request directory") + throw invalid(input, "it resolves through a symlink outside the request directory") } if (!(await access.validateAccess(target))) { - throw new NotebookError("invalid_path", "Notebook is excluded by workspace access rules") + throw invalid(input, "it is excluded by workspace access or ignore rules") } - return target + return { target, relative: path.relative(root, target).split(path.sep).join("/") } } diff --git a/packages/kilo-vscode/src/services/notebook/revision.ts b/packages/kilo-vscode/src/services/notebook/revision.ts new file mode 100644 index 00000000000..7866bcab9e1 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/revision.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto" +import * as vscode from "vscode" + +export interface NotebookState { + revision: string + cells: string[] +} + +function hash(parts: string[]): string { + const value = createHash("sha256") + for (const part of parts) { + value.update(String(Buffer.byteLength(part))) + value.update(":") + value.update(part) + } + return value.digest("base64url") +} + +export function fingerprint(kind: "code" | "markdown", language: string, source: string): string { + return hash([kind, language, source]) +} + +function cell(cell: vscode.NotebookCell): string { + return fingerprint( + cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + cell.document.languageId, + cell.document.getText(), + ) +} + +export function notebookState(document: vscode.NotebookDocument): NotebookState { + const cells = document.getCells().map(cell) + return { revision: `content:${hash(cells)}`, cells } +} + +export function sameCell(left: string | undefined, right: string | undefined): boolean { + return left !== undefined && left === right +} diff --git a/packages/kilo-vscode/src/services/notebook/types.ts b/packages/kilo-vscode/src/services/notebook/types.ts index e8e2acd4b51..ec502dc15c0 100644 --- a/packages/kilo-vscode/src/services/notebook/types.ts +++ b/packages/kilo-vscode/src/services/notebook/types.ts @@ -45,7 +45,8 @@ export interface NotebookCell { export interface NotebookReadResult { operation: "read" path: string - version: number + requestPath: string + revision: string cells: NotebookCell[] truncated?: boolean } @@ -53,15 +54,18 @@ export interface NotebookReadResult { export interface NotebookEditResult { operation: "edit" path: string - version: number + requestPath: string + revision: string index: number action: "insert" | "replace" | "delete" + cell?: NotebookCell } export interface NotebookExecuteResult { operation: "execute" path: string - version: number + requestPath: string + revision: string index: number status: "success" | "error" | "cancelled" outputs: NotebookOutput[] @@ -88,7 +92,7 @@ export interface NotebookReadRequest { export interface NotebookEditRequest { path: string directory: string - version: number + expectedRevision: string index: number edit: NotebookEdit } @@ -96,7 +100,7 @@ export interface NotebookEditRequest { export interface NotebookExecuteRequest { path: string directory: string - version: number + expectedRevision: string index: number signal?: AbortSignal timeout?: number diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts index f64b3eb5524..37c489de6c6 100644 --- a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -90,18 +90,20 @@ function harness(context: NotebookBridgeContext, dirs = ["/repo"]) { function context(overrides: Partial = {}) { const dispose = mock(() => undefined) const adapter = { - read: mock(async () => ({ operation: "read" as const, path: "book.ipynb", version: 2, cells: [] })), + read: mock(async () => ({ operation: "read" as const, path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] })), edit: mock(async () => ({ operation: "edit" as const, path: "book.ipynb", - version: 2, + requestPath: "book.ipynb", + revision: "content:2", index: 0, action: "replace" as const, })), execute: mock(async () => ({ operation: "execute" as const, path: "book.ipynb", - version: 2, + requestPath: "book.ipynb", + revision: "content:2", index: 0, status: "success" as const, outputs: [], @@ -128,7 +130,7 @@ describe("NotebookBridge", () => { { requestID: "notebook-1", directory: "/repo", - result: { operation: "read", path: "book.ipynb", version: 2, cells: [] }, + result: { operation: "read", path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] }, }, ]) @@ -191,7 +193,7 @@ describe("NotebookBridge", () => { sessionID: "session-1", operation: "execute", path: "book.ipynb", - version: 1, + expectedRevision: "content:1", index: 0, }) await flush() @@ -205,10 +207,43 @@ describe("NotebookBridge", () => { test.bridge.dispose() }) + it("bounds unexpected adapter failures for the protocol", async () => { + const ctx = context({ + read: mock(async () => { + throw new Error("x".repeat(20_000)) + }), + }) + const test = harness(ctx.value) + test.request() + await flush() + expect((test.rejections[0] as { error: { code: string; message: string } }).error).toMatchObject({ + code: "execution_failed", + message: "x".repeat(10_000), + }) + test.bridge.dispose() + + const empty = context({ + read: mock(async () => { + throw new Error("") + }), + }) + const fallback = harness(empty.value) + fallback.request() + await flush() + expect((fallback.rejections[0] as { error: { message: string } }).error.message).toBe( + "Notebook operation failed without an error message", + ) + fallback.bridge.dispose() + }) + it("recovers pending requests for known directories and maps adapter failures", async () => { const ctx = context({ read: mock(async () => { - throw new NotebookError("stale_version", "Notebook changed") + throw new NotebookError("stale_revision", "Notebook changed", { + path: "book.ipynb", + index: 0, + currentRevision: "content:current", + }) }), }) const test = harness(ctx.value, ["/root", "/worktree"]) @@ -221,7 +256,13 @@ describe("NotebookBridge", () => { { requestID: "notebook-1", directory: "/worktree", - error: { code: "stale_version", message: "Notebook changed" }, + error: { + code: "stale_revision", + message: "Notebook changed", + path: "book.ipynb", + index: 0, + currentRevision: "content:current", + }, }, ]) test.bridge.dispose() diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts index 09bfeae2587..b15af442830 100644 --- a/packages/kilo-vscode/tests/unit/notebook-core.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -5,31 +5,59 @@ import { normalizeOutputs, normalizeSource } from "../../src/services/notebook/o import { NotebookError, resolveNotebookPath } from "../../src/services/notebook/path" import type { NotebookAdapterDeps, NotebookCellInput } from "../../src/services/notebook/types" +interface CellState { + source: string + kind: vscode.NotebookCellKind + language: string + outputs: vscode.NotebookCellOutput[] + execution?: vscode.NotebookCellExecutionSummary +} + function uri(path: string): vscode.Uri { return { scheme: "file", fsPath: path, path, toString: () => `file://${path}` } as vscode.Uri } -function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code): vscode.NotebookCell { - return { +function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code, language?: string) { + const state: CellState = { + source, kind, - document: { getText: () => source, languageId: kind === vscode.NotebookCellKind.Code ? "python" : "markdown" }, + language: language ?? (kind === vscode.NotebookCellKind.Code ? "python" : "markdown"), outputs: [], - executionSummary: undefined, + } + const value = { + get kind() { + return state.kind + }, + document: { + getText: () => state.source, + get languageId() { + return state.language + }, + }, + get outputs() { + return state.outputs + }, + get executionSummary() { + return state.execution + }, } as unknown as vscode.NotebookCell + return { value, state } } -function notebook(cells: vscode.NotebookCell[], version = 1): vscode.NotebookDocument { +function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vscode.NotebookDocument { return { - uri: uri("/repo/book.ipynb"), - version, + uri: uri(file), + version: 1, isClosed: false, - cellCount: cells.length, + get cellCount() { + return cells.length + }, getCells: () => cells, cellAt: (index: number) => cells[index]!, } as unknown as vscode.NotebookDocument } -function harness(document: vscode.NotebookDocument) { +function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]) { const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>() const closes = new Set<(document: vscode.NotebookDocument) => void>() const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown } @@ -39,8 +67,21 @@ function harness(document: vscode.NotebookDocument) { calls.open++ return document }, - apply: async () => { + apply: async (edit) => { calls.apply++ + const item = (edit as unknown as { edits: Array<{ type: string; index: number; cells?: vscode.NotebookCellData[] }> }) + .edits[0]! + if (item.type === "delete") cells.splice(item.index, 1) + if (item.type !== "delete") { + const input = (item.cells?.[0] as unknown as { input: NotebookCellInput }).input + const next = cell( + input.source, + input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + input.language ?? (input.kind === "code" ? "plaintext" : "markdown"), + ).value + if (item.type === "insert") cells.splice(item.index, 0, next) + if (item.type === "replace") cells.splice(item.index, 1, next) + } Object.assign(document, { version: document.version + 1 }) return true }, @@ -61,8 +102,8 @@ function harness(document: vscode.NotebookDocument) { calls.edit = edits return { edits } as unknown as vscode.WorkspaceEdit }, - insert: (index, cells) => ({ type: "insert", index, cells }) as unknown as vscode.NotebookEdit, - replace: (index, cells) => ({ type: "replace", index, cells }) as unknown as vscode.NotebookEdit, + insert: (index, items) => ({ type: "insert", index, cells: items }) as unknown as vscode.NotebookEdit, + replace: (index, items) => ({ type: "replace", index, cells: items }) as unknown as vscode.NotebookEdit, delete: (index) => ({ type: "delete", index }) as unknown as vscode.NotebookEdit, cell: (input: NotebookCellInput) => ({ input }) as unknown as vscode.NotebookCellData, } @@ -72,21 +113,59 @@ function harness(document: vscode.NotebookDocument) { const paths = { realpath: async (value: string) => value } const access = { validateAccess: mock(() => true) } -function adapter(document: vscode.NotebookDocument, deps = harness(document)) { - return { adapter: new NotebookAdapter(access, { deps: deps.deps, paths, timeout: 50 }), ...deps } +function adapter(items: ReturnType[], file = "/repo/book.ipynb") { + const cells = items.map((item) => item.value) + const document = notebook(cells, file) + const ctx = harness(document, cells) + return { + adapter: new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }), + document, + cells, + ...ctx, + } +} + +async function revision(core: NotebookAdapter, path = "book.ipynb") { + return (await core.read({ directory: "/repo", path, includeOutputs: false })).revision +} + +function event(document: vscode.NotebookDocument, cell: vscode.NotebookCell, change: object) { + return { + notebook: document, + contentChanges: [], + cellChanges: [{ cell, ...change }], + } as unknown as vscode.NotebookDocumentChangeEvent } describe("notebook path security", () => { - it("rejects traversal and symlink escapes before access checks", async () => { - const guard = { validateAccess: mock(() => true) } - await expect(resolveNotebookPath("/repo", "../secret.ipynb", guard, paths)).rejects.toMatchObject({ - code: "invalid_path", + it("accepts relative and contained absolute paths and normalizes results", async () => { + await expect(resolveNotebookPath("/repo", "nested/book.ipynb", access, paths)).resolves.toEqual({ + target: "/repo/nested/book.ipynb", + relative: "nested/book.ipynb", }) - await expect( - resolveNotebookPath("/repo", "linked.ipynb", guard, { - realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value), - }), - ).rejects.toMatchObject({ code: "invalid_path" }) + await expect(resolveNotebookPath("/repo", "/repo/nested/book.ipynb", access, paths)).resolves.toEqual({ + target: "/repo/nested/book.ipynb", + relative: "nested/book.ipynb", + }) + const ctx = adapter([cell()], "/repo/nested/book.ipynb") + expect((await ctx.adapter.read({ directory: "/repo", path: "/repo/nested/book.ipynb", includeOutputs: false })).path).toBe( + "nested/book.ipynb", + ) + }) + + it("rejects outside absolute paths and relative or absolute symlink escapes", async () => { + const guard = { validateAccess: mock(() => true) } + await expect(resolveNotebookPath("/repo", "/outside/secret.ipynb", guard, paths)).rejects.toMatchObject({ + code: "invalid_path", + message: expect.stringContaining("/outside/secret.ipynb"), + }) + for (const input of ["linked.ipynb", "/repo/linked.ipynb"]) { + await expect( + resolveNotebookPath("/repo", input, guard, { + realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value), + }), + ).rejects.toMatchObject({ code: "invalid_path", message: expect.stringContaining(input) }) + } expect(guard.validateAccess).not.toHaveBeenCalled() }) @@ -137,125 +216,228 @@ describe("notebook normalization", () => { }) }) +describe("notebook content revisions", () => { + it("ignores outputs and execution summaries", async () => { + const item = cell("one") + const ctx = adapter([item]) + const before = await revision(ctx.adapter) + item.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("result") }] } as never] + item.state.execution = { success: true, executionOrder: 1 } + expect(await revision(ctx.adapter)).toBe(before) + }) + + it("changes for source, language, kind, insertion, deletion, replacement, and reordering", async () => { + const first = cell("one") + const second = cell("two") + const ctx = adapter([first, second]) + const revisions = [await revision(ctx.adapter)] + first.state.source = "changed" + revisions.push(await revision(ctx.adapter)) + first.state.language = "javascript" + revisions.push(await revision(ctx.adapter)) + first.state.kind = vscode.NotebookCellKind.Markup + revisions.push(await revision(ctx.adapter)) + const added = cell("added") + ctx.cells.splice(1, 0, added.value) + revisions.push(await revision(ctx.adapter)) + ctx.cells.splice(1, 1) + revisions.push(await revision(ctx.adapter)) + ctx.cells.splice(0, 1, cell("replacement").value) + revisions.push(await revision(ctx.adapter)) + ctx.cells.reverse() + revisions.push(await revision(ctx.adapter)) + for (const [index, value] of revisions.entries()) { + if (index > 0) expect(value).not.toBe(revisions[index - 1]) + } + }) +}) + describe("notebook adapter", () => { - it("prefers a dirty open document and reads normalized cells", async () => { - const document = notebook([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)], 7) - const ctx = adapter(document) - const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb" }) + it("prefers an open document and opens missing notebooks in the background", async () => { + const ctx = adapter([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)]) + const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) expect(ctx.calls.open).toBe(0) expect(result).toMatchObject({ path: "book.ipynb", - version: 7, cells: [ { index: 0, kind: "code", language: "python", source: "unsaved" }, { index: 1, kind: "markdown", language: "markdown", source: "# title" }, ], }) - }) - - it("opens notebooks in the background when not already open", async () => { - const document = notebook([cell()]) - const ctx = harness(document) ctx.deps.documents = () => [] - const core = new NotebookAdapter(access, { deps: ctx.deps, paths }) - await core.read({ directory: "/repo", path: "book.ipynb" }) + await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) expect(ctx.calls.open).toBe(1) }) - it("constructs insert, replace, and delete edits and rejects stale versions", async () => { - const document = notebook([cell()], 3) - const ctx = adapter(document) + it("chains sequential edits with returned revisions and affected cells", async () => { + const ctx = adapter([cell("one")]) + const initial = await revision(ctx.adapter) + const first = await ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision: initial, + edit: { action: "replace", kind: "code", language: "python", source: "two" }, + }) + expect(first.cell).toMatchObject({ index: 0, source: "two" }) + const second = await ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 1, + expectedRevision: first.revision, + edit: { action: "insert", kind: "markdown", language: "markdown", source: "three" }, + }) + expect(second.revision).not.toBe(first.revision) + expect(second.cell).toMatchObject({ index: 1, kind: "markdown", source: "three" }) + }) + + it("returns a structured stale-revision error for genuine content conflicts", async () => { + const item = cell("one") + const ctx = adapter([item]) + const expectedRevision = await revision(ctx.adapter) + item.state.source = "user edit" await expect( ctx.adapter.edit({ directory: "/repo", path: "book.ipynb", index: 0, - version: 2, - edit: { action: "replace", kind: "code", language: "python", source: "next" }, + expectedRevision, + edit: { action: "delete" }, }), - ).rejects.toMatchObject({ code: "stale_version" }) - await ctx.adapter.edit({ + ).rejects.toMatchObject({ + code: "stale_revision", + path: "book.ipynb", + index: 0, + currentRevision: expect.stringContaining("content:"), + message: expect.stringContaining("Re-read"), + }) + expect(ctx.calls.apply).toBe(0) + }) + + it("serializes concurrent edits and rejects a stale queued mutation", async () => { + const ctx = adapter([cell("one")]) + const expectedRevision = await revision(ctx.adapter) + const first = ctx.adapter.edit({ directory: "/repo", path: "book.ipynb", index: 0, - version: 3, - edit: { action: "replace", kind: "code", language: "python", source: "next" }, + expectedRevision, + edit: { action: "replace", kind: "code", source: "first" }, }) - expect(ctx.calls.edit).toEqual([ - { type: "replace", index: 0, cells: [{ input: { kind: "code", language: "python", source: "next" } }] }, - ]) + const other = new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }) + const second = other.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + edit: { action: "replace", kind: "code", source: "second" }, + }) + const results = await Promise.allSettled([first, second]) + expect(results[0]).toMatchObject({ status: "fulfilled", value: { action: "replace" } }) + expect(results[1]).toMatchObject({ status: "rejected", reason: { code: "stale_revision" } }) + expect(ctx.cells[0]?.document.getText()).toBe("first") }) - it("correlates a newly completed execution and cleans up listeners", async () => { - const target = cell() - const document = notebook([target], 4) - const ctx = adapter(document) + it("executes the same target when unrelated cell content or outputs change", async () => { + const target = cell("target") + const other = cell("other") + const ctx = adapter([target, other]) + const expectedRevision = await revision(ctx.adapter) + other.state.source = "changed elsewhere" + other.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("noise") }] } as never] ctx.deps.execute = async (...args) => { ctx.calls.command++ ctx.calls.commandArgs = args - Object.assign(target, { - outputs: [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] }], - executionSummary: { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } }, - }) - for (const listener of ctx.changes) { - listener({ - notebook: document, - contentChanges: [], - cellChanges: [{ cell: target, executionSummary: target.executionSummary }], - } as unknown as vscode.NotebookDocumentChangeEvent) - } + target.state.outputs = [ + { items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] } as vscode.NotebookCellOutput, + ] + target.state.execution = { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } } + for (const listener of ctx.changes) listener(event(ctx.document, target.value, { executionSummary: target.state.execution })) } const result = await ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, - version: 4, + expectedRevision, }) expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] }) - expect(ctx.calls.commandArgs).toEqual([ - "notebook.cell.execute", - { ranges: [{ start: 0, end: 1 }], document: document.uri }, - ]) + expect(result.revision).not.toBe(expectedRevision) expect(ctx.changes.size).toBe(0) expect(ctx.closes.size).toBe(0) }) - it("fails closed when execution never starts", async () => { - const document = notebook([cell()]) - const ctx = adapter(document) + it("does not authorize another notebook with a cached revision", async () => { + const first = adapter([cell("target"), cell("first")], "/repo/first.ipynb") + const expectedRevision = await revision(first.adapter, "first.ipynb") + const second = adapter([cell("target"), cell("second")], "/repo/second.ipynb") + await expect( + second.adapter.execute({ + directory: "/repo", + path: "second.ipynb", + index: 0, + expectedRevision, + }), + ).rejects.toMatchObject({ code: "stale_revision", path: "second.ipynb" }) + expect(second.calls.command).toBe(0) + }) + + it("reports an unobserved startup as execution_failed rather than no_kernel", async () => { + const ctx = adapter([cell()]) + const expectedRevision = await revision(ctx.adapter) await expect( ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, - version: 1, + expectedRevision, timeout: 5, }), - ).rejects.toMatchObject({ code: "no_kernel" }) + ).rejects.toMatchObject({ + code: "execution_failed", + path: "book.ipynb", + index: 0, + message: expect.stringContaining("command was dispatched"), + }) expect(ctx.calls.commandArgs).toEqual([ "notebook.cell.cancelExecution", - { ranges: [{ start: 0, end: 1 }], document: document.uri }, + { ranges: [{ start: 0, end: 1 }], document: ctx.document.uri }, ]) - expect(ctx.changes.size).toBe(0) - expect(ctx.closes.size).toBe(0) }) it("cancels execution and disposes listeners", async () => { - const document = notebook([cell()]) - const ctx = adapter(document) + const ctx = adapter([cell()]) + const expectedRevision = await revision(ctx.adapter) const controller = new AbortController() const pending = ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, - version: 1, + expectedRevision, signal: controller.signal, }) controller.abort() - await expect(pending).rejects.toEqual( - new NotebookError("cancelled", "Notebook execution cancellation was requested"), - ) + await expect(pending).rejects.toMatchObject({ code: "cancelled", path: "book.ipynb", index: 0 }) expect(ctx.changes.size).toBe(0) expect(ctx.closes.size).toBe(0) }) + + it("rejects execution if the targeted cell changes", async () => { + const target = cell("before") + const ctx = adapter([target]) + const expectedRevision = await revision(ctx.adapter) + ctx.deps.execute = async (...args) => { + ctx.calls.command++ + ctx.calls.commandArgs = args + if (args[0] !== "notebook.cell.execute") return + target.state.source = "after" + for (const listener of ctx.changes) listener(event(ctx.document, target.value, { document: target.value.document })) + } + await expect( + ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, expectedRevision }), + ).rejects.toMatchObject({ code: "stale_revision" }) + expect(ctx.calls.commandArgs).toEqual([ + "notebook.cell.cancelExecution", + { ranges: [{ start: 0, end: 1 }], document: ctx.document.uri }, + ]) + }) }) diff --git a/packages/opencode/src/kilocode/notebook/protocol.ts b/packages/opencode/src/kilocode/notebook/protocol.ts index a98d53f85e1..12a62078f1a 100644 --- a/packages/opencode/src/kilocode/notebook/protocol.ts +++ b/packages/opencode/src/kilocode/notebook/protocol.ts @@ -11,16 +11,20 @@ export type RequestID = Schema.Schema.Type export const Path = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(4096), - Schema.makeFilter((value: string) => { - const parts = value.replaceAll("\\", "/").split("/") - return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || parts.includes("..") - ? "Notebook path must be workspace-relative and contained in the workspace" - : undefined - }), -).annotate({ description: "Workspace-relative notebook path" }) + Schema.makeFilter((value: string) => + value.includes("\0") + ? "Notebook path must be a request-directory-relative path or an absolute path inside the request directory" + : undefined, + ), +).annotate({ + description: + "Notebook path relative to the request directory, or an absolute path that resolves inside the request directory", +}) const Source = Schema.String.check(Schema.isMaxLength(200_000)) const Text = Schema.String.check(Schema.isMaxLength(100_000)) -const Version = NonNegativeInt.annotate({ description: "Expected VS Code notebook document version" }) +const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({ + description: "Opaque notebook content revision; pass it back unchanged and do not parse or increment it", +}) const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) export const Output = Schema.Struct({ @@ -68,7 +72,7 @@ const CellEdit = { export const EditRequest = Schema.Struct({ ...Base, operation: Schema.Literal("edit"), - version: Version, + expectedRevision: Revision, index: Index, edit: Schema.Union([ Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }), @@ -80,7 +84,7 @@ export const EditRequest = Schema.Struct({ export const ExecuteRequest = Schema.Struct({ ...Base, operation: Schema.Literal("execute"), - version: Version, + expectedRevision: Revision, index: Index, }).annotate({ identifier: "NotebookExecuteRequest" }) @@ -92,7 +96,8 @@ export type Request = Schema.Schema.Type export const ReadResult = Schema.Struct({ operation: Schema.Literal("read"), path: Path, - version: Version, + requestPath: Path, + revision: Revision, cells: Schema.Array(Cell).check(Schema.isMaxLength(2_000)), truncated: Schema.optional(Schema.Boolean), }) @@ -106,15 +111,18 @@ export const ReadResult = Schema.Struct({ export const EditResult = Schema.Struct({ operation: Schema.Literal("edit"), path: Path, - version: Version, + requestPath: Path, + revision: Revision, index: Index, action: Schema.Literals(["insert", "replace", "delete"]), + cell: Schema.optional(Cell), }).annotate({ identifier: "NotebookEditResult" }) export const ExecuteResult = Schema.Struct({ operation: Schema.Literal("execute"), path: Path, - version: Version, + requestPath: Path, + revision: Revision, index: Index, status: Schema.Literals(["success", "error", "cancelled"]), outputs: Schema.Array(Output).check(Schema.isMaxLength(100)), @@ -141,7 +149,7 @@ export const ErrorCode = Schema.Literals([ "invalid_path", "no_kernel", "not_found", - "stale_version", + "stale_revision", "timeout", "unsupported", ]) @@ -150,6 +158,9 @@ export type ErrorCode = Schema.Schema.Type export const Failure = Schema.Struct({ code: ErrorCode, message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10_000)), + path: Schema.optional(Path), + index: Schema.optional(Index), + currentRevision: Schema.optional(Revision), }).annotate({ identifier: "NotebookFailure" }) export type Failure = Schema.Schema.Type diff --git a/packages/opencode/src/kilocode/notebook/service.ts b/packages/opencode/src/kilocode/notebook/service.ts index fffb0aa5cf0..f3d2c8f35d8 100644 --- a/packages/opencode/src/kilocode/notebook/service.ts +++ b/packages/opencode/src/kilocode/notebook/service.ts @@ -12,6 +12,9 @@ export type Input = WithoutID export class HostError extends Schema.TaggedErrorClass()("NotebookHostError", { code: ErrorCode, detail: Schema.String, + path: Schema.optional(Schema.String), + index: Schema.optional(Schema.Number), + currentRevision: Schema.optional(Schema.String), }) { override get message() { return this.detail @@ -35,7 +38,7 @@ interface State { } function matches(request: Request, result: Result) { - if (request.path !== result.path) return false + if (request.path !== result.requestPath) return false if (request.operation === "read") return result.operation === "read" if (request.operation === "execute") return result.operation === "execute" && request.index === result.index return result.operation === "edit" && request.index === result.index && request.edit.action === result.action @@ -138,7 +141,16 @@ export function layer(timeout: Duration.Input = "10 minutes") { return yield* new NotFoundError({ requestID: input.requestID }) } pending.delete(input.requestID) - yield* Deferred.fail(entry.deferred, new HostError({ code: input.error.code, detail: input.error.message })) + yield* Deferred.fail( + entry.deferred, + new HostError({ + code: input.error.code, + detail: input.error.message, + path: input.error.path, + index: input.error.index, + currentRevision: input.error.currentRevision, + }), + ) }) return Service.of({ request, list, reply, reject }) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts index d6e45b97d7f..e6d7a88dc77 100644 --- a/packages/opencode/src/kilocode/tool/notebook-host.ts +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -5,12 +5,14 @@ import * as Tool from "@/tool/tool" import { Effect, Schema } from "effect" const Source = Schema.String.check(Schema.isMaxLength(200_000)) -const Version = NonNegativeInt.annotate({ description: "Notebook version returned by notebook_read" }) +const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({ + description: "Opaque content revision returned by notebook_read or the previous successful notebook_edit", +}) const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) const LIMIT = 20_000 function render(value: unknown) { - const text = JSON.stringify(value, null, 2) + const text = JSON.stringify(value, (key, item) => (key === "requestPath" ? undefined : item), 2) if (text.length <= LIMIT) return text return `${text.slice(0, LIMIT)} ... notebook result truncated by CLI (${text.length - LIMIT} characters omitted)` @@ -39,7 +41,7 @@ const ReadParams = Schema.Struct({ export const NotebookReadTool = Tool.define< typeof ReadParams, - { path: string; version: number }, + { path: string; revision: string }, Notebook.Service, "notebook_read" >( @@ -48,7 +50,7 @@ export const NotebookReadTool = Tool.define< const notebook = yield* Notebook.Service return { description: - "Read the live, possibly unsaved structure and source of one VS Code notebook. Outputs are omitted unless include_outputs is true.", + "Read a live, possibly unsaved VS Code notebook using a request-directory-relative path or a safe absolute path inside that directory. Returns an opaque content revision for edits; outputs are omitted unless include_outputs is true.", parameters: ReadParams, execute: (params, ctx) => Effect.gen(function* () { @@ -72,7 +74,7 @@ export const NotebookReadTool = Tool.define< return { title: `Notebook: ${params.path}`, output: render(result), - metadata: { path: result.path, version: result.version }, + metadata: { path: result.path, revision: result.revision }, } }), } @@ -85,14 +87,26 @@ const Cell = { source: Source, } const EditParams = Schema.Union([ - Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("insert"), ...Cell }), - Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("replace"), ...Cell }), - Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("delete") }), + Schema.Struct({ + path: Path, + expected_revision: Revision, + index: Index, + action: Schema.Literal("insert"), + ...Cell, + }), + Schema.Struct({ + path: Path, + expected_revision: Revision, + index: Index, + action: Schema.Literal("replace"), + ...Cell, + }), + Schema.Struct({ path: Path, expected_revision: Revision, index: Index, action: Schema.Literal("delete") }), ]) export const NotebookEditTool = Tool.define< typeof EditParams, - { path: string; version: number; index: number }, + { path: string; revision: string; index: number }, Notebook.Service, "notebook_edit" >( @@ -101,7 +115,7 @@ export const NotebookEditTool = Tool.define< const notebook = yield* Notebook.Service return { description: - "Insert, replace, or delete one cell in a live VS Code notebook. Requires the exact notebook version from notebook_read and leaves the document dirty.", + "Insert, replace, or delete one cell in a live VS Code notebook. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.", parameters: EditParams, execute: (params, ctx) => Effect.gen(function* () { @@ -113,7 +127,7 @@ export const NotebookEditTool = Tool.define< path: params.path, action: params.action, index: params.index, - version: params.expected_version, + expectedRevision: params.expected_revision, }, }) const edit = @@ -125,7 +139,7 @@ export const NotebookEditTool = Tool.define< operation: "edit", sessionID: ctx.sessionID, path: params.path, - version: params.expected_version, + expectedRevision: params.expected_revision, index: params.index, edit, }), @@ -136,18 +150,18 @@ export const NotebookEditTool = Tool.define< return { title: `${result.action} notebook cell ${result.index}`, output: render(result), - metadata: { path: result.path, version: result.version, index: result.index }, + metadata: { path: result.path, revision: result.revision, index: result.index }, } }), } }), ) -const ExecuteParams = Schema.Struct({ path: Path, expected_version: Version, index: Index }) +const ExecuteParams = Schema.Struct({ path: Path, expected_revision: Revision, index: Index }) export const NotebookExecuteTool = Tool.define< typeof ExecuteParams, - { path: string; version: number; index: number }, + { path: string; revision: string; index: number }, Notebook.Service, "notebook_execute" >( @@ -156,7 +170,7 @@ export const NotebookExecuteTool = Tool.define< const notebook = yield* Notebook.Service return { description: - "Execute one explicit code cell in a live VS Code notebook without revealing the notebook or opening a kernel picker. Requires the exact notebook version from notebook_read.", + "Execute one explicit code cell in a live VS Code notebook using a request-directory-relative or safe absolute path. Pass the latest opaque content revision unchanged. Execution requires a kernel already selected by the user; the tool never reveals the notebook or opens a kernel picker.", parameters: ExecuteParams, execute: (params, ctx) => Effect.gen(function* () { @@ -164,14 +178,14 @@ export const NotebookExecuteTool = Tool.define< permission: "notebook_execute", patterns: [params.path], always: [params.path], - metadata: { path: params.path, index: params.index, version: params.expected_version }, + metadata: { path: params.path, index: params.index, expectedRevision: params.expected_revision }, }) const result = yield* run( notebook.request({ operation: "execute", sessionID: ctx.sessionID, path: params.path, - version: params.expected_version, + expectedRevision: params.expected_revision, index: params.index, }), ctx.abort, @@ -181,7 +195,7 @@ export const NotebookExecuteTool = Tool.define< return { title: `Executed notebook cell ${result.index}`, output: render(result), - metadata: { path: result.path, version: result.version, index: result.index }, + metadata: { path: result.path, revision: result.revision, index: result.index }, } }), } diff --git a/packages/opencode/test/kilocode/notebook-service.test.ts b/packages/opencode/test/kilocode/notebook-service.test.ts index 90552e3396b..1f831f2b892 100644 --- a/packages/opencode/test/kilocode/notebook-service.test.ts +++ b/packages/opencode/test/kilocode/notebook-service.test.ts @@ -2,7 +2,7 @@ import { expect } from "bun:test" import { Bus } from "@/bus" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Notebook, HostError } from "@/kilocode/notebook/service" -import { Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol" +import { EditRequest, Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol" import { SessionID } from "@/session/schema" import { Effect, Fiber, Layer, Queue, Schema } from "effect" import { TestInstance } from "../fixture/fixture" @@ -46,15 +46,21 @@ it.instance( yield* notebook.reply({ requestID: event.properties.id, - result: { operation: "read", path: "analysis.ipynb", version: 3, cells: [] }, + result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] }, + }) + expect(yield* Fiber.join(fiber)).toEqual({ + operation: "read", + path: "analysis.ipynb", + requestPath: "analysis.ipynb", + revision: "content:3", + cells: [], }) - expect(yield* Fiber.join(fiber)).toEqual({ operation: "read", path: "analysis.ipynb", version: 3, cells: [] }) expect(yield* notebook.list()).toEqual([]) const late = yield* notebook .reply({ requestID: event.properties.id, - result: { operation: "read", path: "analysis.ipynb", version: 3, cells: [] }, + result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] }, }) .pipe(Effect.flip) expect(late._tag).toBe("Notebook.NotFoundError") @@ -71,12 +77,18 @@ it.instance( const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) yield* notebook.reject({ requestID: pending[0].id, - error: { code: "stale_version", message: "Expected version 4 but found 5" }, + error: { + code: "stale_revision", + message: "Notebook content changed; re-read before retrying", + path: "analysis.ipynb", + index: 0, + currentRevision: "content:current", + }, }) const err = yield* Fiber.join(fiber).pipe(Effect.flip) expect(err).toBeInstanceOf(HostError) - expect(err.code).toBe("stale_version") - expect(err.message).toContain("version 4") + expect(err.code).toBe("stale_revision") + expect(err.message).toContain("re-read") expect(yield* notebook.list()).toEqual([]) }), { git: true }, @@ -99,18 +111,23 @@ it.instance( const mismatch = yield* notebook .reply({ requestID: pending[0].id, - result: { operation: "edit", path: "analysis.ipynb", version: 2, index: 0, action: "delete" }, + result: { operation: "edit", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:2", index: 0, action: "delete" }, }) .pipe(Effect.flip) expect(mismatch._tag).toBe("Notebook.InvalidReplyError") const wrongPath = yield* notebook .reply({ requestID: pending[0].id, - result: { operation: "read", path: "other.ipynb", version: 2, cells: [] }, + result: { + operation: "read", + path: "other.ipynb", + requestPath: "other.ipynb", + revision: "content:2", + cells: [], + }, }) .pipe(Effect.flip) expect(wrongPath._tag).toBe("Notebook.InvalidReplyError") - yield* Fiber.interrupt(fiber) expect(yield* Queue.take(cancelled).pipe(Effect.timeout("2 seconds"))).toBe("cancelled") expect(yield* notebook.list()).toEqual([]) @@ -131,17 +148,32 @@ it.instance( ) it.instance( - "rejects escaping paths and oversized aggregate results", + "accepts both path forms, requires opaque revisions, and bounds aggregate results", () => Effect.gen(function* () { - const path = yield* Schema.decodeUnknownEffect(ReadRequest)({ + const absolute = yield* Schema.decodeUnknownEffect(ReadRequest)({ id: "nbr_test", sessionID, operation: "read", - path: "../outside.ipynb", + path: "/workspace/analysis.ipynb", includeOutputs: false, + }) + expect(absolute.path).toBe("/workspace/analysis.ipynb") + const relative = yield* Schema.decodeUnknownEffect(ReadRequest)({ + ...absolute, + path: "nested/analysis.ipynb", + }) + expect(relative.path).toBe("nested/analysis.ipynb") + const invalid = yield* Schema.decodeUnknownEffect(EditRequest)({ + id: "nbr_edit", + sessionID, + operation: "edit", + path: "analysis.ipynb", + expectedRevision: 4, + index: 0, + edit: { action: "delete" }, }).pipe(Effect.flip) - expect(String(path)).toContain("workspace-relative") + expect(String(invalid)).toContain("string") const cells = Array.from({ length: 11 }, (_, index) => ({ index, @@ -152,7 +184,8 @@ it.instance( const output = yield* Schema.decodeUnknownEffect(ReadResult)({ operation: "read", path: "analysis.ipynb", - version: 1, + requestPath: "analysis.ipynb", + revision: "content:1", cells, }).pipe(Effect.flip) expect(String(output)).toContain("aggregate output limit") diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 7f5192ed978..64419a1e31f 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -17,21 +17,24 @@ const notebook = Layer.mock(Notebook.Service, { return Effect.succeed({ operation: "read" as const, path: input.path, - version: 7, + requestPath: input.path, + revision: "content:read", cells: [{ index: 0, kind: "code" as const, language: "python", source: "x".repeat(200_000) }], }) if (input.operation === "edit") return Effect.succeed({ operation: "edit" as const, path: input.path, - version: input.version + 1, + requestPath: input.path, + revision: "content:edit", index: input.index, action: input.edit.action, }) return Effect.succeed({ operation: "execute" as const, path: input.path, - version: input.version, + requestPath: input.path, + revision: "content:execute", index: input.index, status: "success" as const, outputs: [], @@ -68,7 +71,7 @@ describe("native notebook tools", () => { const editResult = yield* edit.execute( { path: "analysis.ipynb", - expected_version: 7, + expected_revision: "content:read", index: 0, action: "replace", kind: "code", @@ -77,14 +80,25 @@ describe("native notebook tools", () => { }, ctx, ) - const executeResult = yield* execute.execute({ path: "analysis.ipynb", expected_version: 8, index: 0 }, ctx) + const executeResult = yield* execute.execute( + { path: "/workspace/analysis.ipynb", expected_revision: "content:edit", index: 0 }, + ctx, + ) expect(asks.map((item) => item.permission)).toEqual(["notebook_read", "notebook_edit", "notebook_execute"]) - expect(asks.every((item) => item.patterns[0] === "analysis.ipynb")).toBe(true) + expect(asks.map((item) => item.patterns[0])).toEqual([ + "analysis.ipynb", + "analysis.ipynb", + "/workspace/analysis.ipynb", + ]) expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"]) + expect(calls[1]).toMatchObject({ expectedRevision: "content:read" }) + expect(calls[2]).toMatchObject({ expectedRevision: "content:edit" }) expect(readResult.output.length).toBeLessThanOrEqual(20_100) expect(readResult.output).toContain("notebook result truncated") - expect(editResult.metadata.version).toBe(8) + expect(readResult.output).toContain('"revision"') + expect(readResult.output).not.toContain("requestPath") + expect(editResult.metadata.revision).toBe("content:edit") expect(executeResult.metadata.index).toBe(0) }), { git: true }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7d25ba15f55..9dcb70d2871 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,10 +5,20 @@ export type ClientOptions = { } export type Event = - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventSandboxStatusChanged + | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled + | EventIndexingStatus + | EventIndexingWarning + | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -16,10 +26,6 @@ export type Event = | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -33,7 +39,6 @@ export type Event = | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose - | EventSandboxStatusChanged | EventSessionDiff | EventSessionError | EventTodoUpdated @@ -47,9 +52,6 @@ export type Event = | EventCommandExecuted | EventProjectUpdated | EventSessionCompacted - | EventKilocodeAgentManagerStart - | EventKilocodeNotebookRequested - | EventKilocodeNotebookCancelled | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -94,11 +96,9 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventIndexingStatus - | EventIndexingWarning - | EventModelsDevRefreshed | EventPluginAdded | EventCatalogModelUpdated + | EventModelsDevRefreshed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -139,6 +139,134 @@ export type InvalidRequestError = { field?: string } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type NotebookRequestId = string + +export type NotebookReadRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "read" + includeOutputs: boolean +} + +export type NotebookEditRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "edit" + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + expectedRevision: string + /** + * Zero-based cell index + */ + index: number + edit: + | { + action: "insert" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "replace" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "delete" + } +} + +export type NotebookExecuteRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "execute" + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + expectedRevision: string + /** + * Zero-based cell index + */ + index: number +} + +export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest + +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + +export type IndexingWarning = { + code: "qdrant.version-incompatible" | "qdrant.version-unavailable" + message: string +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -201,61 +329,6 @@ export type QuestionRejected = { requestID: string } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type SessionNetworkWait = { id: string sessionID: string @@ -460,64 +533,6 @@ export type Project = { sandboxes: Array } -export type NotebookRequestId = string - -export type NotebookReadRequest = { - id: NotebookRequestId - sessionID: string - path: string - operation: "read" - includeOutputs: boolean -} - -export type NotebookEditRequest = { - id: NotebookRequestId - sessionID: string - path: string - operation: "edit" - /** - * Expected VS Code notebook document version - */ - version: number - /** - * Zero-based cell index - */ - index: number - edit: - | { - action: "insert" - kind: "code" | "markdown" - language?: string - source: string - } - | { - action: "replace" - kind: "code" | "markdown" - language?: string - source: string - } - | { - action: "delete" - } -} - -export type NotebookExecuteRequest = { - id: NotebookRequestId - sessionID: string - path: string - operation: "execute" - /** - * Expected VS Code notebook document version - */ - version: number - /** - * Zero-based cell index - */ - index: number -} - -export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest - export type Pty = { id: string title: string @@ -964,30 +979,25 @@ export type Prompt = { references?: Array } -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - -export type IndexingWarning = { - code: "qdrant.version-incompatible" | "qdrant.version-unavailable" - message: string -} - export type GlobalEvent = { directory: string project?: string workspace?: string payload: - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventSandboxStatusChanged + | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled + | EventIndexingStatus + | EventIndexingWarning + | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated | EventQuestionAsked @@ -995,10 +1005,6 @@ export type GlobalEvent = { | EventQuestionRejected | EventLspClientDiagnostics | EventLspUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -1012,7 +1018,6 @@ export type GlobalEvent = { | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose - | EventSandboxStatusChanged | EventSessionDiff | EventSessionError | EventTodoUpdated @@ -1026,9 +1031,6 @@ export type GlobalEvent = { | EventCommandExecuted | EventProjectUpdated | EventSessionCompacted - | EventKilocodeAgentManagerStart - | EventKilocodeNotebookRequested - | EventKilocodeNotebookCancelled | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -1073,11 +1075,9 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventIndexingStatus - | EventIndexingWarning - | EventModelsDevRefreshed | EventPluginAdded | EventCatalogModelUpdated + | EventModelsDevRefreshed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -2517,10 +2517,11 @@ export type NotebookCell = { export type NotebookReadResult = { operation: "read" path: string + requestPath: string /** - * Expected VS Code notebook document version + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it */ - version: number + revision: string cells: Array truncated?: boolean } @@ -2528,24 +2529,27 @@ export type NotebookReadResult = { export type NotebookEditResult = { operation: "edit" path: string + requestPath: string /** - * Expected VS Code notebook document version + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it */ - version: number + revision: string /** * Zero-based cell index */ index: number action: "insert" | "replace" | "delete" + cell?: NotebookCell } export type NotebookExecuteResult = { operation: "execute" path: string + requestPath: string /** - * Expected VS Code notebook document version + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it */ - version: number + revision: string /** * Zero-based cell index */ @@ -2567,10 +2571,19 @@ export type NotebookFailure = { | "invalid_path" | "no_kernel" | "not_found" - | "stale_version" + | "stale_revision" | "timeout" | "unsupported" message: string + path?: string + /** + * Zero-based cell index + */ + index?: number + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + currentRevision?: string } export type KilocodeSessionImportResult = { @@ -3118,14 +3131,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerInstanceDisposed = { - id: string - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventServerConnected = { id: string type: "server.connected" @@ -3150,6 +3155,73 @@ export type EventGlobalConfigUpdated = { } } +export type EventSandboxStatusChanged = { + id: string + type: "sandbox.status.changed" + properties: { + sessionID: string + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } +} + +export type EventKilocodeAgentManagerStart = { + id: string + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + prompt?: string + name?: string + branchName?: string + }> + } +} + +export type EventKilocodeNotebookRequested = { + id: string + type: "kilocode.notebook.requested" + properties: NotebookRequest +} + +export type EventKilocodeNotebookCancelled = { + id: string + type: "kilocode.notebook.cancelled" + properties: { + requestID: NotebookRequestId + sessionID: string + reason: "cancelled" | "disposed" | "timeout" + } +} + +export type EventIndexingStatus = { + id: string + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + +export type EventIndexingWarning = { + id: string + type: "indexing.warning" + properties: IndexingWarning +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventFileEdited = { id: string type: "file.edited" @@ -3316,19 +3388,6 @@ export type EventSessionTurnClose = { } } -export type EventSandboxStatusChanged = { - id: string - type: "sandbox.status.changed" - properties: { - sessionID: string - directory: string - enabled: boolean - available: boolean - reason?: string - version: number - } -} - export type EventSessionDiff = { id: string type: "session.diff" @@ -3457,38 +3516,6 @@ export type EventSessionCompacted = { } } -export type EventKilocodeAgentManagerStart = { - id: string - type: "kilocode.agent_manager.start" - properties: { - requestID: string - sessionID: string - mode: "worktree" | "local" - versions?: boolean - tasks: Array<{ - prompt?: string - name?: string - branchName?: string - }> - } -} - -export type EventKilocodeNotebookRequested = { - id: string - type: "kilocode.notebook.requested" - properties: NotebookRequest -} - -export type EventKilocodeNotebookCancelled = { - id: string - type: "kilocode.notebook.cancelled" - properties: { - requestID: NotebookRequestId - sessionID: string - reason: "cancelled" | "disposed" | "timeout" - } -} - export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -4027,28 +4054,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - -export type EventIndexingWarning = { - id: string - type: "indexing.warning" - properties: IndexingWarning -} - -export type EventModelsDevRefreshed = { - id: string - type: "models-dev.refreshed" - properties: { - [key: string]: unknown - } -} - export type EventPluginAdded = { id: string type: "plugin.added" @@ -4163,6 +4168,14 @@ export type EventCatalogModelUpdated = { } } +export type EventModelsDevRefreshed = { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } +} + export type AccountV2oAuthCredential = { type: "oauth" refresh: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b25a7e3dcb8..61970b9f78a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -16917,9 +16917,6 @@ "schemas": { "Event": { "anyOf": [ - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -16929,6 +16926,39 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, + { + "$ref": "#/components/schemas/EventIndexingStatus" + }, + { + "$ref": "#/components/schemas/EventIndexingWarning" + }, + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -16950,18 +16980,6 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -17001,9 +17019,6 @@ { "$ref": "#/components/schemas/EventSessionTurnClose" }, - { - "$ref": "#/components/schemas/EventSandboxStatusChanged" - }, { "$ref": "#/components/schemas/EventSessionDiff" }, @@ -17043,15 +17058,6 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventKilocodeNotebookRequested" - }, - { - "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" - }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -17184,30 +17190,12 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventIndexingWarning" - }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" - }, { "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" - }, { "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, @@ -17285,6 +17273,18 @@ }, { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventModels-devRefreshed" + }, + { + "$ref": "#/components/schemas/EventAccountAdded" + }, + { + "$ref": "#/components/schemas/EventAccountRemoved" + }, + { + "$ref": "#/components/schemas/EventAccountSwitched" } ] }, @@ -17396,6 +17396,355 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "NotebookRequestID": { + "type": "string" + }, + "NotebookReadRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["read"] + }, + "includeOutputs": { + "type": "boolean" + } + }, + "required": ["id", "sessionID", "path", "operation", "includeOutputs"], + "additionalProperties": false + }, + "NotebookEditRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["edit"] + }, + "expectedRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "edit": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["insert"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["replace"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["delete"] + } + }, + "required": ["action"], + "additionalProperties": false + } + ] + } + }, + "required": ["id", "sessionID", "path", "operation", "expectedRevision", "index", "edit"], + "additionalProperties": false + }, + "NotebookExecuteRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["execute"] + }, + "expectedRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + } + }, + "required": ["id", "sessionID", "path", "operation", "expectedRevision", "index"], + "additionalProperties": false + }, + "NotebookRequest": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotebookReadRequest" + }, + { + "$ref": "#/components/schemas/NotebookEditRequest" + }, + { + "$ref": "#/components/schemas/NotebookExecuteRequest" + } + ] + }, + "IndexingStatusState": { + "type": "string", + "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] + }, + "IndexingStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/IndexingStatusState" + }, + "message": { + "type": "string" + }, + "processedFiles": { + "type": "integer", + "minimum": 0 + }, + "totalFiles": { + "type": "integer", + "minimum": 0 + }, + "percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["state", "message", "processedFiles", "totalFiles", "percent"], + "additionalProperties": false + }, + "IndexingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": ["qdrant.version-incompatible", "qdrant.version-unavailable"] + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "additionalProperties": false + }, "QuestionOption": { "type": "object", "properties": { @@ -17538,140 +17887,6 @@ "required": ["sessionID", "requestID"], "additionalProperties": false }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "SessionNetworkWait": { "type": "object", "properties": { @@ -18259,175 +18474,6 @@ "required": ["id", "worktree", "time", "sandboxes"], "additionalProperties": false }, - "NotebookRequestID": { - "type": "string" - }, - "NotebookReadRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/components/schemas/NotebookRequestID" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "path": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "operation": { - "type": "string", - "enum": ["read"] - }, - "includeOutputs": { - "type": "boolean" - } - }, - "required": ["id", "sessionID", "path", "operation", "includeOutputs"], - "additionalProperties": false - }, - "NotebookEditRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/components/schemas/NotebookRequestID" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "path": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "operation": { - "type": "string", - "enum": ["edit"] - }, - "version": { - "type": "integer", - "minimum": 0, - "description": "Expected VS Code notebook document version" - }, - "index": { - "type": "integer", - "minimum": 0, - "description": "Zero-based cell index" - }, - "edit": { - "anyOf": [ - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["insert"] - }, - "kind": { - "type": "string", - "enum": ["code", "markdown"] - }, - "language": { - "type": "string", - "maxLength": 200 - }, - "source": { - "type": "string", - "maxLength": 200000 - } - }, - "required": ["action", "kind", "source"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["replace"] - }, - "kind": { - "type": "string", - "enum": ["code", "markdown"] - }, - "language": { - "type": "string", - "maxLength": 200 - }, - "source": { - "type": "string", - "maxLength": 200000 - } - }, - "required": ["action", "kind", "source"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["delete"] - } - }, - "required": ["action"], - "additionalProperties": false - } - ] - } - }, - "required": ["id", "sessionID", "path", "operation", "version", "index", "edit"], - "additionalProperties": false - }, - "NotebookExecuteRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/components/schemas/NotebookRequestID" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "path": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "operation": { - "type": "string", - "enum": ["execute"] - }, - "version": { - "type": "integer", - "minimum": 0, - "description": "Expected VS Code notebook document version" - }, - "index": { - "type": "integer", - "minimum": 0, - "description": "Zero-based cell index" - } - }, - "required": ["id", "sessionID", "path", "operation", "version", "index"], - "additionalProperties": false - }, - "NotebookRequest": { - "anyOf": [ - { - "$ref": "#/components/schemas/NotebookReadRequest" - }, - { - "$ref": "#/components/schemas/NotebookEditRequest" - }, - { - "$ref": "#/components/schemas/NotebookExecuteRequest" - } - ] - }, "Pty": { "type": "object", "properties": { @@ -19826,50 +19872,6 @@ "required": ["text"], "additionalProperties": false }, - "IndexingStatusState": { - "type": "string", - "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] - }, - "IndexingStatus": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/IndexingStatusState" - }, - "message": { - "type": "string" - }, - "processedFiles": { - "type": "integer", - "minimum": 0 - }, - "totalFiles": { - "type": "integer", - "minimum": 0 - }, - "percent": { - "type": "integer", - "minimum": 0, - "maximum": 100 - } - }, - "required": ["state", "message", "processedFiles", "totalFiles", "percent"], - "additionalProperties": false - }, - "IndexingWarning": { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": ["qdrant.version-incompatible", "qdrant.version-unavailable"] - }, - "message": { - "type": "string" - } - }, - "required": ["code", "message"], - "additionalProperties": false - }, "GlobalEvent": { "type": "object", "properties": { @@ -19884,9 +19886,6 @@ }, "payload": { "anyOf": [ - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -19896,6 +19895,39 @@ { "$ref": "#/components/schemas/EventGlobalConfigUpdated" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, + { + "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, + { + "$ref": "#/components/schemas/EventIndexingStatus" + }, + { + "$ref": "#/components/schemas/EventIndexingWarning" + }, + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, { "$ref": "#/components/schemas/EventFileEdited" }, @@ -19917,18 +19949,6 @@ { "$ref": "#/components/schemas/EventLspUpdated" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -19968,9 +19988,6 @@ { "$ref": "#/components/schemas/EventSessionTurnClose" }, - { - "$ref": "#/components/schemas/EventSandboxStatusChanged" - }, { "$ref": "#/components/schemas/EventSessionDiff" }, @@ -20010,15 +20027,6 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, - { - "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" - }, - { - "$ref": "#/components/schemas/EventKilocodeNotebookRequested" - }, - { - "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" - }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -20151,30 +20159,12 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventIndexingStatus" - }, - { - "$ref": "#/components/schemas/EventIndexingWarning" - }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" - }, { "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" - }, { "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, @@ -20253,6 +20243,18 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventModels-devRefreshed" + }, + { + "$ref": "#/components/schemas/EventAccountAdded" + }, + { + "$ref": "#/components/schemas/EventAccountRemoved" + }, + { + "$ref": "#/components/schemas/EventAccountSwitched" + }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -24458,10 +24460,16 @@ "minLength": 1, "maxLength": 4096 }, - "version": { - "type": "integer", - "minimum": 0, - "description": "Expected VS Code notebook document version" + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" }, "cells": { "type": "array", @@ -24474,7 +24482,7 @@ "type": "boolean" } }, - "required": ["operation", "path", "version", "cells"], + "required": ["operation", "path", "requestPath", "revision", "cells"], "additionalProperties": false }, "NotebookEditResult": { @@ -24489,10 +24497,16 @@ "minLength": 1, "maxLength": 4096 }, - "version": { - "type": "integer", - "minimum": 0, - "description": "Expected VS Code notebook document version" + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" }, "index": { "type": "integer", @@ -24502,9 +24516,12 @@ "action": { "type": "string", "enum": ["insert", "replace", "delete"] + }, + "cell": { + "$ref": "#/components/schemas/NotebookCell" } }, - "required": ["operation", "path", "version", "index", "action"], + "required": ["operation", "path", "requestPath", "revision", "index", "action"], "additionalProperties": false }, "NotebookExecuteResult": { @@ -24519,10 +24536,16 @@ "minLength": 1, "maxLength": 4096 }, - "version": { - "type": "integer", - "minimum": 0, - "description": "Expected VS Code notebook document version" + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" }, "index": { "type": "integer", @@ -24544,7 +24567,7 @@ "type": "boolean" } }, - "required": ["operation", "path", "version", "index", "status", "outputs"], + "required": ["operation", "path", "requestPath", "revision", "index", "status", "outputs"], "additionalProperties": false }, "NotebookResult": { @@ -24574,7 +24597,7 @@ "invalid_path", "no_kernel", "not_found", - "stale_version", + "stale_revision", "timeout", "unsupported" ] @@ -24583,6 +24606,22 @@ "type": "string", "minLength": 1, "maxLength": 10000 + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "currentRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" } }, "required": ["code", "message"], @@ -26479,30 +26518,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "EventServerInstanceDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.instance.disposed"] - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventServerConnected": { "type": "object", "properties": { @@ -26557,6 +26572,215 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSandboxStatusChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sandbox.status.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "directory": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": ["sessionID", "directory", "enabled", "available", "version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeAgent_managerStart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.agent_manager.start"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "mode": { + "type": "string", + "enum": ["worktree", "local"] + }, + "versions": { + "type": "boolean" + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branchName": { + "type": "string" + } + }, + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": ["requestID", "sessionID", "mode", "tasks"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeNotebookRequested": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.requested"] + }, + "properties": { + "$ref": "#/components/schemas/NotebookRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeNotebookCancelled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.cancelled"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["cancelled", "disposed", "timeout"] + } + }, + "required": ["requestID", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventIndexingStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["indexing.status"] + }, + "properties": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IndexingStatus" + } + }, + "required": ["status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventIndexingWarning": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["indexing.warning"] + }, + "properties": { + "$ref": "#/components/schemas/IndexingWarning" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventFileEdited": { "type": "object", "properties": { @@ -27062,46 +27286,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSandboxStatusChanged": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["sandbox.status.changed"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "directory": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "available": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "version": { - "type": "integer" - } - }, - "required": ["sessionID", "directory", "enabled", "available", "version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionDiff": { "type": "object", "properties": { @@ -27488,110 +27672,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventKilocodeAgent_managerStart": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilocode.agent_manager.start"] - }, - "properties": { - "type": "object", - "properties": { - "requestID": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "mode": { - "type": "string", - "enum": ["worktree", "local"] - }, - "versions": { - "type": "boolean" - }, - "tasks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "prompt": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branchName": { - "type": "string" - } - }, - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 20 - } - }, - "required": ["requestID", "sessionID", "mode", "tasks"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventKilocodeNotebookRequested": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilocode.notebook.requested"] - }, - "properties": { - "$ref": "#/components/schemas/NotebookRequest" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventKilocodeNotebookCancelled": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["kilocode.notebook.cancelled"] - }, - "properties": { - "type": "object", - "properties": { - "requestID": { - "$ref": "#/components/schemas/NotebookRequestID" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["cancelled", "disposed", "timeout"] - } - }, - "required": ["requestID", "sessionID", "reason"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -29221,65 +29301,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventIndexingStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["indexing.status"] - }, - "properties": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/IndexingStatus" - } - }, - "required": ["status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventIndexingWarning": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["indexing.warning"] - }, - "properties": { - "$ref": "#/components/schemas/IndexingWarning" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventModels-devRefreshed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["models-dev.refreshed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventPluginAdded": { "type": "object", "properties": { @@ -29652,6 +29673,24 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventModels-devRefreshed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "AccountV2OAuthCredential": { "type": "object", "properties": { From b0747d2595504bd6bd60fe72fef8e784cb2558e4 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:32:46 +0200 Subject: [PATCH 13/29] chore(vscode): format notebook tool changes --- .../src/services/notebook/adapter.ts | 9 +++++++-- .../tests/unit/notebook-bridge.test.ts | 8 +++++++- .../tests/unit/notebook-core.test.ts | 17 ++++++++++------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index 8c2f393817e..195c3dc9056 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -230,7 +230,10 @@ export class NotebookAdapter { }) } const expected = revisions.get(revisionKey(loaded.target, request.expectedRevision)) - if (state.revision !== request.expectedRevision && !sameCell(expected?.cells[request.index], state.cells[request.index])) { + if ( + state.revision !== request.expectedRevision && + !sameCell(expected?.cells[request.index], state.cells[request.index]) + ) { throw this.stale(loaded.path, request.index, state.revision, "The targeted notebook cell changed") } const cell = loaded.document.cellAt(request.index) @@ -347,7 +350,9 @@ export class NotebookAdapter { document.cellAt(request.index) !== cell || !sameCell(fingerprint, current.cells[request.index]) ) { - reject(this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution")) + reject( + this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution"), + ) stop() return } diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts index 37c489de6c6..d5b7453ecc8 100644 --- a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -90,7 +90,13 @@ function harness(context: NotebookBridgeContext, dirs = ["/repo"]) { function context(overrides: Partial = {}) { const dispose = mock(() => undefined) const adapter = { - read: mock(async () => ({ operation: "read" as const, path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] })), + read: mock(async () => ({ + operation: "read" as const, + path: "book.ipynb", + requestPath: "book.ipynb", + revision: "content:2", + cells: [], + })), edit: mock(async () => ({ operation: "edit" as const, path: "book.ipynb", diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts index b15af442830..d4e805f8cec 100644 --- a/packages/kilo-vscode/tests/unit/notebook-core.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -69,8 +69,9 @@ function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[] }, apply: async (edit) => { calls.apply++ - const item = (edit as unknown as { edits: Array<{ type: string; index: number; cells?: vscode.NotebookCellData[] }> }) - .edits[0]! + const item = ( + edit as unknown as { edits: Array<{ type: string; index: number; cells?: vscode.NotebookCellData[] }> } + ).edits[0]! if (item.type === "delete") cells.splice(item.index, 1) if (item.type !== "delete") { const input = (item.cells?.[0] as unknown as { input: NotebookCellInput }).input @@ -148,9 +149,9 @@ describe("notebook path security", () => { relative: "nested/book.ipynb", }) const ctx = adapter([cell()], "/repo/nested/book.ipynb") - expect((await ctx.adapter.read({ directory: "/repo", path: "/repo/nested/book.ipynb", includeOutputs: false })).path).toBe( - "nested/book.ipynb", - ) + expect( + (await ctx.adapter.read({ directory: "/repo", path: "/repo/nested/book.ipynb", includeOutputs: false })).path, + ).toBe("nested/book.ipynb") }) it("rejects outside absolute paths and relative or absolute symlink escapes", async () => { @@ -352,7 +353,8 @@ describe("notebook adapter", () => { { items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] } as vscode.NotebookCellOutput, ] target.state.execution = { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } } - for (const listener of ctx.changes) listener(event(ctx.document, target.value, { executionSummary: target.state.execution })) + for (const listener of ctx.changes) + listener(event(ctx.document, target.value, { executionSummary: target.state.execution })) } const result = await ctx.adapter.execute({ directory: "/repo", @@ -430,7 +432,8 @@ describe("notebook adapter", () => { ctx.calls.commandArgs = args if (args[0] !== "notebook.cell.execute") return target.state.source = "after" - for (const listener of ctx.changes) listener(event(ctx.document, target.value, { document: target.value.document })) + for (const listener of ctx.changes) + listener(event(ctx.document, target.value, { document: target.value.document })) } await expect( ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, expectedRevision }), From e125e2c3f2a3b942ad84f2383506e643e72a3ec0 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:45:10 +0200 Subject: [PATCH 14/29] refactor(vscode): clarify notebook cell collection --- packages/kilo-vscode/src/services/notebook/adapter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index 195c3dc9056..fb7b1485487 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -117,12 +117,12 @@ export class NotebookAdapter { const budget = { sources: 0, outputs: 0 } const flags = { sources: false, outputs: false } - const source = loaded.document.getCells() + const items = loaded.document.getCells() const state = this.remember(loaded.document, loaded.target) - if (source.length > 2_000) { + if (items.length > 2_000) { flags.sources = true } - for (const [index, cell] of source.slice(0, 2_000).entries()) { + for (const [index, cell] of items.slice(0, 2_000).entries()) { const source = normalizeSource( cell.document.getText(), Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)), From 00dfb926eaba467ca336ecf586dcef9091537432 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:46:01 +0200 Subject: [PATCH 15/29] fix(vscode): preserve cached client access --- .../cli-backend/connection-service.test.ts | 20 +++++++++++++++++++ .../cli-backend/connection-service.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 41f92c86e61..68404061cbb 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,26 @@ import { describe, expect, test } from "bun:test" +import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" +describe("KiloConnectionService clients", () => { + test("returns a connected client without a workspace folder", async () => { + const service = new KiloConnectionService({} as any) + const client = {} + const workspace = vscode.workspace as { workspaceFolders?: readonly vscode.WorkspaceFolder[] } + const folders = workspace.workspaceFolders + + ;(service as any).client = client + ;(service as any).state = "connected" + workspace.workspaceFolders = undefined + + try { + expect(await service.getClientAsync()).toBe(client) + } finally { + workspace.workspaceFolders = folders + } + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 95417b6514f..33a8f17b712 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -139,10 +139,11 @@ export class KiloConnectionService { * or if the connection fails. */ async getClientAsync(dir?: string): Promise { + if (dir) this.trackDirectory(dir) + if (this.client && this.state === "connected") return this.client const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error("No workspace folder open") this.trackDirectory(root) - if (this.client && this.state === "connected") return this.client await this.connect(root) return this.getClient() } From 3cd521403f900deb7f9f56f0350a759309112636 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:47:35 +0200 Subject: [PATCH 16/29] fix(vscode): relax notebook execution timeouts --- packages/kilo-vscode/src/services/notebook/adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index fb7b1485487..253c95f08a0 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -91,7 +91,7 @@ export class NotebookAdapter { private readonly options: NotebookAdapterOptions = {}, ) { this.deps = options.deps ?? defaults() - this.timeout = options.timeout ?? 120_000 + this.timeout = options.timeout ?? 9 * 60_000 } private async document( @@ -386,7 +386,7 @@ export class NotebookAdapter { ), ) }, - Math.min(timeout, 10_000), + Math.min(timeout, 60_000), ) state.timer = setTimeout(() => { stop() From 9309a8ee8c358bd928877a7e88aa397ab1b64e16 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:51:03 +0200 Subject: [PATCH 17/29] fix(cli): remove dead notebook cancellation status --- packages/kilo-vscode/src/services/notebook/types.ts | 2 +- packages/opencode/src/kilocode/notebook/protocol.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/types.ts b/packages/kilo-vscode/src/services/notebook/types.ts index ec502dc15c0..d195675994f 100644 --- a/packages/kilo-vscode/src/services/notebook/types.ts +++ b/packages/kilo-vscode/src/services/notebook/types.ts @@ -67,7 +67,7 @@ export interface NotebookExecuteResult { requestPath: string revision: string index: number - status: "success" | "error" | "cancelled" + status: "success" | "error" outputs: NotebookOutput[] truncated?: boolean } diff --git a/packages/opencode/src/kilocode/notebook/protocol.ts b/packages/opencode/src/kilocode/notebook/protocol.ts index 12a62078f1a..c893a57f89c 100644 --- a/packages/opencode/src/kilocode/notebook/protocol.ts +++ b/packages/opencode/src/kilocode/notebook/protocol.ts @@ -124,7 +124,7 @@ export const ExecuteResult = Schema.Struct({ requestPath: Path, revision: Revision, index: Index, - status: Schema.Literals(["success", "error", "cancelled"]), + status: Schema.Literals(["success", "error"]), outputs: Schema.Array(Output).check(Schema.isMaxLength(100)), truncated: Schema.optional(Schema.Boolean), }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9dcb70d2871..325f453be3a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2554,7 +2554,7 @@ export type NotebookExecuteResult = { * Zero-based cell index */ index: number - status: "success" | "error" | "cancelled" + status: "success" | "error" outputs: Array truncated?: boolean } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 61970b9f78a..b2c15691b83 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -24554,7 +24554,7 @@ }, "status": { "type": "string", - "enum": ["success", "error", "cancelled"] + "enum": ["success", "error"] }, "outputs": { "type": "array", From ee862d80cc68338c08582fede7e5ab656448c5bf Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:52:33 +0200 Subject: [PATCH 18/29] fix(cli): keep truncated notebook output valid JSON --- packages/opencode/src/kilocode/tool/notebook-host.ts | 12 ++++++++++-- .../opencode/test/kilocode/notebook-tools.test.ts | 9 +++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts index e6d7a88dc77..f192970e6a7 100644 --- a/packages/opencode/src/kilocode/tool/notebook-host.ts +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -14,8 +14,16 @@ const LIMIT = 20_000 function render(value: unknown) { const text = JSON.stringify(value, (key, item) => (key === "requestPath" ? undefined : item), 2) if (text.length <= LIMIT) return text - return `${text.slice(0, LIMIT)} -... notebook result truncated by CLI (${text.length - LIMIT} characters omitted)` + const preview = text.slice(0, 3_000) + return JSON.stringify( + { + truncated: true, + omittedCharacters: text.length - preview.length, + preview, + }, + null, + 2, + ) } function abort(signal: AbortSignal) { diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 64419a1e31f..bca88370e47 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -94,10 +94,11 @@ describe("native notebook tools", () => { expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"]) expect(calls[1]).toMatchObject({ expectedRevision: "content:read" }) expect(calls[2]).toMatchObject({ expectedRevision: "content:edit" }) - expect(readResult.output.length).toBeLessThanOrEqual(20_100) - expect(readResult.output).toContain("notebook result truncated") - expect(readResult.output).toContain('"revision"') - expect(readResult.output).not.toContain("requestPath") + expect(readResult.output.length).toBeLessThanOrEqual(20_000) + const rendered = JSON.parse(readResult.output) + expect(rendered).toMatchObject({ truncated: true, omittedCharacters: expect.any(Number) }) + expect(rendered.preview).toContain('"revision"') + expect(rendered.preview).not.toContain("requestPath") expect(editResult.metadata.revision).toBe("content:edit") expect(executeResult.metadata.index).toBe(0) }), From 54afdd0b4a610336123cb45a93b0c1c6ad00d6a3 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 11:57:08 +0200 Subject: [PATCH 19/29] fix(vscode): release delivered notebook outcomes --- packages/kilo-vscode/src/services/notebook/bridge.ts | 5 ++++- packages/kilo-vscode/tests/unit/notebook-bridge.test.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts index adbaeff8c5f..15f0e24f62b 100644 --- a/packages/kilo-vscode/src/services/notebook/bridge.ts +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -184,7 +184,10 @@ export class NotebookBridge { "result" in outcome ? await this.reply(request.id, directory, outcome.result) : await this.reject(request.id, directory, outcome.error) - if (accepted) this.remember(this.settled, request.id) + if (accepted) { + this.outcomes.delete(request.id) + this.remember(this.settled, request.id) + } } finally { if (this.active.get(request.id) === active) this.active.delete(request.id) } diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts index d5b7453ecc8..274841ad73b 100644 --- a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -139,6 +139,9 @@ describe("NotebookBridge", () => { result: { operation: "read", path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] }, }, ]) + const bridge = test.bridge as unknown as { outcomes: Map; settled: Set } + expect(bridge.outcomes.size).toBe(0) + expect(bridge.settled.has("notebook-1")).toBe(true) test.bridge.dispose() await flush() From 409aaf806761835f8ce6b49f119ba074791a2b95 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 12:00:57 +0200 Subject: [PATCH 20/29] fix(vscode): bound notebook source extraction --- .../src/services/notebook/adapter.ts | 16 +++++++---- .../tests/unit/notebook-core.test.ts | 27 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index 253c95f08a0..16372f8901e 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -123,11 +123,17 @@ export class NotebookAdapter { flags.sources = true } for (const [index, cell] of items.slice(0, 2_000).entries()) { - const source = normalizeSource( - cell.document.getText(), - Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)), - ) - budget.sources += Math.min(source.bytes, NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources) + const limit = Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)) + const source = (() => { + if (limit === 0) { + flags.sources = true + return { text: "", bytes: 0 } + } + const end = cell.document.positionAt(limit + 2) + const range = new vscode.Range(new vscode.Position(0, 0), end) + return normalizeSource(cell.document.getText(range), limit) + })() + budget.sources += source.bytes flags.sources ||= source.truncated === true const value: NotebookCell = { index, diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts index d4e805f8cec..e225d2ca266 100644 --- a/packages/kilo-vscode/tests/unit/notebook-core.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -24,12 +24,17 @@ function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code, langu language: language ?? (kind === vscode.NotebookCellKind.Code ? "python" : "markdown"), outputs: [], } + const getText = mock((range?: vscode.Range) => { + const offset = (range?.end as (vscode.Position & { offset?: number }) | undefined)?.offset + return state.source.slice(0, offset ?? state.source.length) + }) const value = { get kind() { return state.kind }, document: { - getText: () => state.source, + getText, + positionAt: (offset: number) => ({ line: 0, character: offset, offset }) as vscode.Position, get languageId() { return state.language }, @@ -41,7 +46,7 @@ function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code, langu return state.execution }, } as unknown as vscode.NotebookCell - return { value, state } + return { value, state, getText } } function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vscode.NotebookDocument { @@ -270,6 +275,24 @@ describe("notebook adapter", () => { expect(ctx.calls.open).toBe(1) }) + it("bounds source reads and skips extraction after the aggregate budget", async () => { + const items = [ + cell("a".repeat(64 * 1024)), + cell("b".repeat(64 * 1024)), + cell("c".repeat(64 * 1024)), + cell("d".repeat(64 * 1024)), + cell("ignored"), + ] + const ctx = adapter(items) + const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) + + expect(items[0]!.getText).toHaveBeenCalledTimes(2) + expect(items[0]!.getText.mock.calls[1]?.[0]).toBeInstanceOf(vscode.Range) + expect(items[4]!.getText).toHaveBeenCalledTimes(1) + expect(result.cells[4]?.source).toBe("") + expect(result.truncated).toBe(true) + }) + it("chains sequential edits with returned revisions and affected cells", async () => { const ctx = adapter([cell("one")]) const initial = await revision(ctx.adapter) From 75fe210ef98ddbb69fedcd051571e732296e51dc Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 12:06:30 +0200 Subject: [PATCH 21/29] fix(vscode): restrict notebook request directories --- .../src/services/notebook/bridge.ts | 91 +++++++++++++++---- .../tests/unit/notebook-bridge.test.ts | 24 ++++- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts index 15f0e24f62b..7b4f5644d04 100644 --- a/packages/kilo-vscode/src/services/notebook/bridge.ts +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -1,3 +1,4 @@ +import { realpath } from "node:fs/promises" import type { EventKilocodeNotebookCancelled, EventKilocodeNotebookRequested, @@ -37,6 +38,7 @@ export interface NotebookBridgeContext { export interface NotebookBridgeOptions { create?: (directory: string) => Promise + canonical?: (directory: string) => Promise } interface NotebookConnection { @@ -51,6 +53,12 @@ interface ActiveRequest { cancelled: boolean } +interface RequestOrigin { + directory: string + root: string + sessionID: string +} + type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure } async function createContext(directory: string): Promise { @@ -81,12 +89,14 @@ function failure(error: unknown): NotebookFailure { export class NotebookBridge { private readonly contexts = new Map>() private readonly active = new Map() - private readonly origins = new Map() + private readonly admitting = new Set() + private readonly origins = new Map() private readonly outcomes = new Map() private readonly settled = new Set() private readonly unsubscribeEvent: () => void private readonly unsubscribeState: () => void private readonly create: (directory: string) => Promise + private readonly canonical: (directory: string) => Promise private disposed = false private revision = 0 private backend: KiloClient | undefined @@ -96,6 +106,7 @@ export class NotebookBridge { options: NotebookBridgeOptions = {}, ) { this.create = options.create ?? createContext + this.canonical = options.canonical ?? realpath this.unsubscribeEvent = connection.onEvent((event, directory) => this.event(event, directory)) this.unsubscribeState = connection.onStateChange((state) => { if (state !== "connected") return @@ -120,6 +131,7 @@ export class NotebookBridge { request.controller.abort() } this.active.clear() + this.admitting.clear() for (const context of this.contexts.values()) { void context .then((value) => value.dispose()) @@ -137,6 +149,7 @@ export class NotebookBridge { request.controller.abort() } this.active.clear() + this.admitting.clear() this.origins.clear() this.outcomes.clear() this.settled.clear() @@ -154,20 +167,47 @@ export class NotebookBridge { private request(event: EventKilocodeNotebookRequested, directory?: string): void { const request = event.properties - const dir = this.origins.get(request.id) ?? directory - if (!dir || this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return - this.remember(this.origins, request.id, dir) + const origin = this.origins.get(request.id) + if (origin) { + if (origin.sessionID !== request.sessionID || (directory && origin.directory !== directory)) return + this.start(request, origin) + return + } + if (!directory || this.disposed || this.admitting.has(request.id) || this.settled.has(request.id)) return + this.admitting.add(request.id) + void this.admit(request, directory).finally(() => this.admitting.delete(request.id)) + } + + private async admit(request: NotebookRequest, directory: string): Promise { + const root = await this.allowed(directory) + if (this.disposed || this.settled.has(request.id)) return + if (!root) { + const accepted = await this.reject(request.id, directory, { + code: "invalid_path", + message: "Notebook request directory is not an active VS Code workspace", + }) + if (accepted) this.remember(this.settled, request.id) + return + } + const origin = { directory, root, sessionID: request.sessionID } + this.rememberOrigin(request.id, origin) + this.start(request, origin) + } + + private start(request: NotebookRequest, origin: RequestOrigin): void { + if (this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return const active = { controller: new AbortController(), cancelled: false } this.active.set(request.id, active) - void this.run(request, dir, active).catch((error: unknown) => { + void this.run(request, origin, active).catch((error: unknown) => { console.error(`[Kilo New] NotebookBridge: request ${request.id} failed:`, error) }) } private cancel(event: EventKilocodeNotebookCancelled, directory?: string): void { const id = event.properties.requestID - const dir = this.origins.get(id) ?? directory - if (dir) this.remember(this.origins, id, dir) + const origin = this.origins.get(id) + if (origin && (origin.sessionID !== event.properties.sessionID || (directory && origin.directory !== directory))) + return this.remember(this.settled, id) const active = this.active.get(id) if (!active) return @@ -175,15 +215,15 @@ export class NotebookBridge { active.controller.abort() } - private async run(request: NotebookRequest, directory: string, active: ActiveRequest): Promise { + private async run(request: NotebookRequest, origin: RequestOrigin, active: ActiveRequest): Promise { try { - const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, directory, active)) + const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin.root, active)) if (!outcome || this.disposed || active.cancelled) return this.rememberOutcome(request.id, outcome) const accepted = "result" in outcome - ? await this.reply(request.id, directory, outcome.result) - : await this.reject(request.id, directory, outcome.error) + ? await this.reply(request.id, origin.directory, outcome.result) + : await this.reject(request.id, origin.directory, outcome.error) if (accepted) { this.outcomes.delete(request.id) this.remember(this.settled, request.id) @@ -238,6 +278,15 @@ export class NotebookBridge { }) } + private async allowed(directory: string): Promise { + const root = await this.canonical(directory).catch(() => undefined) + if (!root) return undefined + const known = await Promise.all( + this.connection.getKnownDirectories().map((dir) => this.canonical(dir).catch(() => undefined)), + ) + return known.includes(root) ? root : undefined + } + private context(directory: string): Promise { const existing = this.contexts.get(directory) if (existing) return existing @@ -299,14 +348,18 @@ export class NotebookBridge { if (oldest !== undefined) this.outcomes.delete(oldest) } - private remember(map: Map, id: string, value: string): void - private remember(set: Set, id: string): void - private remember(target: Map | Set, id: string, value?: string): void { - if (target instanceof Map && value !== undefined) target.set(id, value) - if (target instanceof Set) target.add(id) - if (target.size <= RETAINED_REQUESTS) return - const oldest = target.keys().next().value - if (oldest !== undefined) target.delete(oldest) + private rememberOrigin(id: string, origin: RequestOrigin): void { + this.origins.set(id, origin) + if (this.origins.size <= RETAINED_REQUESTS) return + const oldest = this.origins.keys().next().value + if (oldest !== undefined) this.origins.delete(oldest) + } + + private remember(set: Set, id: string): void { + set.add(id) + if (set.size <= RETAINED_REQUESTS) return + const oldest = set.keys().next().value + if (oldest !== undefined) set.delete(oldest) } } diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts index 274841ad73b..8fa3e413fdc 100644 --- a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -69,7 +69,7 @@ function harness(context: NotebookBridgeContext, dirs = ["/repo"]) { getKnownDirectories: () => dirs, } const create = mock(async () => context) - const bridge = new NotebookBridge(connection as never, { create }) + const bridge = new NotebookBridge(connection as never, { create, canonical: async (directory) => directory }) const request = (value: NotebookRequest = read, directory = "/repo") => handlers.event?.( { id: `event-${value.id}`, type: "kilocode.notebook.requested", properties: value } as SSEPayload, @@ -150,6 +150,28 @@ describe("NotebookBridge", () => { expect(test.handlers.state).toBeUndefined() }) + it("rejects live requests outside known VS Code directories", async () => { + const ctx = context() + const test = harness(ctx.value, ["/repo"]) + + test.request(read, "/outside") + await flush() + + expect(test.create).not.toHaveBeenCalled() + expect(ctx.adapter.read).not.toHaveBeenCalled() + expect(test.rejections).toEqual([ + { + requestID: "notebook-1", + directory: "/outside", + error: { + code: "invalid_path", + message: "Notebook request directory is not an active VS Code workspace", + }, + }, + ]) + test.bridge.dispose() + }) + it("retries context initialization after a transient failure", async () => { const ctx = context() const test = harness(ctx.value) From 831514e5e7213e9668703b8d375000840faf0635 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 12:31:34 +0200 Subject: [PATCH 22/29] feat(vscode): gate notebook tools behind experiment --- .../components/settings/ExperimentalTab.tsx | 13 ++++++++++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/it.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 +++ .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 +++ .../webview-ui/src/types/messages/config.ts | 1 + packages/opencode/src/config/config.ts | 3 +++ packages/opencode/src/kilocode/agent/index.ts | 3 ++- .../opencode/src/kilocode/tool/registry.ts | 8 +++++-- .../test/kilocode/notebook-tools.test.ts | 7 ++++-- .../kilocode/tool-registry-indexing.test.ts | 24 ++++++++++--------- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 +++ 29 files changed, 107 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 5244ccddd7b..a641b50eff6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -154,6 +154,19 @@ const ExperimentalTab: Component = () => { + + updateExperimental("native_notebook_tools", checked)} + hideLabel + > + {language.t("settings.experimental.nativeNotebookTools.title")} + + + { ) }) -test("uses dedicated VS Code notebook permission defaults", () => { +test("uses dedicated VS Code notebook permission defaults only when enabled", () => { const prev = process.env.KILO_CLIENT try { process.env.KILO_CLIENT = "vscode" - const rules = KiloAgent.prepare({}).defaultsPatch + const disabled = KiloAgent.prepare({}).defaultsPatch + expect(disabled.some((rule) => rule.permission.startsWith("notebook_"))).toBe(false) + + const rules = KiloAgent.prepare({ experimental: { native_notebook_tools: true } }).defaultsPatch expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("allow") expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask") expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask") diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index f80c04366e7..420b359bafa 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -221,18 +221,15 @@ describe("kilocode tool registry indexing", () => { process.env["KILO_CLIENT"] = "vscode" expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual( - [ - "codebase_search", - "semantic_search", - "recall", - "background_process", - "agent_manager", - "notebook_read", - "notebook_edit", - "notebook_execute", - ], + ["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"], ) - expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ + expect( + KiloToolRegistry.extra(tools, { + experimental: { codebase_search: true, native_notebook_tools: true }, + }).map((tool) => tool.id), + ).toEqual([ + "codebase_search", + "semantic_search", "recall", "background_process", "agent_manager", @@ -240,6 +237,11 @@ describe("kilocode tool registry indexing", () => { "notebook_edit", "notebook_execute", ]) + expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ + "recall", + "background_process", + "agent_manager", + ]) process.env["KILO_CLIENT"] = "desktop" expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"]) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 325f453be3a..18badb5a81d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1607,6 +1607,7 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean codebase_search?: boolean + native_notebook_tools?: boolean speech_to_text_model?: string openTelemetry?: boolean primary_tools?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b2c15691b83..4f4e2b491d2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21681,6 +21681,9 @@ "codebase_search": { "type": "boolean" }, + "native_notebook_tools": { + "type": "boolean" + }, "speech_to_text_model": { "type": "string" }, From 9bbb6d5e9fde1df13e17408b986224473f037f0d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 13:10:15 +0200 Subject: [PATCH 23/29] test(cli): update notebook reply fixture --- .../opencode/test/server/httpapi-exercise/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 499bb51202a..68e288bd3bb 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -243,7 +243,15 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: route("/kilocode/notebook/{requestID}/reply", { requestID: "nbr_httpapi_reply" }), headers: ctx.headers(), - body: { result: { operation: "read", path: "notebook.ipynb", version: 1, cells: [] } }, + body: { + result: { + operation: "read", + path: "notebook.ipynb", + requestPath: "notebook.ipynb", + revision: "content:1", + cells: [], + }, + }, })) .json(404, object, "status"), http.protected From e303fa43ea5ef05b022f281ec593b8509e2ad09b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:00:48 +0200 Subject: [PATCH 24/29] chore(vscode): remove native notebook tools plan --- .../1782310595680-native-notebook-tools.md | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 .kilo/plans/1782310595680-native-notebook-tools.md diff --git a/.kilo/plans/1782310595680-native-notebook-tools.md b/.kilo/plans/1782310595680-native-notebook-tools.md deleted file mode 100644 index bab76137deb..00000000000 --- a/.kilo/plans/1782310595680-native-notebook-tools.md +++ /dev/null @@ -1,119 +0,0 @@ -# Native VS Code notebook tools - -## Goal - -Integrate live notebook inspection, editing, and execution into the Kilo VS Code extension without installing, configuring, or embedding a second MCP server. Expose the capabilities only to CLI backends started by the VS Code extension, while keeping Microsoft Jupyter optional. - -## Decisions - -- Expose three focused tools: `notebook_read`, `notebook_edit`, and `notebook_execute`. -- Use the existing authenticated CLI server, global SSE stream, and HTTP SDK for a correlated extension-host request/reply bridge. -- Require an explicit workspace-relative notebook path for every targeted operation. -- Resolve paths against the SSE request directory, enforce containment and ignore rules, and never fall back to the globally active notebook. -- Permit notebooks to be opened in the background for reads and edits. -- Never reveal/focus a notebook or invoke the kernel picker. Execution fails with an actionable error if no controller/kernel is selected. -- Keep Jupyter optional and use stable generic VS Code Notebook APIs and commands. -- Use separate `notebook_read`, `notebook_edit`, and `notebook_execute` permissions. -- Leave edits dirty and preserve VS Code undo/redo and existing autosave behavior. -- Require the expected notebook version for index-based mutations/execution and reject stale requests. -- `notebook_read` returns structure and source by default; `include_outputs: true` adds bounded text/error outputs. -- `notebook_edit` supports inserting, replacing, and deleting one cell per call. -- `notebook_execute` runs one explicit code cell per call and returns only the newly completed execution result. -- Enable the tools by default for VS Code sessions; omit them from other clients. -- Defer cell movement, bulk operations, search, outline generation, metadata editing, output clearing, rich MIME/images, variable introspection, and kernel-context inspection. - -## Implementation plan - -1. **Define a Kilo-owned notebook host protocol in the CLI.** - - Add request, result, and structured error schemas under `packages/opencode/src/kilocode/`. - - Include request ID, session ID, operation, relative notebook path, expected notebook version where applicable, cell index, and operation-specific arguments. - - Define a typed bus event for pending notebook requests and ensure it is imported before the global bus/OpenAPI event union is constructed. - -2. **Implement the pending request lifecycle.** - - Follow the `Question.Service` pattern: store a deferred result, publish the request event, and await an authenticated reply. - - Add cancellation, bounded timeout, instance-disposal cleanup, duplicate/late-reply handling, and typed rejection. - - Add a pending-list operation so the extension can recover requests after an SSE/backend reconnect. - - Ensure every terminal path removes pending state and listeners. - -3. **Add authenticated Kilo HTTP endpoints.** - - Extend the existing Kilo-owned API group rather than adding another shared upstream route integration where possible. - - Add endpoints to list pending requests and reply/reject by request ID. - - Preserve directory scoping through the existing SDK request conventions. - - Regenerate `packages/sdk/js/` with `./script/generate.ts`; do not edit generated files manually. - -4. **Register the three CLI tools.** - - Add Kilo-owned tool implementations and register them through `packages/opencode/src/kilocode/tool/registry.ts` only when `Flag.KILO_CLIENT === "vscode"`. - - Give each tool a narrow non-polymorphic schema and its dedicated permission. - - `notebook_read`: list eligible/open notebooks when appropriate, or read one explicit notebook; include outputs only when requested. - - `notebook_edit`: insert, replace, or delete one cell and require the expected notebook version. - - `notebook_execute`: execute one explicit code-cell index and require the expected notebook version. - - Return compact structured text suitable for the model and apply aggregate output limits. - -5. **Implement a singleton extension-host notebook bridge.** - - Add a service under `packages/kilo-vscode/src/services/notebook/` and instantiate it once from extension activation, not once per `KiloProvider`. - - Subscribe through `KiloConnectionService.onEventFiltered()`, retain the SSE directory, deduplicate request IDs, and recover pending requests after reconnect. - - Post results/rejections through the generated authenticated SDK client. - - Dispose subscriptions and reject/stop in-flight work on extension shutdown or backend replacement. - -6. **Implement a narrow VS Code notebook adapter.** - - Resolve the explicit relative path against the request directory and verify containment and ignore/access policy before opening anything. - - Prefer an already-open `NotebookDocument` so dirty in-memory state is authoritative; otherwise use `workspace.openNotebookDocument()` without showing it. - - Never use active-editor fallback for targeted operations. - - Normalize notebook/cell data into protocol-owned values rather than serializing VS Code objects. - - Bound source/output sizes and represent omitted rich MIME/image data by MIME type and omission metadata. - -7. **Implement reading and editing.** - - Read cell index, kind, language, source, notebook version, and execution summary where stable. - - Include only bounded text and error outputs when `include_outputs` is true; omit image/HTML/binary payload bodies in v1. - - Build `WorkspaceEdit`/`NotebookEdit` operations for insert, replacement, and deletion so changes use the notebook model and undo stack. - - Validate notebook version and cell index immediately before applying an edit. - - Do not save automatically. - -8. **Implement deterministic execution.** - - Validate that the indexed cell is a code cell and that the notebook/version still matches. - - Subscribe to notebook execution/output changes before dispatching the standard VS Code notebook execution command. - - Correlate completion to the requested notebook and cell, requiring a new execution cycle rather than accepting stale `executionSummary` or existing outputs. - - Return the resulting bounded text/error outputs and execution status. - - Honor tool cancellation and timeout, clean up listeners in every path, and return actionable errors for no controller/kernel, closed document, changed cell, extension disconnect, or execution failure. - - Do not reveal the notebook or invoke kernel selection. - -9. **Add CLI tests.** - - Verify VS Code-only tool registration and absence in other clients. - - Verify each dedicated permission request. - - Test event publication with session and directory context, reply completion, rejection, cancellation, timeout, disposal, pending recovery, and duplicate/late replies. - - Test schemas, output bounds, stale-version failures, and structured errors using actual service behavior rather than duplicating implementation logic. - -10. **Add extension tests.** - - Unit-test path containment, worktree routing, ignore enforcement, open-document preference, background opening, version conflicts, cell validation, edit construction, output normalization/truncation, deduplication, reconnect recovery, cancellation, and listener cleanup. - - Add an extension-host integration test with a test notebook serializer/controller. Open a temporary notebook, read live unsaved source, edit a cell, execute one cell, publish outputs, and verify the CLI receives the normalized result. - - Include a no-controller test proving execution fails without changing focus. - - Keep Jupyter-specific behavior as an optional manual/smoke validation, not a CI dependency. - -11. **Release and verification.** - - Add a user-facing minor changeset describing native notebook reading, editing, and execution in VS Code. - - If substantial upstream MIT code is copied, retain its copyright and license notice; prefer adapting concepts and small helpers instead of porting the upstream MCP server or monolithic handlers. - - Run targeted CLI tests and `bun run typecheck` from `packages/opencode/`. - - Run extension typecheck, lint, targeted unit/integration tests, compile/package checks as needed, and `bun run knip` from `packages/kilo-vscode/`. - - Run `bun run script/check-opencode-annotations.ts` and `bun run script/check-opencode-promise-facades.ts` when affected. - - Confirm ordinary Kilo activation and static `.ipynb` reads still work with Jupyter absent. - -## Failure and security requirements - -- Do not start a localhost MCP server, allocate an extra port, or write user MCP configuration. -- Do not allow a request from one workspace/worktree to operate on a notebook outside its directory. -- Do not use editor focus as authority. -- Treat notebook outputs as sensitive and potentially unbounded. -- Treat kernel execution as arbitrary code execution with its own permission. -- Fail closed on stale notebook versions, missing kernels/controllers, disconnects, unsupported notebook providers, and malformed replies. -- Avoid fixed sleeps; use VS Code events and correlated request state. - -## Out of scope - -- Support for notebook operations in the standalone CLI/TUI or non-VS Code clients -- Automatic installation or activation of Microsoft Jupyter -- Automatic kernel selection or UI focus changes -- Cell movement or bulk editing -- Search and outline tools -- Rich image, HTML, widget, or arbitrary MIME transport -- Kernel namespace/variable introspection -- Editing notebook metadata or clearing outputs From 092bfc9ab2c4bf6b2f51a41bbf3d807802b7bd36 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:01:40 +0200 Subject: [PATCH 25/29] fix(cli): default notebook_read permission to ask --- packages/opencode/src/kilocode/agent/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 871e8f9b7a1..15859313516 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -233,7 +233,7 @@ export function prepare(cfg: Config.Info): KiloData { bash, recall: "ask", ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true - ? { notebook_read: "allow" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const } + ? { notebook_read: "ask" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const } : {}), }) return { mcpRules, defaultsPatch } From ecf39e3d0e5cc21acc5e46b75119440e31da1e7b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:23:22 +0200 Subject: [PATCH 26/29] perf(vscode): avoid full-notebook re-hash during execution --- packages/kilo-vscode/src/services/notebook/adapter.ts | 7 ++++--- packages/kilo-vscode/src/services/notebook/revision.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index 16372f8901e..1cad2e9eee3 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -2,7 +2,7 @@ import path from "node:path" import * as vscode from "vscode" import { normalizeOutputs, normalizeSource } from "./output" import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" -import { fingerprint, notebookState, sameCell, type NotebookState } from "./revision" +import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision" import { NOTEBOOK_LIMITS, type NotebookAccess, @@ -349,13 +349,14 @@ export class NotebookAdapter { if (event.notebook !== document) { return } - const current = this.remember(document, target) + const live = document.cellCount > request.index ? cellFingerprint(document.cellAt(request.index)) : undefined if ( document.isClosed || document.cellCount <= request.index || document.cellAt(request.index) !== cell || - !sameCell(fingerprint, current.cells[request.index]) + !sameCell(fingerprint, live) ) { + const current = this.remember(document, target) reject( this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution"), ) diff --git a/packages/kilo-vscode/src/services/notebook/revision.ts b/packages/kilo-vscode/src/services/notebook/revision.ts index 7866bcab9e1..aa820a86e10 100644 --- a/packages/kilo-vscode/src/services/notebook/revision.ts +++ b/packages/kilo-vscode/src/services/notebook/revision.ts @@ -20,7 +20,7 @@ export function fingerprint(kind: "code" | "markdown", language: string, source: return hash([kind, language, source]) } -function cell(cell: vscode.NotebookCell): string { +export function cellFingerprint(cell: vscode.NotebookCell): string { return fingerprint( cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", cell.document.languageId, @@ -29,7 +29,7 @@ function cell(cell: vscode.NotebookCell): string { } export function notebookState(document: vscode.NotebookDocument): NotebookState { - const cells = document.getCells().map(cell) + const cells = document.getCells().map(cellFingerprint) return { revision: `content:${hash(cells)}`, cells } } From 00e9299dc6d59fac6d829d2de8ef10961c00f0fa Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:26:22 +0200 Subject: [PATCH 27/29] i18n(vscode): translate native notebook tools settings --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/it.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 5 ++--- packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 5 ++--- 19 files changed, 38 insertions(+), 40 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 25768f7cf7d..005065c4d32 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1301,9 +1301,9 @@ export const dict = { "قم بتمكين وتسجيل الدخول إلى مزود Kilo لاستخدام Speech to Text. ميزة Speech to Text مدعومة حاليًا فقط مع Kilo Gateway.", "settings.models.speechToTextModel.title": "نموذج تحويل الصوت إلى نص", "settings.models.speechToTextModel.description": "اختر نموذج نسخ Kilo Gateway للإدخال الصوتي.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "أدوات الدفاتر الأصلية", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "تمكين الأدوات التجريبية لقراءة دفاتر VS Code وتحريرها وتنفيذها", "settings.experimental.continueOnDeny.title": "المتابعة عند الرفض", "settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن", "settings.sandboxing.title": "العزل", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 9718d75f62b..7cdef478134 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1334,9 +1334,9 @@ export const dict = { "settings.models.speechToTextModel.title": "Modelo de fala para texto", "settings.models.speechToTextModel.description": "Escolha o modelo de transcrição do Kilo Gateway para entrada de voz.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Ferramentas nativas de notebook", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Ativar ferramentas experimentais para ler, editar e executar notebooks do VS Code", "settings.experimental.continueOnDeny.title": "Continuar ao negar", "settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada", "settings.sandboxing.title": "Isolamento em sandbox", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 1388f2a3e79..b7e2aaa9dc6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1332,9 +1332,9 @@ export const dict = { "Omogućite i prijavite se na Kilo provajder da biste koristili Speech to Text. Speech to Text je trenutno podržan samo uz Kilo Gateway.", "settings.models.speechToTextModel.title": "Model govora u tekst", "settings.models.speechToTextModel.description": "Odaberite Kilo Gateway model za transkripciju za glasovni unos.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Izvorni alati za bilježnice", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Omogući eksperimentalne alate za čitanje, uređivanje i izvršavanje VS Code bilježnica", "settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju", "settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena", "settings.sandboxing.title": "Rad u izoliranom okruženju", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index f75c8ce00fb..781b26d9cd7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1326,9 +1326,9 @@ export const dict = { "Aktivér og log ind på Kilo-udbyderen for at bruge Speech to Text. Speech to Text understøttes i øjeblikket kun med Kilo Gateway.", "settings.models.speechToTextModel.title": "Model til tale til tekst", "settings.models.speechToTextModel.description": "Vælg Kilo Gateway-transskriptionsmodellen til stemmeinput.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Indbyggede notebook-værktøjer", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Aktivér eksperimentelle værktøjer til at læse, redigere og køre VS Code-notebooks", "settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning", "settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises", "settings.sandboxing.title": "Sandboxing", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 101a3c3aebd..15a0a08a813 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1350,9 +1350,9 @@ export const dict = { "settings.models.speechToTextModel.title": "Sprache-zu-Text-Modell", "settings.models.speechToTextModel.description": "Wählen Sie das Kilo Gateway-Transkriptionsmodell für die Spracheingabe.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Native Notebook-Tools", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Experimentelle Tools zum Lesen, Bearbeiten und Ausführen von VS Code-Notebooks aktivieren", "settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren", "settings.experimental.continueOnDeny.description": "Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index efadeffd737..dd6c13884cd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1342,9 +1342,9 @@ export const dict = { "settings.models.speechToTextModel.title": "Modelo de voz a texto", "settings.models.speechToTextModel.description": "Elige el modelo de transcripción de Kilo Gateway para la entrada de voz.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Herramientas nativas de notebook", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Habilitar herramientas experimentales para leer, editar y ejecutar notebooks de VS Code", "settings.experimental.continueOnDeny.title": "Continuar al denegar", "settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso", "settings.sandboxing.title": "Sandbox", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 912a6abc022..6bd8b8246b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1357,9 +1357,9 @@ export const dict = { "settings.models.speechToTextModel.title": "Modèle de transcription vocale", "settings.models.speechToTextModel.description": "Choisissez le modèle de transcription Kilo Gateway pour la saisie vocale.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Outils de notebook natifs", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Activer les outils expérimentaux pour lire, modifier et exécuter les notebooks VS Code", "settings.experimental.continueOnDeny.title": "Continuer en cas de refus", "settings.experimental.continueOnDeny.description": "Continuer la boucle de l'agent lorsqu'une autorisation est refusée", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 338e658f00f..2f0b2b4c890 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1159,9 +1159,9 @@ export const dict = { "Abilita l'indicizzazione semantica del codebase e il tool semantic_search. Richiede configurazione indicizzazione.", "settings.experimental.codebaseSearch.title": "Ricerca codebase", "settings.experimental.codebaseSearch.description": "Abilita ricerca in linguaggio naturale con AI nel codebase", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Strumenti nativi per notebook", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Abilita strumenti sperimentali per leggere, modificare ed eseguire i notebook di VS Code", "settings.experimental.continueOnDeny.title": "Continua dopo rifiuto", "settings.experimental.continueOnDeny.description": "Continua il loop agente quando un'autorizzazione viene negata", "settings.sandboxing.title": "Sandbox", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index fe3f4ad1a84..0c373cd50dd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1320,9 +1320,9 @@ export const dict = { "Speech to Text を使用するには、Kilo プロバイダーを有効にしてサインインしてください。現在、Speech to Text は Kilo Gateway でのみサポートされています。", "settings.models.speechToTextModel.title": "音声認識モデル", "settings.models.speechToTextModel.description": "音声入力に使用するKilo Gateway文字起こしモデルを選択します。", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "ネイティブノートブックツール", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "VS Codeノートブックの読み取り、編集、実行を行う実験的なツールを有効にします", "settings.experimental.continueOnDeny.title": "拒否時に続行", "settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行", "settings.sandboxing.title": "サンドボックス化", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 50df754b777..772842c33e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1314,9 +1314,9 @@ export const dict = { "Speech to Text를 사용하려면 Kilo 제공자를 활성화하고 로그인하세요. 현재 Speech to Text는 Kilo Gateway에서만 지원됩니다.", "settings.models.speechToTextModel.title": "음성 텍스트 변환 모델", "settings.models.speechToTextModel.description": "음성 입력에 사용할 Kilo Gateway 변환 모델을 선택하세요.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "네이티브 노트북 도구", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "VS Code 노트북을 읽고, 편집하고, 실행하는 실험적 도구를 활성화합니다", "settings.experimental.continueOnDeny.title": "거부 시 계속", "settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속", "settings.sandboxing.title": "샌드박스", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index cf3f87bfd60..2ded7151586 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1327,9 +1327,9 @@ export const dict = { "Schakel de Kilo-provider in en meld u aan om Speech to Text te gebruiken. Speech to Text wordt momenteel alleen ondersteund met Kilo Gateway.", "settings.models.speechToTextModel.title": "Spraak-naar-tekst-model", "settings.models.speechToTextModel.description": "Kies het Kilo Gateway-transcriptiemodel voor spraakinvoer.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Native notebooktools", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Experimentele tools inschakelen voor het lezen, bewerken en uitvoeren van VS Code-notebooks", "settings.experimental.continueOnDeny.title": "Doorgaan bij weigering", "settings.experimental.continueOnDeny.description": "Ga door met de agent loop wanneer een toestemming wordt geweigerd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 2f59d28af5a..1047dea88f4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1288,9 +1288,9 @@ export const dict = { "Aktiver og logg på Kilo-leverandøren for å bruke Speech to Text. Speech to Text støttes for øyeblikket bare med Kilo Gateway.", "settings.models.speechToTextModel.title": "Tale-til-tekst-modell", "settings.models.speechToTextModel.description": "Velg Kilo Gateway-transkripsjonsmodellen for taleinndata.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Innebygde notatbok-verktøy", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Aktiver eksperimentelle verktøy for å lese, redigere og kjøre VS Code-notatbøker", "settings.experimental.continueOnDeny.title": "Fortsett ved avvisning", "settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises", "settings.sandboxing.title": "Kjøring i sandkasse", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 915f27bb861..f21c5827e77 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1289,9 +1289,9 @@ export const dict = { "settings.models.speechToTextModel.title": "Model mowy na tekst", "settings.models.speechToTextModel.description": "Wybierz model transkrypcji Kilo Gateway dla wprowadzania głosowego.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Natywne narzędzia notatników", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Włącz eksperymentalne narzędzia do odczytu, edycji i uruchamiania notatników VS Code", "settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie", "settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia", "settings.sandboxing.title": "Izolacja w piaskownicy", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 1f290b8ddcb..0d978e2acd0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1330,9 +1330,9 @@ export const dict = { "Включите провайдер Kilo и выполните вход, чтобы использовать Speech to Text. В настоящее время Speech to Text поддерживается только с Kilo Gateway.", "settings.models.speechToTextModel.title": "Модель речи в текст", "settings.models.speechToTextModel.description": "Выберите модель транскрипции Kilo Gateway для голосового ввода.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Нативные инструменты блокнотов", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Включить экспериментальные инструменты для чтения, редактирования и выполнения блокнотов VS Code", "settings.experimental.continueOnDeny.title": "Продолжить при отказе", "settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении", "settings.sandboxing.title": "Изоляция в песочнице", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 42221c4d0de..1a6bd35f1aa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1310,9 +1310,9 @@ export const dict = { "เปิดใช้งานและลงชื่อเข้าใช้ผู้ให้บริการ Kilo เพื่อใช้ Speech to Text ปัจจุบัน Speech to Text รองรับเฉพาะกับ Kilo Gateway เท่านั้น", "settings.models.speechToTextModel.title": "โมเดลแปลงเสียงเป็นข้อความ", "settings.models.speechToTextModel.description": "เลือกโมเดลการถอดเสียง Kilo Gateway สำหรับการป้อนข้อมูลด้วยเสียง", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "เครื่องมือโน้ตบุ๊กดั้งเดิม", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "เปิดใช้งานเครื่องมือทดลองสำหรับการอ่าน แก้ไข และเรียกใช้โน้ตบุ๊ก VS Code", "settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ", "settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ", "settings.sandboxing.title": "การทำงานในแซนด์บ็อกซ์", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 08476795eeb..c5b57ebfe1e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1319,9 +1319,9 @@ export const dict = { "Speech to Text kullanmak için Kilo sağlayıcısını etkinleştirin ve giriş yapın. Speech to Text şu anda yalnızca Kilo Gateway ile desteklenmektedir.", "settings.models.speechToTextModel.title": "Sesten metne modeli", "settings.models.speechToTextModel.description": "Sesli giriş için Kilo Gateway transkripsiyon modelini seçin.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Yerel Not Defteri Araçları", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "VS Code not defterlerini okumak, düzenlemek ve çalıştırmak için deneysel araçları etkinleştir", "settings.experimental.continueOnDeny.title": "Reddetme Durumunda Devam Et", "settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et", "settings.sandboxing.title": "Sandbox", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index d227c888cb6..a04f92bf540 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1318,9 +1318,9 @@ export const dict = { "Увімкніть провайдер Kilo та виконайте вхід, щоб використовувати Speech to Text. Наразі Speech to Text підтримується лише з Kilo Gateway.", "settings.models.speechToTextModel.title": "Модель мовлення в текст", "settings.models.speechToTextModel.description": "Виберіть модель транскрипції Kilo Gateway для голосового введення.", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", + "settings.experimental.nativeNotebookTools.title": "Власні інструменти для блокнотів", "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "Увімкнути експериментальні інструменти для читання, редагування та виконання блокнотів VS Code", "settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні", "settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено", "settings.sandboxing.title": "Пісочниця", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 220d656431e..da22b965443 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1288,9 +1288,8 @@ export const dict = { "启用并登录 Kilo 提供商以使用 Speech to Text。Speech to Text 目前仅支持通过 Kilo Gateway 使用。", "settings.models.speechToTextModel.title": "语音转文本模型", "settings.models.speechToTextModel.description": "选择用于语音输入的 Kilo Gateway 转录模型。", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", - "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "settings.experimental.nativeNotebookTools.title": "原生笔记本工具", + "settings.experimental.nativeNotebookTools.description": "启用用于读取、编辑和执行 VS Code 笔记本的实验性工具", "settings.experimental.continueOnDeny.title": "拒绝后继续", "settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环", "settings.sandboxing.title": "沙盒", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index e913affce6b..a93e904bda2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1253,9 +1253,8 @@ export const dict = { "啟用並登入 Kilo 供應商以使用 Speech to Text。Speech to Text 目前僅支援透過 Kilo Gateway 使用。", "settings.models.speechToTextModel.title": "語音轉文字模型", "settings.models.speechToTextModel.description": "選擇用於語音輸入的 Kilo Gateway 轉錄模型。", - "settings.experimental.nativeNotebookTools.title": "Native Notebook Tools", - "settings.experimental.nativeNotebookTools.description": - "Enable experimental tools for reading, editing, and executing VS Code notebooks", + "settings.experimental.nativeNotebookTools.title": "原生筆記本工具", + "settings.experimental.nativeNotebookTools.description": "啟用用於讀取、編輯和執行 VS Code 筆記本的實驗性工具", "settings.experimental.continueOnDeny.title": "拒絕後繼續", "settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈", "settings.sandboxing.title": "沙盒", From 5271db7f39802ffea773524e709cda5c469d5710 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:36:35 +0200 Subject: [PATCH 28/29] fix(cli): flatten notebook_edit schema for Anthropic --- .../src/kilocode/tool/notebook-host.ts | 54 ++++++++++--------- .../test/kilocode/notebook-tools.test.ts | 31 ++++++++++- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts index f192970e6a7..7df26967235 100644 --- a/packages/opencode/src/kilocode/tool/notebook-host.ts +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -89,28 +89,37 @@ export const NotebookReadTool = Tool.define< }), ) -const Cell = { - kind: Schema.Literals(["code", "markdown"]), +const EditParams = Schema.Struct({ + path: Path, + expected_revision: Revision, + index: Index, + action: Schema.Literals(["insert", "replace", "delete"]).annotate({ + description: "insert and replace require kind and source; delete ignores cell fields", + }), + kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({ + description: "Cell kind. Required for insert and replace.", + }), language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), - source: Source, + source: Schema.optional(Source).annotate({ description: "Cell source. Required for insert and replace." }), +}) +type EditInput = Schema.Schema.Type + +function cellEdit(params: EditInput) { + if (params.action === "delete") return Effect.succeed({ action: params.action } as const) + if (params.kind === undefined || params.source === undefined) + return Effect.die( + new Tool.InvalidArgumentsError({ + tool: "notebook_edit", + detail: `the "${params.action}" action requires both "kind" and "source"`, + }), + ) + return Effect.succeed({ + action: params.action, + kind: params.kind, + language: params.language, + source: params.source, + }) } -const EditParams = Schema.Union([ - Schema.Struct({ - path: Path, - expected_revision: Revision, - index: Index, - action: Schema.Literal("insert"), - ...Cell, - }), - Schema.Struct({ - path: Path, - expected_revision: Revision, - index: Index, - action: Schema.Literal("replace"), - ...Cell, - }), - Schema.Struct({ path: Path, expected_revision: Revision, index: Index, action: Schema.Literal("delete") }), -]) export const NotebookEditTool = Tool.define< typeof EditParams, @@ -138,10 +147,7 @@ export const NotebookEditTool = Tool.define< expectedRevision: params.expected_revision, }, }) - const edit = - params.action === "delete" - ? ({ action: params.action } as const) - : { action: params.action, kind: params.kind, language: params.language, source: params.source } + const edit = yield* cellEdit(params) const result = yield* run( notebook.request({ operation: "edit", diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 19b2a356b55..90224346fa1 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -5,6 +5,7 @@ import * as KiloAgent from "@/kilocode/agent" import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "@/kilocode/tool/notebook-host" import { MessageID, SessionID } from "@/session/schema" import * as Tool from "@/tool/tool" +import { ToolJsonSchema } from "@/tool/json-schema" import { Truncate } from "@/tool/truncate" import { Effect, Layer } from "effect" import { testEffect } from "../lib/effect" @@ -104,6 +105,34 @@ describe("native notebook tools", () => { }), { git: true }, ) + + it.instance( + "exposes notebook_edit without a top-level schema union", + () => + Effect.gen(function* () { + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const schema = ToolJsonSchema.fromTool(edit) + expect(schema.type).toBe("object") + expect(schema.anyOf).toBeUndefined() + expect(schema.oneOf).toBeUndefined() + expect(schema.allOf).toBeUndefined() + }), + { git: true }, + ) + + it.instance( + "rejects insert without kind and source", + () => + Effect.gen(function* () { + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const ctx = context([]) + const exit = yield* edit + .execute({ path: "analysis.ipynb", expected_revision: "content:read", index: 0, action: "insert" }, ctx) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + { git: true }, + ) }) test("uses dedicated VS Code notebook permission defaults only when enabled", () => { @@ -114,7 +143,7 @@ test("uses dedicated VS Code notebook permission defaults only when enabled", () expect(disabled.some((rule) => rule.permission.startsWith("notebook_"))).toBe(false) const rules = KiloAgent.prepare({ experimental: { native_notebook_tools: true } }).defaultsPatch - expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("allow") + expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("ask") expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask") expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask") } finally { From 8fbb57a7cbda4d26872c047d72809a95a0092439 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 14:53:16 +0200 Subject: [PATCH 29/29] fix(cli): validate notebook_edit before permission prompt --- packages/opencode/src/kilocode/tool/notebook-host.ts | 2 +- packages/opencode/test/kilocode/notebook-tools.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts index 7df26967235..f50f8e78c06 100644 --- a/packages/opencode/src/kilocode/tool/notebook-host.ts +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -136,6 +136,7 @@ export const NotebookEditTool = Tool.define< parameters: EditParams, execute: (params, ctx) => Effect.gen(function* () { + const edit = yield* cellEdit(params) yield* ctx.ask({ permission: "notebook_edit", patterns: [params.path], @@ -147,7 +148,6 @@ export const NotebookEditTool = Tool.define< expectedRevision: params.expected_revision, }, }) - const edit = yield* cellEdit(params) const result = yield* run( notebook.request({ operation: "edit", diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 90224346fa1..616349b4236 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -121,15 +121,17 @@ describe("native notebook tools", () => { ) it.instance( - "rejects insert without kind and source", + "rejects insert without kind and source before asking for permission", () => Effect.gen(function* () { const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) - const ctx = context([]) + const asks: Parameters[0][] = [] + const ctx = context(asks) const exit = yield* edit .execute({ path: "analysis.ipynb", expected_revision: "content:read", index: 0, action: "insert" }, ctx) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") + expect(asks).toEqual([]) }), { git: true }, )