Merge remote-tracking branch 'origin/main' into hardly-wish

# Conflicts:
#	packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts
This commit is contained in:
marius-kilocode
2026-06-25 19:26:36 +02:00
95 changed files with 5021 additions and 54 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Filter marketplace items to those relevant to the current workspace's files and installed VS Code extensions.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
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.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f
size 9851
oid sha256:3545994fd7a126ea860b31e2ac07f9cbeaa03e872f805e3fd2ec7012556c6ea8
size 12665
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c
size 54735
oid sha256:10adc41427bfb41fb25fb25e14c771443c1b3cf23a546b05665370974fbc2209
size 54839
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:572b5a2bfd530b743a8ebaaed2ddacc9611aaa31f450df0f2904f314d8cfff6d
size 52660
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
@@ -33,6 +33,8 @@ export class MarketplacePanelProvider implements vscode.Disposable {
private panel: vscode.WebviewPanel | undefined
private project: string | null = null
private ready = false
private generation = 0
private refresh: ReturnType<typeof setTimeout> | undefined
private statuses = new Map<string, SessionStatus["type"]>()
private disposables: vscode.Disposable[] = []
private subscriptions: Array<() => void> = []
@@ -60,6 +62,7 @@ export class MarketplacePanelProvider implements vscode.Disposable {
if (this.panel) {
this.setProjectDirectory(project)
this.panel.reveal(vscode.ViewColumn.One)
this.scheduleRefresh()
return
}
@@ -105,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) => {
@@ -123,12 +130,15 @@ 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 = []
this.subscriptions = []
this.panel = undefined
this.ready = false
this.generation++
this.statuses.clear()
}
@@ -198,19 +208,32 @@ 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<void> {
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<boolean>("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 +270,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 ?? "" })
}
@@ -264,6 +288,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)
+3
View File
@@ -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<RestoreState>(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()
},
})
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import * as vscode from "vscode"
import { KiloConnectionService } from "./connection-service"
function state(value: boolean) {
@@ -19,6 +20,25 @@ describe("KiloConnectionService sandbox preference", () => {
})
})
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)
@@ -72,6 +72,8 @@ export class KiloConnectionService {
private readonly favoritesChangeListeners: Set<FavoritesChangeListener> = new Set()
private readonly clearPendingPromptsListeners: Set<ClearPendingPromptsListener> = new Set()
private readonly directoryProviders: Set<DirectoryProvider> = new Set()
private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
private currentDirectory: string | undefined
private readonly permissionDirectories: Map<string, string> = new Map()
private readonly questionDirectories: Map<string, string> = new Map()
private questionRevision = 0
@@ -106,6 +108,7 @@ export class KiloConnectionService {
* Lazily start server + SSE. Multiple callers share the same promise.
*/
async connect(workspaceDir: string): Promise<void> {
this.trackDirectory(workspaceDir)
if (this.connectPromise) {
return this.connectPromise
}
@@ -145,13 +148,28 @@ export class KiloConnectionService {
* or if the connection fails.
*/
async getClientAsync(dir?: string): Promise<KiloClient> {
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)
await this.connect(root)
return this.getClient()
}
/** Directories that may own directory-scoped requests on the shared backend. */
getKnownDirectories(): string[] {
const dirs = new Set<string>()
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.
*/
@@ -434,6 +452,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.
@@ -580,6 +604,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()
@@ -28,9 +28,10 @@ export async function fetchMarketplaceData(
ctx: MarketplaceActionContext,
project: string | undefined,
dir: string | undefined,
roots: readonly vscode.Uri[],
): Promise<MarketplaceDataResponse> {
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(
@@ -38,6 +38,7 @@ function transformSkill(raw: RawSkill): SkillMarketplaceItem {
displayCategory: kebabToTitleCase(raw.category),
githubUrl: raw.githubUrl,
content: raw.content,
suggest_for: raw.suggest_for,
}
}
@@ -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<string, Promise<MarketplaceRelevanceMetadata>>()
constructor() {
this.paths = new MarketplacePaths()
@@ -24,16 +27,30 @@ export class MarketplaceService {
this.installer = new MarketplaceInstaller(this.paths)
}
async fetchData(workspace?: string, skills?: CliSkill[]): Promise<MarketplaceDataResponse> {
const [fetched, metadata] = await Promise.all([this.api.fetchAll(), this.detector.detect(workspace, skills)])
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, roots))
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[], roots: readonly vscode.Uri[]): Promise<MarketplaceRelevanceMetadata> {
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, roots).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()
}
}
@@ -0,0 +1,72 @@
import * as vscode from "vscode"
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<boolean>
}
function context(): RelevanceHost {
return {
extensions: vscode.extensions.all.map((extension) => extension.id),
find: async (root, pattern) => {
const glob = new vscode.RelativePattern(root, `**/${pattern}`)
return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0
},
}
}
export async function detectMarketplaceRelevance(
items: MarketplaceItem[],
roots: readonly vscode.Uri[],
source: RelevanceHost = context(),
): Promise<MarketplaceRelevanceMetadata> {
const patterns = Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename))))
const files = new Map<string, boolean>()
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))
}),
)
}
const extensions = new Set(source.extensions.map((id) => id.toLowerCase()))
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()),
)
if (!filename?.length && !vscodeExtension?.length) return []
return [
[
`${item.type}:${item.id}`,
{
...(filename?.length && { filename }),
...(vscodeExtension?.length && { vscodeExtension }),
},
],
]
}),
)
}
@@ -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<string, { type: string }>
}
export interface MarketplaceRelevance {
filename?: string[]
vscodeExtension?: string[]
}
export type MarketplaceRelevanceMetadata = Record<string, MarketplaceRelevance>
export interface MarketplaceDataResponse {
marketplaceItems: MarketplaceItem[]
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
marketplaceRelevance: MarketplaceRelevanceMetadata
errors?: string[]
}
@@ -0,0 +1,460 @@
import path from "node:path"
import * as vscode from "vscode"
import { normalizeOutputs, normalizeSource } from "./output"
import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision"
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"
const RETAINED_REVISIONS = 1_000
const revisions = new Map<string, NotebookState>()
const locks = new Map<string, Promise<void>>()
function revisionKey(target: string, revision: string): string {
return `${target}\0${revision}`
}
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 ?? 9 * 60_000
}
private async document(
directory: string,
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 ${JSON.stringify(resolved.relative)} is closed`, {
path: resolved.relative,
})
}
return { document, path: resolved.relative, target: resolved.target }
}
async read(request: NotebookReadRequest): Promise<NotebookReadResult> {
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 items = loaded.document.getCells()
const state = this.remember(loaded.document, loaded.target)
if (items.length > 2_000) {
flags.sources = true
}
for (const [index, cell] of items.slice(0, 2_000).entries()) {
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,
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: loaded.path,
requestPath: request.path,
revision: state.revision,
cells,
...(flags.sources || flags.outputs ? { truncated: true } : {}),
}
}
async edit(request: NotebookEditRequest): Promise<NotebookEditResult> {
const loaded = await this.document(request.directory, request.path)
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 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 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")
}
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<NotebookExecuteResult> {
const loaded = await this.document(request.directory, request.path)
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`, {
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`, {
path: loaded.path,
index: request.index,
})
}
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 }],
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}`, {
path: loaded.path,
index: request.index,
}),
)
})
return result.promise
}
private wait(
document: vscode.NotebookDocument,
cell: vscode.NotebookCell,
target: string,
path: string,
fingerprint: NotebookState["cells"][number],
request: NotebookExecuteRequest,
) {
const state: {
done: boolean
timer?: ReturnType<typeof setTimeout>
startup?: ReturnType<typeof setTimeout>
} = { 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<NotebookExecuteResult>((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,
requestPath: request.path,
revision: this.remember(document, target).revision,
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", {
path,
index: request.index,
}),
)
}
disposables.push(
this.deps.change((event) => {
if (event.notebook !== document) {
return
}
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, live)
) {
const current = this.remember(document, target)
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 (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve()
}),
this.deps.close((closed) => {
if (closed === document) {
reject(new NotebookError("closed", "Notebook closed during execution", { path, index: request.index }))
}
}),
)
const timeout = request.timeout ?? this.timeout
state.startup = setTimeout(
() => {
stop()
reject(
new NotebookError(
"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 },
),
)
},
Math.min(timeout, 60_000),
)
state.timer = setTimeout(() => {
stop()
reject(new NotebookError("timeout", "Notebook execution timed out", { path, index: request.index }))
}, timeout)
if (request.signal?.aborted) {
abort()
} else {
request.signal?.addEventListener("abort", abort, { once: true })
}
return { promise, reject }
}
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<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = locks.get(key) ?? Promise.resolve()
const gate: { release?: () => void } = {}
const current = new Promise<void>((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)
}
}
}
@@ -0,0 +1,368 @@
import { realpath } from "node:fs/promises"
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<NotebookFailure["code"]>([
"cancelled",
"closed",
"disconnected",
"execution_failed",
"invalid_cell",
"invalid_path",
"no_kernel",
"not_found",
"stale_revision",
"timeout",
"unsupported",
])
type NotebookAdapterLike = Pick<NotebookAdapter, "read" | "edit" | "execute">
export interface NotebookBridgeContext {
adapter: NotebookAdapterLike
refresh?(): Promise<void>
dispose(): void
}
export interface NotebookBridgeOptions {
create?: (directory: string) => Promise<NotebookBridgeContext>
canonical?: (directory: string) => Promise<string>
}
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
}
interface RequestOrigin {
directory: string
root: string
sessionID: string
}
type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure }
async function createContext(directory: string): Promise<NotebookBridgeContext> {
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 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,
...(error.path !== undefined ? { path: error.path } : {}),
...(error.index !== undefined ? { index: error.index } : {}),
...(error.currentRevision !== undefined ? { currentRevision: error.currentRevision } : {}),
}
}
return { code: "execution_failed", message }
}
export class NotebookBridge {
private readonly contexts = new Map<string, Promise<NotebookBridgeContext>>()
private readonly active = new Map<string, ActiveRequest>()
private readonly admitting = new Set<string>()
private readonly origins = new Map<string, RequestOrigin>()
private readonly outcomes = new Map<string, NotebookOutcome>()
private readonly settled = new Set<string>()
private readonly unsubscribeEvent: () => void
private readonly unsubscribeState: () => void
private readonly create: (directory: string) => Promise<NotebookBridgeContext>
private readonly canonical: (directory: string) => Promise<string>
private disposed = false
private revision = 0
private backend: KiloClient | undefined
constructor(
private readonly connection: NotebookConnection,
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
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()
this.admitting.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.admitting.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 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<void> {
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, 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 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
active.cancelled = true
active.controller.abort()
}
private async run(request: NotebookRequest, origin: RequestOrigin, active: ActiveRequest): Promise<void> {
try {
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, 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)
}
} finally {
if (this.active.get(request.id) === active) this.active.delete(request.id)
}
}
private async execute(
request: NotebookRequest,
directory: string,
active: ActiveRequest,
): Promise<NotebookOutcome | undefined> {
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<NotebookResult> {
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,
expectedRevision: request.expectedRevision,
index: request.index,
edit: request.edit,
})
}
return adapter.execute({
path: request.path,
directory,
expectedRevision: request.expectedRevision,
index: request.index,
signal,
})
}
private async allowed(directory: string): Promise<string | undefined> {
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<NotebookBridgeContext> {
const existing = this.contexts.get(directory)
if (existing) return existing
const context = this.create(directory).catch((error: unknown) => {
this.contexts.delete(directory)
throw error
})
this.contexts.set(directory, context)
return context
}
private async reply(requestID: string, directory: string, result: NotebookResult): Promise<boolean> {
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<boolean> {
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<void> {
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 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<string>, id: string): void {
set.add(id)
if (set.size <= RETAINED_REQUESTS) return
const oldest = set.keys().next().value
if (oldest !== undefined) set.delete(oldest)
}
}
export function createNotebookBridge(connection: KiloConnectionService): NotebookBridge {
return new NotebookBridge(connection)
}
@@ -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"
@@ -0,0 +1,95 @@
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
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 {
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<string, unknown>) : {}
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 }
}
@@ -0,0 +1,86 @@
import fs from "node:fs/promises"
import path from "node:path"
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
}
}
export interface NotebookPathDeps {
realpath(path: string): Promise<string>
}
export interface NotebookPath {
target: string
relative: string
}
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))
}
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,
input: string,
access: NotebookAccess,
deps: NotebookPathDeps = defaults,
): Promise<NotebookPath> {
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 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 ${JSON.stringify(input)}: ${detail}`, { path: input })
})
if (!contained(root, target)) {
throw invalid(input, "it resolves through a symlink outside the request directory")
}
if (!(await access.validateAccess(target))) {
throw invalid(input, "it is excluded by workspace access or ignore rules")
}
return { target, relative: path.relative(root, target).split(path.sep).join("/") }
}
@@ -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])
}
export function cellFingerprint(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(cellFingerprint)
return { revision: `content:${hash(cells)}`, cells }
}
export function sameCell(left: string | undefined, right: string | undefined): boolean {
return left !== undefined && left === right
}
@@ -0,0 +1,126 @@
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
requestPath: string
revision: string
cells: NotebookCell[]
truncated?: boolean
}
export interface NotebookEditResult {
operation: "edit"
path: string
requestPath: string
revision: string
index: number
action: "insert" | "replace" | "delete"
cell?: NotebookCell
}
export interface NotebookExecuteResult {
operation: "execute"
path: string
requestPath: string
revision: string
index: number
status: "success" | "error"
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
expectedRevision: string
index: number
edit: NotebookEdit
}
export interface NotebookExecuteRequest {
path: string
directory: string
expectedRevision: string
index: number
signal?: AbortSignal
timeout?: number
}
export interface NotebookAccess {
validateAccess(path: string): boolean | Promise<boolean>
}
export interface NotebookAdapterDeps {
documents(): readonly vscode.NotebookDocument[]
open(uri: vscode.Uri): Promise<vscode.NotebookDocument>
apply(edit: vscode.WorkspaceEdit): Promise<boolean>
execute(command: string, ...args: unknown[]): Promise<unknown>
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
}
@@ -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"
@@ -127,6 +132,16 @@ 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"])
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)
})
})
@@ -0,0 +1,104 @@
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"
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 root = vscode.Uri.file("/repo")
const find = mock(async (_root: vscode.Uri, pattern: string) => pattern === "*.component.ts")
const relevance = await detectMarketplaceRelevance(items, [root], {
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([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("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)
const relevance = await detectMarketplaceRelevance(items, [], {
extensions: ["ms-toolsai.jupyter"],
find,
})
expect(relevance).toEqual({ "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } })
expect(find).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,324 @@
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<T>() {
const state: { resolve?: (value: T) => void; reject?: (error: Error) => void } = {}
const promise = new Promise<T>((resolve, reject) => {
state.resolve = resolve
state.reject = reject
})
return { promise, resolve: state.resolve!, reject: state.reject! }
}
async function flush(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve))
await new Promise<void>((resolve) => setImmediate(resolve))
}
function harness(context: NotebookBridgeContext, dirs = ["/repo"]) {
const replies: unknown[] = []
const rejections: unknown[] = []
const lists = new Map<string, NotebookRequest[]>()
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, 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,
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<NotebookBridgeContext["adapter"]> = {}) {
const dispose = mock(() => undefined)
const adapter = {
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",
requestPath: "book.ipynb",
revision: "content:2",
index: 0,
action: "replace" as const,
})),
execute: mock(async () => ({
operation: "execute" as const,
path: "book.ipynb",
requestPath: "book.ipynb",
revision: "content: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", requestPath: "book.ipynb", revision: "content:2", cells: [] },
},
])
const bridge = test.bridge as unknown as { outcomes: Map<string, unknown>; settled: Set<string> }
expect(bridge.outcomes.size).toBe(0)
expect(bridge.settled.has("notebook-1")).toBe(true)
test.bridge.dispose()
await flush()
expect(ctx.dispose).toHaveBeenCalledTimes(1)
expect(test.handlers.event).toBeUndefined()
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)
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)
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<never>()
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",
expectedRevision: "content: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("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_revision", "Notebook changed", {
path: "book.ipynb",
index: 0,
currentRevision: "content:current",
})
}),
})
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_revision",
message: "Notebook changed",
path: "book.ipynb",
index: 0,
currentRevision: "content:current",
},
},
])
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)
})
})
@@ -0,0 +1,469 @@
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"
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, language?: string) {
const state: CellState = {
source,
kind,
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,
positionAt: (offset: number) => ({ line: 0, character: offset, offset }) as vscode.Position,
get languageId() {
return state.language
},
},
get outputs() {
return state.outputs
},
get executionSummary() {
return state.execution
},
} as unknown as vscode.NotebookCell
return { value, state, getText }
}
function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vscode.NotebookDocument {
return {
uri: uri(file),
version: 1,
isClosed: false,
get cellCount() {
return cells.length
},
getCells: () => cells,
cellAt: (index: number) => cells[index]!,
} as unknown as 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 }
const deps: NotebookAdapterDeps = {
documents: () => [document],
open: async () => {
calls.open++
return document
},
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
},
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, 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,
}
return { deps, changes, closes, calls }
}
const paths = { realpath: async (value: string) => value }
const access = { validateAccess: mock(() => true) }
function adapter(items: ReturnType<typeof cell>[], 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("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", "/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()
})
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 })
expect(normalizeSource("a€b", 3)).toEqual({ text: "a", bytes: 5, 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 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 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",
cells: [
{ index: 0, kind: "code", language: "python", source: "unsaved" },
{ index: 1, kind: "markdown", language: "markdown", source: "# title" },
],
})
ctx.deps.documents = () => []
await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false })
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)
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,
expectedRevision,
edit: { action: "delete" },
}),
).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,
expectedRevision,
edit: { action: "replace", kind: "code", source: "first" },
})
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("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
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,
expectedRevision,
})
expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] })
expect(result.revision).not.toBe(expectedRevision)
expect(ctx.changes.size).toBe(0)
expect(ctx.closes.size).toBe(0)
})
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,
expectedRevision,
timeout: 5,
}),
).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: ctx.document.uri },
])
})
it("cancels execution and disposes listeners", async () => {
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,
expectedRevision,
signal: controller.signal,
})
controller.abort()
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 },
])
})
})
@@ -3,14 +3,16 @@ 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"
import { filterItems, hasRelevantItems, retain } from "./utils"
import { ItemCard } from "./ItemCard"
import { MarketplaceContribute } from "./MarketplaceContribute"
@@ -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<StatusOption>({ value: "all", label: t("marketplace.filter.all") })
const [types, setTypes] = createSignal<MarketplaceItem["type"][]>([])
const [categories, setCategories] = createSignal<string[]>([])
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)}
/>
</div>
<div class="marketplace-relevance-filter">
<Checkbox checked={relevant()} onChange={setRelevant}>
{t("marketplace.filter.relevant")}
</Checkbox>
</div>
<Show when={allTypes().length > 1}>
<div class="marketplace-types">
<For each={allTypes()}>
@@ -149,7 +170,11 @@ export const MarketplaceListView = (props: Props) => {
when={filtered().length > 0}
fallback={
<div class="marketplace-empty">
<span class="marketplace-empty-message">{props.emptyMessage}</span>
<span class="marketplace-empty-message">
{relevant() && !hasRelevantItems(props.items, props.relevance)
? props.relevantEmptyMessage
: props.emptyMessage}
</span>
<MarketplaceContribute />
</div>
}
@@ -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<MarketplaceItem[]>([])
const [metadata, setMetadata] = createSignal<MarketplaceInstalledMetadata>(EMPTY_METADATA)
const [relevance, setRelevance] = createSignal<MarketplaceRelevanceMetadata>(EMPTY_RELEVANCE)
const [fetching, setFetching] = createSignal(true)
const [errors, setErrors] = createSignal<string[]>([])
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 = () => {
<MarketplaceListView
items={items()}
metadata={metadata()}
relevance={relevance()}
fetching={fetching()}
searchPlaceholder={t("marketplace.search")}
emptyMessage={t("marketplace.empty")}
relevantEmptyMessage={t("marketplace.empty.relevant")}
onInstall={handleInstall}
onRemove={handleRemove}
/>
@@ -27,6 +27,11 @@
flex: 1;
}
.marketplace-relevance-filter {
display: flex;
align-items: center;
}
/* Type and category filters */
.marketplace-types,
@@ -1,4 +1,12 @@
import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace"
import type {
MarketplaceInstalledMetadata,
MarketplaceItem,
MarketplaceRelevanceMetadata,
} from "../../types/marketplace"
export function hasRelevantItems(items: MarketplaceItem[], relevance: MarketplaceRelevanceMetadata): boolean {
return items.some((item) => !!relevance[`${item.type}:${item.id}`])
}
export function retain<T>(selected: T[], available: T[]): T[] {
const values = new Set(available)
@@ -48,6 +56,8 @@ export function filterItems(
categories: string[],
types: MarketplaceItem["type"][],
labels: Partial<Record<MarketplaceItem["type"], string>> = {},
relevant = false,
relevance: MarketplaceRelevanceMetadata = {},
): MarketplaceItem[] {
const query = search.trim().toLowerCase()
return items
@@ -56,6 +66,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)
})
@@ -154,6 +154,19 @@ const ExperimentalTab: Component = () => {
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.nativeNotebookTools.title")}
description={language.t("settings.experimental.nativeNotebookTools.description")}
>
<Switch
checked={experimental().native_notebook_tools ?? false}
onChange={(checked) => updateExperimental("native_notebook_tools", checked)}
hideLabel
>
{language.t("settings.experimental.nativeNotebookTools.title")}
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.continueOnDeny.title")}
description={language.t("settings.experimental.continueOnDeny.description")}
+3
View File
@@ -1313,6 +1313,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": "أدوات الدفاتر الأصلية",
"settings.experimental.nativeNotebookTools.description":
"تمكين الأدوات التجريبية لقراءة دفاتر VS Code وتحريرها وتنفيذها",
"settings.experimental.continueOnDeny.title": "المتابعة عند الرفض",
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
"settings.sandboxing.title": "العزل",
+3
View File
@@ -1348,6 +1348,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": "Ferramentas nativas de notebook",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1344,6 +1344,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": "Izvorni alati za bilježnice",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1338,6 +1338,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": "Indbyggede notebook-værktøjer",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1364,6 +1364,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.description":
"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",
@@ -1321,6 +1321,9 @@ export const dict = {
"Enable and sign in to the Kilo provider to use Speech to Text. Speech to Text is currently only supported through Kilo Gateway.",
"settings.models.speechToTextModel.title": "Speech to Text Model",
"settings.models.speechToTextModel.description": "Choose the Kilo Gateway transcription model for voice input.",
"settings.experimental.nativeNotebookTools.title": "Native Notebook Tools",
"settings.experimental.nativeNotebookTools.description":
"Enable experimental tools for reading, editing, and executing VS Code notebooks",
"settings.experimental.continueOnDeny.title": "Continue on Deny",
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
"settings.experimental.sandbox.title": "Sandbox",
+3
View File
@@ -1355,6 +1355,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": "Herramientas nativas de notebook",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1370,6 +1370,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": "Outils de notebook natifs",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1159,6 +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": "Strumenti nativi per notebook",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1333,6 +1333,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": "ネイティブノートブックツール",
"settings.experimental.nativeNotebookTools.description":
"VS Codeノートブックの読み取り、編集、実行を行う実験的なツールを有効にします",
"settings.experimental.continueOnDeny.title": "拒否時に続行",
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
"settings.sandboxing.title": "サンドボックス化",
+3
View File
@@ -1326,6 +1326,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": "네이티브 노트북 도구",
"settings.experimental.nativeNotebookTools.description":
"VS Code 노트북을 읽고, 편집하고, 실행하는 실험적 도구를 활성화합니다",
"settings.experimental.continueOnDeny.title": "거부 시 계속",
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
"settings.sandboxing.title": "샌드박스",
+3
View File
@@ -1340,6 +1340,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 notebooktools",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1301,6 +1301,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": "Innebygde notatbok-verktøy",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1301,6 +1301,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": "Natywne narzędzia notatników",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1342,6 +1342,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": "Нативные инструменты блокнотов",
"settings.experimental.nativeNotebookTools.description":
"Включить экспериментальные инструменты для чтения, редактирования и выполнения блокнотов VS Code",
"settings.experimental.continueOnDeny.title": "Продолжить при отказе",
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
"settings.sandboxing.title": "Изоляция в песочнице",
+3
View File
@@ -1322,6 +1322,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": "เครื่องมือโน้ตบุ๊กดั้งเดิม",
"settings.experimental.nativeNotebookTools.description":
"เปิดใช้งานเครื่องมือทดลองสำหรับการอ่าน แก้ไข และเรียกใช้โน้ตบุ๊ก VS Code",
"settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ",
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
"settings.sandboxing.title": "การทำงานในแซนด์บ็อกซ์",
+3
View File
@@ -1332,6 +1332,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": "Yerel Not Defteri Araçları",
"settings.experimental.nativeNotebookTools.description":
"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",
+3
View File
@@ -1330,6 +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": "Власні інструменти для блокнотів",
"settings.experimental.nativeNotebookTools.description":
"Увімкнути експериментальні інструменти для читання, редагування та виконання блокнотів VS Code",
"settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні",
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
"settings.sandboxing.title": "Пісочниця",
+2
View File
@@ -1300,6 +1300,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": "原生笔记本工具",
"settings.experimental.nativeNotebookTools.description": "启用用于读取、编辑和执行 VS Code 笔记本的实验性工具",
"settings.experimental.continueOnDeny.title": "拒绝后继续",
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
"settings.sandboxing.title": "沙盒",
+2
View File
@@ -1264,6 +1264,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": "原生筆記本工具",
"settings.experimental.nativeNotebookTools.description": "啟用用於讀取、編輯和執行 VS Code 筆記本的實驗性工具",
"settings.experimental.continueOnDeny.title": "拒絕後繼續",
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
"settings.sandboxing.title": "沙盒",
@@ -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 = {
<MarketplaceListView
items={[...MOCK_AGENTS, ...MOCK_MCPS, ...MOCK_SKILLS]}
metadata={PARTIAL_INSTALLED_MIXED}
relevance={RELEVANCE}
fetching={false}
searchPlaceholder="Search marketplace..."
emptyMessage="No items found"
relevantEmptyMessage="No relevant marketplace items found for this workspace."
onInstall={noop}
onRemove={noop}
/>
</div>
</StoryProviders>
),
}
export const RelevantItems: Story = {
name: "Mixed list — relevant to workspace",
render: () => (
<StoryProviders>
<div style={{ "max-height": "700px", overflow: "auto", padding: "12px" }}>
<MarketplaceListView
items={[...MOCK_AGENTS, ...MOCK_MCPS, ...MOCK_SKILLS]}
metadata={PARTIAL_INSTALLED_MIXED}
relevance={RELEVANCE}
fetching={false}
searchPlaceholder="Search marketplace..."
emptyMessage="No items found"
relevantEmptyMessage="No relevant marketplace items found for this workspace."
initialRelevant
onInstall={noop}
onRemove={noop}
/>
@@ -327,9 +357,11 @@ export const EmptyList: Story = {
<MarketplaceListView
items={[]}
metadata={EMPTY_METADATA}
relevance={{}}
fetching={false}
searchPlaceholder="Search marketplace..."
emptyMessage="No items found"
relevantEmptyMessage="No relevant marketplace items found for this workspace."
onInstall={noop}
onRemove={noop}
/>
@@ -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<string, { type: string }>
}
export interface MarketplaceRelevance {
filename?: string[]
vscodeExtension?: string[]
}
export type MarketplaceRelevanceMetadata = Record<string, MarketplaceRelevance>
export interface MarketplaceFilters {
type?: string
search?: string
@@ -40,6 +40,7 @@ export interface WatcherConfig {
export interface ExperimentalConfig {
batch_tool?: boolean
codebase_search?: boolean
native_notebook_tools?: boolean
speech_to_text_model?: string
primary_tools?: string[]
continue_loop_on_deny?: boolean
@@ -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 {
@@ -919,6 +919,7 @@ export interface MarketplaceDataMessage {
type: "marketplaceData"
marketplaceItems: MarketplaceItem[]
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
marketplaceRelevance: MarketplaceRelevanceMetadata
errors?: string[]
showAgentMigrationBanner?: boolean
}
+3
View File
@@ -390,6 +390,9 @@ export const Info = Schema.Struct({
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
// kilocode_change start
codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }),
native_notebook_tools: Schema.optional(Schema.Boolean).annotate({
description: "Enable native tools for reading, editing, and executing VS Code notebooks",
}),
speech_to_text_model: Schema.optional(Schema.String).annotate({
description: "Speech-to-text transcription model ID to use for voice input",
}),
@@ -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)],
)
@@ -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,
@@ -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" && cfg.experimental?.native_notebook_tools === true
? { notebook_read: "ask" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const }
: {}),
})
return { mcpRules, defaultsPatch }
}
@@ -239,6 +246,7 @@ export function cacheKey(cfg: Config.Info) {
mcp: cfg.mcp,
mode: cfg.mode,
permission: cfg.permission,
native_notebook_tools: cfg.experimental?.native_notebook_tools,
})
}
@@ -0,0 +1,177 @@
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<typeof RequestID>
export const Path = Schema.String.check(
Schema.isMinLength(1),
Schema.isMaxLength(4096),
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 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({
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<typeof Output>
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<typeof Cell>
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"),
expectedRevision: Revision,
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"),
expectedRevision: Revision,
index: Index,
}).annotate({ identifier: "NotebookExecuteRequest" })
export const Request = Schema.Union([ReadRequest, EditRequest, ExecuteRequest]).annotate({
identifier: "NotebookRequest",
})
export type Request = Schema.Schema.Type<typeof Request>
export const ReadResult = Schema.Struct({
operation: Schema.Literal("read"),
path: Path,
requestPath: Path,
revision: Revision,
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,
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,
requestPath: Path,
revision: Revision,
index: Index,
status: Schema.Literals(["success", "error"]),
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<typeof Result>
export const ErrorCode = Schema.Literals([
"cancelled",
"closed",
"disconnected",
"execution_failed",
"invalid_cell",
"invalid_path",
"no_kernel",
"not_found",
"stale_revision",
"timeout",
"unsupported",
])
export type ErrorCode = Schema.Schema.Type<typeof ErrorCode>
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<typeof Failure>
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"]),
}),
),
}
@@ -0,0 +1,162 @@
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> = T extends unknown ? Omit<T, "id"> : never
export type Input = WithoutID<Request>
export class HostError extends Schema.TaggedErrorClass<HostError>()("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
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Notebook.NotFoundError", {
requestID: RequestID,
}) {}
export class InvalidReplyError extends Schema.TaggedErrorClass<InvalidReplyError>()("Notebook.InvalidReplyError", {
requestID: RequestID,
}) {}
interface Entry {
info: Request
deferred: Deferred.Deferred<Result, HostError>
}
interface State {
pending: Map<RequestID, Entry>
}
function matches(request: Request, result: Result) {
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
}
export interface Interface {
readonly request: (input: Input) => Effect.Effect<Result, HostError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly reply: (input: {
requestID: RequestID
result: Result
}) => Effect.Effect<void, NotFoundError | InvalidReplyError>
readonly reject: (input: { requestID: RequestID; error: Failure }) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@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<State>(
Effect.fn("Notebook.state")(function* () {
const state = { pending: new Map<RequestID, Entry>() }
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<Result, HostError>()
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,
path: input.error.path,
index: input.error.index,
currentRevision: input.error.currentRevision,
}),
)
})
return Service.of({ request, list, reply, reject })
}),
)
}
export const defaultLayer = layer().pipe(Layer.provide(Bus.layer))
export * as Notebook from "./service"
@@ -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({
@@ -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)
}),
)
@@ -0,0 +1,217 @@
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 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, (key, item) => (key === "requestPath" ? undefined : item), 2)
if (text.length <= LIMIT) return text
const preview = text.slice(0, 3_000)
return JSON.stringify(
{
truncated: true,
omittedCharacters: text.length - preview.length,
preview,
},
null,
2,
)
}
function abort(signal: AbortSignal) {
return Effect.callback<never, HostError>((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<Result, HostError>, 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; revision: string },
Notebook.Service,
"notebook_read"
>(
"notebook_read",
Effect.gen(function* () {
const notebook = yield* Notebook.Service
return {
description:
"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* () {
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, revision: result.revision },
}
}),
}
}),
)
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: Schema.optional(Source).annotate({ description: "Cell source. Required for insert and replace." }),
})
type EditInput = Schema.Schema.Type<typeof EditParams>
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,
})
}
export const NotebookEditTool = Tool.define<
typeof EditParams,
{ path: string; revision: string; 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. 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* () {
const edit = yield* cellEdit(params)
yield* ctx.ask({
permission: "notebook_edit",
patterns: [params.path],
always: [params.path],
metadata: {
path: params.path,
action: params.action,
index: params.index,
expectedRevision: params.expected_revision,
},
})
const result = yield* run(
notebook.request({
operation: "edit",
sessionID: ctx.sessionID,
path: params.path,
expectedRevision: params.expected_revision,
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, revision: result.revision, index: result.index },
}
}),
}
}),
)
const ExecuteParams = Schema.Struct({ path: Path, expected_revision: Revision, index: Index })
export const NotebookExecuteTool = Tool.define<
typeof ExecuteParams,
{ path: string; revision: string; 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 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* () {
yield* ctx.ask({
permission: "notebook_execute",
patterns: [params.path],
always: [params.path],
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,
expectedRevision: params.expected_revision,
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, revision: result.revision, index: result.index },
}
}),
}
}),
)
@@ -3,9 +3,11 @@ 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"
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"
@@ -31,20 +33,34 @@ 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
return { codebase, recall, manager, process }
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 }
})
}
/** 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 = {},
) {
@@ -55,8 +71,16 @@ export namespace KiloToolRegistry {
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
})
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 }
})
}
@@ -99,8 +123,17 @@ 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 },
cfg: { experimental?: { codebase_search?: boolean } },
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; native_notebook_tools?: boolean } },
): Tool.Def[] {
return [
...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []),
@@ -109,6 +142,13 @@ export namespace KiloToolRegistry {
...(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" &&
cfg.experimental?.native_notebook_tools === true &&
tools.notebookRead &&
tools.notebookEdit &&
tools.notebookExecute
? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute]
: []),
]
}
@@ -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,
+5 -2
View File
@@ -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
@@ -38,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" // 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"
@@ -150,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()
const notebook = Option.getOrUndefined(yield* Effect.serviceOption(Notebook.Service))
const kiloToolInfos = yield* KiloToolRegistry.infos(notebook)
// kilocode_change end
const state = yield* InstanceState.make<State>(
@@ -450,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),
),
@@ -0,0 +1,209 @@
import { expect } from "bun:test"
import { Bus } from "@/bus"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Notebook, HostError } from "@/kilocode/notebook/service"
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"
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<GlobalEvent>()
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", 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* notebook.list()).toEqual([])
const late = yield* notebook
.reply({
requestID: event.properties.id,
result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content: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_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_revision")
expect(err.message).toContain("re-read")
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<string>()
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", 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",
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([])
}),
{ 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(
"accepts both path forms, requires opaque revisions, and bounds aggregate results",
() =>
Effect.gen(function* () {
const absolute = yield* Schema.decodeUnknownEffect(ReadRequest)({
id: "nbr_test",
sessionID,
operation: "read",
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(invalid)).toContain("string")
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",
requestPath: "analysis.ipynb",
revision: "content: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 },
)
@@ -0,0 +1,155 @@
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 { ToolJsonSchema } from "@/tool/json-schema"
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,
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,
requestPath: input.path,
revision: "content:edit",
index: input.index,
action: input.edit.action,
})
return Effect.succeed({
operation: "execute" as const,
path: input.path,
requestPath: input.path,
revision: "content:execute",
index: input.index,
status: "success" as const,
outputs: [],
})
},
})
const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer))
function context(asks: Parameters<Tool.Context["ask"]>[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<Tool.Context["ask"]>[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_revision: "content:read",
index: 0,
action: "replace",
kind: "code",
language: "python",
source: "print(42)",
},
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.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_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)
}),
{ 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 before asking for permission",
() =>
Effect.gen(function* () {
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
const asks: Parameters<Tool.Context["ask"]>[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 },
)
})
test("uses dedicated VS Code notebook permission defaults only when enabled", () => {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = "vscode"
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("ask")
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
}
})
@@ -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"),
}
}
@@ -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 {
@@ -220,6 +223,20 @@ describe("kilocode tool registry indexing", () => {
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"],
)
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",
"notebook_read",
"notebook_edit",
"notebook_execute",
])
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
"recall",
"background_process",
@@ -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"),
}
}
@@ -236,6 +236,33 @@ 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",
requestPath: "notebook.ipynb",
revision: "content: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) => ({
+136
View File
@@ -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,
@@ -6964,6 +6973,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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
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<ThrowOnError extends boolean = false>(
parameters: {
requestID: NotebookRequestId
directory?: string
workspace?: string
result?: NotebookResult
},
options?: Options<never, ThrowOnError>,
) {
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<ThrowOnError extends boolean = false>(
parameters: {
requestID: NotebookRequestId
directory?: string
workspace?: string
error?: NotebookFailure
},
options?: Options<never, ThrowOnError>,
) {
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
@@ -7432,6 +7563,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 }))
+281
View File
@@ -14,6 +14,8 @@ export type Event =
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventKilocodeNotebookRequested
| EventKilocodeNotebookCancelled
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
@@ -192,6 +194,64 @@ export type EventTuiSessionSelect = {
}
}
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 = {
@@ -933,6 +993,8 @@ export type GlobalEvent = {
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventKilocodeNotebookRequested
| EventKilocodeNotebookCancelled
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
@@ -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
}
@@ -1542,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<string>
@@ -2422,6 +2488,105 @@ 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<NotebookOutput>
}
export type NotebookReadResult = {
operation: "read"
path: string
requestPath: string
/**
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
*/
revision: string
cells: Array<NotebookCell>
truncated?: boolean
}
export type NotebookEditResult = {
operation: "edit"
path: string
requestPath: string
/**
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
*/
revision: string
/**
* Zero-based cell index
*/
index: number
action: "insert" | "replace" | "delete"
cell?: NotebookCell
}
export type NotebookExecuteResult = {
operation: "execute"
path: string
requestPath: string
/**
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
*/
revision: string
/**
* Zero-based cell index
*/
index: number
status: "success" | "error"
outputs: Array<NotebookOutput>
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_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 = {
ok: boolean
id: string
@@ -3020,6 +3185,22 @@ export type EventKilocodeAgentManagerStart = {
}
}
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"
@@ -10546,6 +10727,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<NotebookRequest>
}
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
+963 -22
View File
File diff suppressed because it is too large Load Diff