feat(vscode): add native notebook tools

This commit is contained in:
Mark IJbema
2026-06-24 17:11:23 +02:00
parent e000cbe2c3
commit c193ee8561
30 changed files with 4209 additions and 613 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code.
+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()
},
})
@@ -70,6 +70,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
@@ -97,6 +99,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
}
@@ -136,13 +139,27 @@ export class KiloConnectionService {
* or if the connection fails.
*/
async getClientAsync(dir?: string): Promise<KiloClient> {
if (this.client && this.state === "connected") return this.client
const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
if (!root) throw new Error("No workspace folder open")
this.trackDirectory(root)
if (this.client && this.state === "connected") return this.client
await this.connect(root)
return this.getClient()
}
/** Directories that may own directory-scoped requests on the shared backend. */
getKnownDirectories(): string[] {
const dirs = new Set<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.
*/
@@ -425,6 +442,12 @@ export class KiloConnectionService {
}
}
private trackDirectory(dir: string): void {
if (!dir) return
this.rootDirectory ??= dir
this.currentDirectory = dir
}
/**
* Reject all pending permission requests and questions across every
* directory known to any currently-mounted KiloProvider.
@@ -571,6 +594,8 @@ export class KiloConnectionService {
this.favoritesChangeListeners.clear()
this.clearPendingPromptsListeners.clear()
this.directoryProviders.clear()
this.rootDirectory = undefined
this.currentDirectory = undefined
this.messageSessionIdsByMessageId.clear()
this.permissionDirectories.clear()
this.questionDirectories.clear()
@@ -0,0 +1,328 @@
import path from "node:path"
import * as vscode from "vscode"
import { normalizeOutputs, normalizeSource } from "./output"
import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
import {
NOTEBOOK_LIMITS,
type NotebookAccess,
type NotebookAdapterDeps,
type NotebookCell,
type NotebookEditRequest,
type NotebookEditResult,
type NotebookExecuteRequest,
type NotebookExecuteResult,
type NotebookExecution,
type NotebookReadRequest,
type NotebookReadResult,
} from "./types"
export interface NotebookAdapterOptions {
deps?: NotebookAdapterDeps
paths?: NotebookPathDeps
timeout?: number
}
function execution(summary: vscode.NotebookCellExecutionSummary | undefined): NotebookExecution | undefined {
if (!summary) {
return undefined
}
return {
order: summary.executionOrder,
success: summary.success,
started: summary.timing?.startTime,
ended: summary.timing?.endTime,
}
}
function changed(
base: vscode.NotebookCellExecutionSummary | undefined,
summary: vscode.NotebookCellExecutionSummary | undefined,
): boolean {
if (!summary) return false
return (
summary.executionOrder !== base?.executionOrder ||
summary.success !== base?.success ||
summary.timing?.startTime !== base?.timing?.startTime ||
summary.timing?.endTime !== base?.timing?.endTime
)
}
function defaults(): NotebookAdapterDeps {
return {
documents: () => vscode.workspace.notebookDocuments,
open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)),
apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)),
execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)),
change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener),
close: (listener) => vscode.workspace.onDidCloseNotebookDocument(listener),
uri: vscode.Uri.file,
edit: (uri, edits) => {
const edit = new vscode.WorkspaceEdit()
edit.set(uri, edits)
return edit
},
insert: (index, cells) => vscode.NotebookEdit.insertCells(index, cells),
replace: (index, cells) => vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(index, index + 1), cells),
delete: (index) => vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(index, index + 1)),
cell: (input) =>
new vscode.NotebookCellData(
input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup,
input.source,
input.language ?? (input.kind === "code" ? "plaintext" : "markdown"),
),
}
}
export class NotebookAdapter {
private readonly deps: NotebookAdapterDeps
private readonly timeout: number
constructor(
private readonly access: NotebookAccess,
private readonly options: NotebookAdapterOptions = {},
) {
this.deps = options.deps ?? defaults()
this.timeout = options.timeout ?? 120_000
}
private async document(
directory: string,
relative: string,
): Promise<{ document: vscode.NotebookDocument; path: string }> {
const target = await resolveNotebookPath(directory, relative, this.access, this.options.paths)
const open = this.deps.documents().find((document) => path.resolve(document.uri.fsPath) === path.resolve(target))
const document = open ?? (await this.deps.open(this.deps.uri(target)))
if (document.isClosed) {
throw new NotebookError("closed", "Notebook document is closed")
}
return { document, path: target }
}
async read(request: NotebookReadRequest): Promise<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 source = loaded.document.getCells()
if (source.length > 2_000) {
flags.sources = true
}
for (const [index, cell] of source.slice(0, 2_000).entries()) {
const source = normalizeSource(
cell.document.getText(),
Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)),
)
budget.sources += Math.min(source.bytes, NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)
flags.sources ||= source.truncated === true
const value: NotebookCell = {
index,
kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown",
language: cell.document.languageId.slice(0, 200),
source: source.text,
execution: execution(cell.executionSummary),
}
if (request.includeOutputs) {
const normalized = normalizeOutputs(
cell.outputs,
Math.max(0, Math.min(NOTEBOOK_LIMITS.output, NOTEBOOK_LIMITS.outputs - budget.outputs)),
)
value.outputs = normalized.outputs
budget.outputs += normalized.bytes
if (normalized.truncated) {
flags.outputs = true
}
}
cells.push(value)
}
return {
operation: "read",
path: request.path,
version: loaded.document.version,
cells,
...(flags.sources || flags.outputs ? { truncated: true } : {}),
}
}
async edit(request: NotebookEditRequest): Promise<NotebookEditResult> {
const loaded = await this.document(request.directory, request.path)
this.version(loaded.document, request.version)
const count = loaded.document.cellCount
const max = request.edit.action === "insert" ? count : count - 1
if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) {
throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`)
}
const edits = (() => {
if (request.edit.action === "delete") {
return [this.deps.delete(request.index)]
}
const cell = this.deps.cell({
kind: request.edit.kind,
language: request.edit.language,
source: request.edit.source,
})
if (request.edit.action === "insert") {
return [this.deps.insert(request.index, [cell])]
}
return [this.deps.replace(request.index, [cell])]
})()
this.version(loaded.document, request.version)
if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) {
throw new NotebookError("unsupported", "VS Code rejected the notebook edit")
}
return {
operation: "edit",
path: request.path,
version: loaded.document.version,
index: request.index,
action: request.edit.action,
}
}
async execute(request: NotebookExecuteRequest): Promise<NotebookExecuteResult> {
const loaded = await this.document(request.directory, request.path)
this.version(loaded.document, request.version)
if (!Number.isInteger(request.index) || request.index < 0 || request.index >= loaded.document.cellCount) {
throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`)
}
const cell = loaded.document.cellAt(request.index)
if (cell.kind !== vscode.NotebookCellKind.Code) {
throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`)
}
const result = this.wait(loaded.document, cell, request)
void this.deps
.execute("notebook.cell.execute", {
ranges: [{ start: request.index, end: request.index + 1 }],
document: loaded.document.uri,
})
.catch((error: unknown) => {
const detail = error instanceof Error ? error.message : String(error)
result.reject(new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`))
})
return result.promise
}
private wait(document: vscode.NotebookDocument, cell: vscode.NotebookCell, request: NotebookExecuteRequest) {
const state: {
done: boolean
timer?: ReturnType<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: request.path,
version: document.version,
index: request.index,
status: cell.executionSummary?.success === false ? "error" : "success",
outputs: normalized.outputs,
...(normalized.truncated ? { truncated: true } : {}),
})
}
const stop = () =>
void this.deps.execute("notebook.cell.cancelExecution", {
ranges: [{ start: request.index, end: request.index + 1 }],
document: document.uri,
})
const abort = () => {
stop()
reject(new NotebookError("cancelled", "Notebook execution cancellation was requested"))
}
disposables.push(
this.deps.change((event) => {
if (event.notebook !== document) {
return
}
if (document.isClosed || document.cellCount <= request.index || document.cellAt(request.index) !== cell) {
reject(new NotebookError("stale_version", "Notebook cell changed during execution"))
return
}
const change = event.cellChanges.find((item) => item.cell === cell)
if (!change) {
return
}
const summary = change.executionSummary
if (!summary || !changed(base, summary)) return
if (state.startup) clearTimeout(state.startup)
if (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve()
}),
this.deps.close((closed) => {
if (closed === document) {
reject(new NotebookError("closed", "Notebook closed during execution"))
}
}),
)
const timeout = request.timeout ?? this.timeout
state.startup = setTimeout(
() => {
stop()
reject(
new NotebookError(
"no_kernel",
"Notebook execution did not start; open the notebook in VS Code and select a kernel",
),
)
},
Math.min(timeout, 10_000),
)
state.timer = setTimeout(() => {
stop()
reject(new NotebookError("timeout", "Notebook execution timed out"))
}, timeout)
if (request.signal?.aborted) {
abort()
} else {
request.signal?.addEventListener("abort", abort, { once: true })
}
return { promise, reject }
}
private version(document: vscode.NotebookDocument, expected: number): void {
if (document.version !== expected) {
throw new NotebookError(
"stale_version",
`Notebook version changed (expected ${expected}, current ${document.version})`,
)
}
}
}
@@ -0,0 +1,302 @@
import type {
EventKilocodeNotebookCancelled,
EventKilocodeNotebookRequested,
KiloClient,
NotebookFailure,
NotebookRequest,
NotebookResult,
} from "@kilocode/sdk/v2/client"
import { FileIgnoreController } from "../autocomplete/shims/FileIgnoreController"
import type { ConnectionState, KiloConnectionService } from "../cli-backend/connection-service"
import type { SSEPayload } from "../cli-backend/sdk-sse-adapter"
import { NotebookAdapter } from "./adapter"
import { NotebookError } from "./path"
const RETAINED_REQUESTS = 1_000
const CODES = new Set<NotebookFailure["code"]>([
"cancelled",
"closed",
"disconnected",
"execution_failed",
"invalid_cell",
"invalid_path",
"no_kernel",
"not_found",
"stale_version",
"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>
}
interface NotebookConnection {
onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void
onStateChange(listener: (state: ConnectionState, error?: Error) => void): () => void
getClient(): KiloClient
getKnownDirectories(): string[]
}
interface ActiveRequest {
controller: AbortController
cancelled: boolean
}
type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure }
async function createContext(directory: string): Promise<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 message = error instanceof Error ? error.message : String(error)
if (error instanceof NotebookError && CODES.has(error.code as NotebookFailure["code"])) {
return { code: error.code as NotebookFailure["code"], message }
}
return { code: "execution_failed", message }
}
export class NotebookBridge {
private readonly contexts = new Map<string, Promise<NotebookBridgeContext>>()
private readonly active = new Map<string, ActiveRequest>()
private readonly origins = new Map<string, string>()
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 disposed = false
private revision = 0
private backend: KiloClient | undefined
constructor(
private readonly connection: NotebookConnection,
options: NotebookBridgeOptions = {},
) {
this.create = options.create ?? createContext
this.unsubscribeEvent = connection.onEvent((event, directory) => this.event(event, directory))
this.unsubscribeState = connection.onStateChange((state) => {
if (state !== "connected") return
const backend = connection.getClient()
if (this.backend && this.backend !== backend) this.reset()
this.backend = backend
const revision = ++this.revision
void this.recover(revision).catch((error: unknown) => {
console.error("[Kilo New] NotebookBridge: pending request recovery failed:", error)
})
})
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.revision += 1
this.unsubscribeEvent()
this.unsubscribeState()
for (const request of this.active.values()) {
request.cancelled = true
request.controller.abort()
}
this.active.clear()
for (const context of this.contexts.values()) {
void context
.then((value) => value.dispose())
.catch((error: unknown) => console.error("[Kilo New] NotebookBridge: context disposal failed:", error))
}
this.contexts.clear()
this.origins.clear()
this.outcomes.clear()
this.settled.clear()
}
private reset(): void {
for (const request of this.active.values()) {
request.cancelled = true
request.controller.abort()
}
this.active.clear()
this.origins.clear()
this.outcomes.clear()
this.settled.clear()
}
private event(event: SSEPayload, directory?: string): void {
if (event.type === "kilocode.notebook.requested") {
this.request(event as EventKilocodeNotebookRequested, directory)
return
}
if (event.type === "kilocode.notebook.cancelled") {
this.cancel(event as EventKilocodeNotebookCancelled, directory)
}
}
private request(event: EventKilocodeNotebookRequested, directory?: string): void {
const request = event.properties
const dir = this.origins.get(request.id) ?? directory
if (!dir || this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return
this.remember(this.origins, request.id, dir)
const active = { controller: new AbortController(), cancelled: false }
this.active.set(request.id, active)
void this.run(request, dir, active).catch((error: unknown) => {
console.error(`[Kilo New] NotebookBridge: request ${request.id} failed:`, error)
})
}
private cancel(event: EventKilocodeNotebookCancelled, directory?: string): void {
const id = event.properties.requestID
const dir = this.origins.get(id) ?? directory
if (dir) this.remember(this.origins, id, dir)
this.remember(this.settled, id)
const active = this.active.get(id)
if (!active) return
active.cancelled = true
active.controller.abort()
}
private async run(request: NotebookRequest, directory: string, active: ActiveRequest): Promise<void> {
try {
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, directory, active))
if (!outcome || this.disposed || active.cancelled) return
this.rememberOutcome(request.id, outcome)
const accepted =
"result" in outcome
? await this.reply(request.id, directory, outcome.result)
: await this.reject(request.id, directory, outcome.error)
if (accepted) this.remember(this.settled, request.id)
} finally {
if (this.active.get(request.id) === active) this.active.delete(request.id)
}
}
private async execute(
request: NotebookRequest,
directory: string,
active: ActiveRequest,
): Promise<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,
version: request.version,
index: request.index,
edit: request.edit,
})
}
return adapter.execute({
path: request.path,
directory,
version: request.version,
index: request.index,
signal,
})
}
private context(directory: string): Promise<NotebookBridgeContext> {
const existing = this.contexts.get(directory)
if (existing) return existing
const context = this.create(directory)
this.contexts.set(directory, context)
return context
}
private async reply(requestID: string, directory: string, result: NotebookResult): Promise<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 remember(map: Map<string, string>, id: string, value: string): void
private remember(set: Set<string>, id: string): void
private remember(target: Map<string, string> | Set<string>, id: string, value?: string): void {
if (target instanceof Map && value !== undefined) target.set(id, value)
if (target instanceof Set) target.add(id)
if (target.size <= RETAINED_REQUESTS) return
const oldest = target.keys().next().value
if (oldest !== undefined) target.delete(oldest)
}
}
export function createNotebookBridge(connection: KiloConnectionService): NotebookBridge {
return new NotebookBridge(connection)
}
@@ -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,91 @@
import type * as vscode from "vscode"
import { NOTEBOOK_LIMITS, type NotebookOutput, type NotebookText } from "./types"
const decoder = new TextDecoder()
const encoder = new TextEncoder()
const ERROR_MIME = "application/vnd.code.notebook.error"
const TEXT_MIMES = new Set([
"text/plain",
"text/markdown",
"application/json",
"application/vnd.code.notebook.stdout",
"application/vnd.code.notebook.stderr",
])
function slice(data: Uint8Array, limit: number): NotebookText {
const bytes = data.byteLength
const body = bytes > limit ? data.subarray(0, limit) : data
const text = decoder.decode(body)
return bytes > limit ? { text, bytes, truncated: true } : { text, bytes }
}
export function normalizeSource(source: string, limit = NOTEBOOK_LIMITS.source): NotebookText {
return slice(encoder.encode(source), limit)
}
function field(value: unknown, limit: number): string | undefined {
if (typeof value !== "string") return undefined
return value.slice(0, limit)
}
function error(item: vscode.NotebookCellOutputItem, limit: number): NotebookOutput {
const value = slice(item.data, limit)
const parsed = (() => {
try {
return JSON.parse(value.text) as unknown
} catch (err) {
void err
return undefined
}
})()
const data = typeof parsed === "object" && parsed !== null ? (parsed as Record<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,55 @@
import fs from "node:fs/promises"
import path from "node:path"
import type { NotebookAccess } from "./types"
const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[/\\]/
export class NotebookError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message)
this.name = "NotebookError"
}
}
export interface NotebookPathDeps {
realpath(path: string): Promise<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))
}
export async function resolveNotebookPath(
directory: string,
relative: string,
access: NotebookAccess,
deps: NotebookPathDeps = defaults,
): Promise<string> {
if (!relative || relative.length > 4_096 || path.isAbsolute(relative) || WINDOWS_ABSOLUTE.test(relative)) {
throw new NotebookError("invalid_path", "Notebook path must be workspace-relative")
}
const root = await deps.realpath(path.resolve(directory))
const candidate = path.resolve(root, relative)
if (!contained(root, candidate)) {
throw new NotebookError("invalid_path", "Notebook path is outside the request directory")
}
const target = await deps.realpath(candidate).catch((error: unknown) => {
const detail = error instanceof Error ? error.message : String(error)
throw new NotebookError("not_found", `Cannot resolve notebook: ${detail}`)
})
if (!contained(root, target)) {
throw new NotebookError("invalid_path", "Notebook resolves outside the request directory")
}
if (!(await access.validateAccess(target))) {
throw new NotebookError("invalid_path", "Notebook is excluded by workspace access rules")
}
return target
}
@@ -0,0 +1,122 @@
import type * as vscode from "vscode"
export const NOTEBOOK_LIMITS = {
source: 64 * 1024,
sources: 256 * 1024,
item: 16 * 1024,
output: 64 * 1024,
outputs: 256 * 1024,
} as const
export type NotebookCellKind = "code" | "markdown"
export interface NotebookText {
text: string
bytes: number
truncated?: true
}
export interface NotebookOutput {
mime: string
text?: string
name?: string
message?: string
stack?: string
omitted?: boolean
truncated?: boolean
}
export interface NotebookExecution {
order?: number
success?: boolean
started?: number
ended?: number
}
export interface NotebookCell {
index: number
kind: NotebookCellKind
language: string
source: string
execution?: NotebookExecution
outputs?: NotebookOutput[]
}
export interface NotebookReadResult {
operation: "read"
path: string
version: number
cells: NotebookCell[]
truncated?: boolean
}
export interface NotebookEditResult {
operation: "edit"
path: string
version: number
index: number
action: "insert" | "replace" | "delete"
}
export interface NotebookExecuteResult {
operation: "execute"
path: string
version: number
index: number
status: "success" | "error" | "cancelled"
outputs: NotebookOutput[]
truncated?: boolean
}
export interface NotebookCellInput {
kind: NotebookCellKind
language?: string
source: string
}
export type NotebookEdit =
| ({ action: "insert" } & NotebookCellInput)
| ({ action: "replace" } & NotebookCellInput)
| { action: "delete" }
export interface NotebookReadRequest {
path: string
directory: string
includeOutputs: boolean
}
export interface NotebookEditRequest {
path: string
directory: string
version: number
index: number
edit: NotebookEdit
}
export interface NotebookExecuteRequest {
path: string
directory: string
version: number
index: number
signal?: AbortSignal
timeout?: number
}
export interface NotebookAccess {
validateAccess(path: string): boolean | Promise<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
}
@@ -0,0 +1,233 @@
import { describe, expect, it, mock } from "bun:test"
import type { NotebookRequest } from "@kilocode/sdk/v2/client"
import * as vscode from "vscode"
import { KiloConnectionService } from "../../src/services/cli-backend/connection-service"
import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter"
import { NotebookBridge, type NotebookBridgeContext } from "../../src/services/notebook/bridge"
import { NotebookError } from "../../src/services/notebook/path"
const read: NotebookRequest = {
id: "notebook-1",
sessionID: "session-1",
operation: "read",
path: "book.ipynb",
includeOutputs: true,
}
function deferred<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 })
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", version: 2, cells: [] })),
edit: mock(async () => ({
operation: "edit" as const,
path: "book.ipynb",
version: 2,
index: 0,
action: "replace" as const,
})),
execute: mock(async () => ({
operation: "execute" as const,
path: "book.ipynb",
version: 2,
index: 0,
status: "success" as const,
outputs: [],
})),
...overrides,
}
return { value: { adapter, dispose }, adapter, dispose }
}
describe("NotebookBridge", () => {
it("deduplicates requests, retains their directory, and posts replies", async () => {
const ctx = context()
const test = harness(ctx.value)
test.request()
test.request()
await flush()
expect(test.create).toHaveBeenCalledTimes(1)
expect(test.create).toHaveBeenCalledWith("/repo")
expect(ctx.adapter.read).toHaveBeenCalledTimes(1)
expect(ctx.adapter.read).toHaveBeenCalledWith({ path: "book.ipynb", directory: "/repo", includeOutputs: true })
expect(test.replies).toEqual([
{
requestID: "notebook-1",
directory: "/repo",
result: { operation: "read", path: "book.ipynb", version: 2, cells: [] },
},
])
test.bridge.dispose()
await flush()
expect(ctx.dispose).toHaveBeenCalledTimes(1)
expect(test.handlers.event).toBeUndefined()
expect(test.handlers.state).toBeUndefined()
})
it("retries a failed reply without repeating the adapter operation", async () => {
const ctx = context()
const test = harness(ctx.value)
test.state.failReply = true
test.request()
await flush()
test.state.failReply = false
test.request()
await flush()
expect(ctx.adapter.read).toHaveBeenCalledTimes(1)
expect(test.replies).toHaveLength(2)
test.bridge.dispose()
})
it("aborts cancelled execution without posting a late completion", async () => {
const pending = deferred<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",
version: 1,
index: 0,
})
await flush()
test.cancel("execute-1")
await flush()
expect(signals[0]?.aborted).toBe(true)
expect(test.replies).toEqual([])
expect(test.rejections).toEqual([])
test.bridge.dispose()
})
it("recovers pending requests for known directories and maps adapter failures", async () => {
const ctx = context({
read: mock(async () => {
throw new NotebookError("stale_version", "Notebook changed")
}),
})
const test = harness(ctx.value, ["/root", "/worktree"])
test.lists.set("/worktree", [read])
test.handlers.state?.("connected")
await flush()
expect(test.rejections).toEqual([
{
requestID: "notebook-1",
directory: "/worktree",
error: { code: "stale_version", message: "Notebook changed" },
},
])
test.bridge.dispose()
})
})
describe("KiloConnectionService notebook directories", () => {
it("tracks the workspace root, current request directory, and provider directories", async () => {
const descriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
Object.defineProperty(vscode.workspace, "workspaceFolders", {
configurable: true,
value: [{ uri: { fsPath: "/root" } }],
})
const service = new KiloConnectionService({} as vscode.ExtensionContext)
const internals = service as unknown as { client: object; state: string }
internals.client = {}
internals.state = "connected"
const unregister = service.registerDirectoryProvider(() => ["/worktree", "/root"])
await service.getClientAsync("/current")
expect(service.getKnownDirectories()).toEqual(["/root", "/current", "/worktree"])
unregister()
expect(service.getKnownDirectories()).toEqual(["/root", "/current"])
service.dispose()
if (descriptor) Object.defineProperty(vscode.workspace, "workspaceFolders", descriptor)
})
})
@@ -0,0 +1,260 @@
import { describe, expect, it, mock } from "bun:test"
import * as vscode from "vscode"
import { NotebookAdapter } from "../../src/services/notebook/adapter"
import { normalizeOutputs, normalizeSource } from "../../src/services/notebook/output"
import { NotebookError, resolveNotebookPath } from "../../src/services/notebook/path"
import type { NotebookAdapterDeps, NotebookCellInput } from "../../src/services/notebook/types"
function uri(path: string): vscode.Uri {
return { scheme: "file", fsPath: path, path, toString: () => `file://${path}` } as vscode.Uri
}
function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code): vscode.NotebookCell {
return {
kind,
document: { getText: () => source, languageId: kind === vscode.NotebookCellKind.Code ? "python" : "markdown" },
outputs: [],
executionSummary: undefined,
} as unknown as vscode.NotebookCell
}
function notebook(cells: vscode.NotebookCell[], version = 1): vscode.NotebookDocument {
return {
uri: uri("/repo/book.ipynb"),
version,
isClosed: false,
cellCount: cells.length,
getCells: () => cells,
cellAt: (index: number) => cells[index]!,
} as unknown as vscode.NotebookDocument
}
function harness(document: vscode.NotebookDocument) {
const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>()
const closes = new Set<(document: vscode.NotebookDocument) => void>()
const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown }
const deps: NotebookAdapterDeps = {
documents: () => [document],
open: async () => {
calls.open++
return document
},
apply: async () => {
calls.apply++
Object.assign(document, { version: document.version + 1 })
return true
},
execute: async (...args) => {
calls.command++
calls.commandArgs = args
},
change: (listener) => {
changes.add(listener)
return { dispose: () => changes.delete(listener) }
},
close: (listener) => {
closes.add(listener)
return { dispose: () => closes.delete(listener) }
},
uri,
edit: (_uri, edits) => {
calls.edit = edits
return { edits } as unknown as vscode.WorkspaceEdit
},
insert: (index, cells) => ({ type: "insert", index, cells }) as unknown as vscode.NotebookEdit,
replace: (index, cells) => ({ type: "replace", index, cells }) as unknown as vscode.NotebookEdit,
delete: (index) => ({ type: "delete", index }) as unknown as vscode.NotebookEdit,
cell: (input: NotebookCellInput) => ({ input }) as unknown as vscode.NotebookCellData,
}
return { deps, changes, closes, calls }
}
const paths = { realpath: async (value: string) => value }
const access = { validateAccess: mock(() => true) }
function adapter(document: vscode.NotebookDocument, deps = harness(document)) {
return { adapter: new NotebookAdapter(access, { deps: deps.deps, paths, timeout: 50 }), ...deps }
}
describe("notebook path security", () => {
it("rejects traversal and symlink escapes before access checks", async () => {
const guard = { validateAccess: mock(() => true) }
await expect(resolveNotebookPath("/repo", "../secret.ipynb", guard, paths)).rejects.toMatchObject({
code: "invalid_path",
})
await expect(
resolveNotebookPath("/repo", "linked.ipynb", guard, {
realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value),
}),
).rejects.toMatchObject({ code: "invalid_path" })
expect(guard.validateAccess).not.toHaveBeenCalled()
})
it("enforces ignore access on the canonical target", async () => {
const guard = { validateAccess: mock(() => false) }
await expect(resolveNotebookPath("/repo", "book.ipynb", guard, paths)).rejects.toMatchObject({
code: "invalid_path",
})
expect(guard.validateAccess).toHaveBeenCalledWith("/repo/book.ipynb")
})
})
describe("notebook normalization", () => {
it("bounds UTF-8 source and text outputs and omits rich bodies", () => {
expect(normalizeSource("abcdef", 3)).toEqual({ text: "abc", bytes: 6, truncated: true })
const normalized = normalizeOutputs(
[
{
items: [
{ mime: "text/plain", data: new TextEncoder().encode("abcdef") },
{ mime: "image/png", data: new Uint8Array(40) },
],
} as vscode.NotebookCellOutput,
],
3,
)
expect(normalized).toEqual({
outputs: [
{ mime: "text/plain", text: "abc", truncated: true },
{ mime: "image/png", omitted: true },
],
truncated: true,
bytes: 3,
})
})
it("extracts and bounds standard notebook errors", () => {
const data = new TextEncoder().encode(
JSON.stringify({ name: "N".repeat(600), message: "M".repeat(11_000), stack: "trace" }),
)
const normalized = normalizeOutputs([
{ items: [{ mime: "application/vnd.code.notebook.error", data }] } as vscode.NotebookCellOutput,
])
expect(normalized.outputs[0]).toMatchObject({ stack: "trace" })
expect(normalized.outputs[0]?.name).toHaveLength(500)
expect(normalized.outputs[0]?.message).toHaveLength(10_000)
})
})
describe("notebook adapter", () => {
it("prefers a dirty open document and reads normalized cells", async () => {
const document = notebook([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)], 7)
const ctx = adapter(document)
const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb" })
expect(ctx.calls.open).toBe(0)
expect(result).toMatchObject({
path: "book.ipynb",
version: 7,
cells: [
{ index: 0, kind: "code", language: "python", source: "unsaved" },
{ index: 1, kind: "markdown", language: "markdown", source: "# title" },
],
})
})
it("opens notebooks in the background when not already open", async () => {
const document = notebook([cell()])
const ctx = harness(document)
ctx.deps.documents = () => []
const core = new NotebookAdapter(access, { deps: ctx.deps, paths })
await core.read({ directory: "/repo", path: "book.ipynb" })
expect(ctx.calls.open).toBe(1)
})
it("constructs insert, replace, and delete edits and rejects stale versions", async () => {
const document = notebook([cell()], 3)
const ctx = adapter(document)
await expect(
ctx.adapter.edit({
directory: "/repo",
path: "book.ipynb",
index: 0,
version: 2,
edit: { action: "replace", kind: "code", language: "python", source: "next" },
}),
).rejects.toMatchObject({ code: "stale_version" })
await ctx.adapter.edit({
directory: "/repo",
path: "book.ipynb",
index: 0,
version: 3,
edit: { action: "replace", kind: "code", language: "python", source: "next" },
})
expect(ctx.calls.edit).toEqual([
{ type: "replace", index: 0, cells: [{ input: { kind: "code", language: "python", source: "next" } }] },
])
})
it("correlates a newly completed execution and cleans up listeners", async () => {
const target = cell()
const document = notebook([target], 4)
const ctx = adapter(document)
ctx.deps.execute = async (...args) => {
ctx.calls.command++
ctx.calls.commandArgs = args
Object.assign(target, {
outputs: [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] }],
executionSummary: { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } },
})
for (const listener of ctx.changes) {
listener({
notebook: document,
contentChanges: [],
cellChanges: [{ cell: target, executionSummary: target.executionSummary }],
} as unknown as vscode.NotebookDocumentChangeEvent)
}
}
const result = await ctx.adapter.execute({
directory: "/repo",
path: "book.ipynb",
index: 0,
version: 4,
})
expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] })
expect(ctx.calls.commandArgs).toEqual([
"notebook.cell.execute",
{ ranges: [{ start: 0, end: 1 }], document: document.uri },
])
expect(ctx.changes.size).toBe(0)
expect(ctx.closes.size).toBe(0)
})
it("fails closed when execution never starts", async () => {
const document = notebook([cell()])
const ctx = adapter(document)
await expect(
ctx.adapter.execute({
directory: "/repo",
path: "book.ipynb",
index: 0,
version: 1,
timeout: 5,
}),
).rejects.toMatchObject({ code: "no_kernel" })
expect(ctx.calls.commandArgs).toEqual([
"notebook.cell.cancelExecution",
{ ranges: [{ start: 0, end: 1 }], document: document.uri },
])
expect(ctx.changes.size).toBe(0)
expect(ctx.closes.size).toBe(0)
})
it("cancels execution and disposes listeners", async () => {
const document = notebook([cell()])
const ctx = adapter(document)
const controller = new AbortController()
const pending = ctx.adapter.execute({
directory: "/repo",
path: "book.ipynb",
index: 0,
version: 1,
signal: controller.signal,
})
controller.abort()
await expect(pending).rejects.toEqual(
new NotebookError("cancelled", "Notebook execution cancellation was requested"),
)
expect(ctx.changes.size).toBe(0)
expect(ctx.closes.size).toBe(0)
})
})
@@ -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"
? { notebook_read: "allow" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const }
: {}),
})
return { mcpRules, defaultsPatch }
}
@@ -0,0 +1,166 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema } from "effect"
export const RequestID = Schema.String.pipe(Schema.brand("NotebookRequestID")).annotate({
identifier: "NotebookRequestID",
})
export type RequestID = Schema.Schema.Type<typeof RequestID>
export const Path = Schema.String.check(
Schema.isMinLength(1),
Schema.isMaxLength(4096),
Schema.makeFilter((value: string) => {
const parts = value.replaceAll("\\", "/").split("/")
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || parts.includes("..")
? "Notebook path must be workspace-relative and contained in the workspace"
: undefined
}),
).annotate({ description: "Workspace-relative notebook path" })
const Source = Schema.String.check(Schema.isMaxLength(200_000))
const Text = Schema.String.check(Schema.isMaxLength(100_000))
const Version = NonNegativeInt.annotate({ description: "Expected VS Code notebook document version" })
const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" })
export const Output = Schema.Struct({
mime: Schema.String.check(Schema.isMaxLength(200)),
text: Schema.optional(Text),
name: Schema.optional(Schema.String.check(Schema.isMaxLength(500))),
message: Schema.optional(Schema.String.check(Schema.isMaxLength(10_000))),
stack: Schema.optional(Schema.String.check(Schema.isMaxLength(50_000))),
omitted: Schema.optional(Schema.Boolean),
truncated: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "NotebookOutput" })
export type Output = Schema.Schema.Type<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"),
version: Version,
index: Index,
edit: Schema.Union([
Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }),
Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }),
Schema.Struct({ action: Schema.Literal("delete") }),
]),
}).annotate({ identifier: "NotebookEditRequest" })
export const ExecuteRequest = Schema.Struct({
...Base,
operation: Schema.Literal("execute"),
version: Version,
index: Index,
}).annotate({ identifier: "NotebookExecuteRequest" })
export const Request = Schema.Union([ReadRequest, EditRequest, ExecuteRequest]).annotate({
identifier: "NotebookRequest",
})
export type Request = Schema.Schema.Type<typeof Request>
export const ReadResult = Schema.Struct({
operation: Schema.Literal("read"),
path: Path,
version: Version,
cells: Schema.Array(Cell).check(Schema.isMaxLength(2_000)),
truncated: Schema.optional(Schema.Boolean),
})
.check(
Schema.makeFilter((value) =>
JSON.stringify(value).length <= 2_000_000 ? undefined : "Notebook read result exceeds the aggregate output limit",
),
)
.annotate({ identifier: "NotebookReadResult" })
export const EditResult = Schema.Struct({
operation: Schema.Literal("edit"),
path: Path,
version: Version,
index: Index,
action: Schema.Literals(["insert", "replace", "delete"]),
}).annotate({ identifier: "NotebookEditResult" })
export const ExecuteResult = Schema.Struct({
operation: Schema.Literal("execute"),
path: Path,
version: Version,
index: Index,
status: Schema.Literals(["success", "error", "cancelled"]),
outputs: Schema.Array(Output).check(Schema.isMaxLength(100)),
truncated: Schema.optional(Schema.Boolean),
})
.check(
Schema.makeFilter((value) =>
JSON.stringify(value).length <= 2_000_000
? undefined
: "Notebook execution result exceeds the aggregate output limit",
),
)
.annotate({ identifier: "NotebookExecuteResult" })
export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).annotate({ identifier: "NotebookResult" })
export type Result = Schema.Schema.Type<typeof Result>
export const ErrorCode = Schema.Literals([
"cancelled",
"closed",
"disconnected",
"execution_failed",
"invalid_cell",
"invalid_path",
"no_kernel",
"not_found",
"stale_version",
"timeout",
"unsupported",
])
export type ErrorCode = Schema.Schema.Type<typeof ErrorCode>
export const Failure = Schema.Struct({
code: ErrorCode,
message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10_000)),
}).annotate({ identifier: "NotebookFailure" })
export type Failure = Schema.Schema.Type<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,150 @@
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { Identifier } from "@/id/id"
import { Deferred, Duration, Effect, Layer, Schema, Context } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol"
const log = Log.create({ service: "notebook-host" })
type WithoutID<T> = 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,
}) {
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.path) return false
if (request.operation === "read") return result.operation === "read"
if (request.operation === "execute") return result.operation === "execute" && request.index === result.index
return result.operation === "edit" && request.index === result.index && request.edit.action === result.action
}
export interface Interface {
readonly request: (input: Input) => Effect.Effect<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 }))
})
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,189 @@
import { Notebook, HostError } from "@/kilocode/notebook/service"
import { Path, type Result } from "@/kilocode/notebook/protocol"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import * as Tool from "@/tool/tool"
import { Effect, Schema } from "effect"
const Source = Schema.String.check(Schema.isMaxLength(200_000))
const Version = NonNegativeInt.annotate({ description: "Notebook version returned by notebook_read" })
const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" })
const LIMIT = 20_000
function render(value: unknown) {
const text = JSON.stringify(value, null, 2)
if (text.length <= LIMIT) return text
return `${text.slice(0, LIMIT)}
... notebook result truncated by CLI (${text.length - LIMIT} characters omitted)`
}
function abort(signal: AbortSignal) {
return Effect.callback<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; version: number },
Notebook.Service,
"notebook_read"
>(
"notebook_read",
Effect.gen(function* () {
const notebook = yield* Notebook.Service
return {
description:
"Read the live, possibly unsaved structure and source of one VS Code notebook. Outputs are omitted unless include_outputs is true.",
parameters: ReadParams,
execute: (params, ctx) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "notebook_read",
patterns: [params.path],
always: [params.path],
metadata: { path: params.path, includeOutputs: params.include_outputs === true },
})
const result = yield* run(
notebook.request({
operation: "read",
sessionID: ctx.sessionID,
path: params.path,
includeOutputs: params.include_outputs === true,
}),
ctx.abort,
)
if (result.operation !== "read")
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
return {
title: `Notebook: ${params.path}`,
output: render(result),
metadata: { path: result.path, version: result.version },
}
}),
}
}),
)
const Cell = {
kind: Schema.Literals(["code", "markdown"]),
language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))),
source: Source,
}
const EditParams = Schema.Union([
Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("insert"), ...Cell }),
Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("replace"), ...Cell }),
Schema.Struct({ path: Path, expected_version: Version, index: Index, action: Schema.Literal("delete") }),
])
export const NotebookEditTool = Tool.define<
typeof EditParams,
{ path: string; version: number; index: number },
Notebook.Service,
"notebook_edit"
>(
"notebook_edit",
Effect.gen(function* () {
const notebook = yield* Notebook.Service
return {
description:
"Insert, replace, or delete one cell in a live VS Code notebook. Requires the exact notebook version from notebook_read and leaves the document dirty.",
parameters: EditParams,
execute: (params, ctx) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "notebook_edit",
patterns: [params.path],
always: [params.path],
metadata: {
path: params.path,
action: params.action,
index: params.index,
version: params.expected_version,
},
})
const edit =
params.action === "delete"
? ({ action: params.action } as const)
: { action: params.action, kind: params.kind, language: params.language, source: params.source }
const result = yield* run(
notebook.request({
operation: "edit",
sessionID: ctx.sessionID,
path: params.path,
version: params.expected_version,
index: params.index,
edit,
}),
ctx.abort,
)
if (result.operation !== "edit")
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
return {
title: `${result.action} notebook cell ${result.index}`,
output: render(result),
metadata: { path: result.path, version: result.version, index: result.index },
}
}),
}
}),
)
const ExecuteParams = Schema.Struct({ path: Path, expected_version: Version, index: Index })
export const NotebookExecuteTool = Tool.define<
typeof ExecuteParams,
{ path: string; version: number; index: number },
Notebook.Service,
"notebook_execute"
>(
"notebook_execute",
Effect.gen(function* () {
const notebook = yield* Notebook.Service
return {
description:
"Execute one explicit code cell in a live VS Code notebook without revealing the notebook or opening a kernel picker. Requires the exact notebook version from notebook_read.",
parameters: ExecuteParams,
execute: (params, ctx) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "notebook_execute",
patterns: [params.path],
always: [params.path],
metadata: { path: params.path, index: params.index, version: params.expected_version },
})
const result = yield* run(
notebook.request({
operation: "execute",
sessionID: ctx.sessionID,
path: params.path,
version: params.expected_version,
index: params.index,
}),
ctx.abort,
)
if (result.operation !== "execute")
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
return {
title: `Executed notebook cell ${result.index}`,
output: render(result),
metadata: { path: result.path, version: result.version, index: result.index },
}
}),
}
}),
)
@@ -3,6 +3,7 @@ import { CodebaseSearchTool } from "../../tool/warpgrep"
import { RecallTool } from "../../tool/recall"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
import * as Tool from "../../tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
@@ -37,14 +38,25 @@ export namespace KiloToolRegistry {
const recall = yield* RecallTool
const manager = yield* AgentManagerTool
const process = yield* BackgroundProcessTool
return { codebase, recall, manager, process }
const notebookRead = yield* NotebookReadTool
const notebookEdit = yield* NotebookEditTool
const notebookExecute = yield* NotebookExecuteTool
return { codebase, recall, manager, process, notebookRead, notebookEdit, notebookExecute }
})
}
/** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect —
* it has no Service deps beyond what Tool.init itself needs. */
export function build(
tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info },
tools: {
codebase: Tool.Info
recall: Tool.Info
manager: Tool.Info
process: Tool.Info
notebookRead: Tool.Info
notebookEdit: Tool.Info
notebookExecute: Tool.Info
},
deps: Deps,
loaders: Loaders = {},
) {
@@ -54,6 +66,9 @@ export namespace KiloToolRegistry {
recall: Tool.init(tools.recall),
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
notebookRead: Tool.init(tools.notebookRead),
notebookEdit: Tool.init(tools.notebookEdit),
notebookExecute: Tool.init(tools.notebookExecute),
})
const semantic = yield* semanticTool(deps, loaders)
return { ...base, semantic }
@@ -99,7 +114,16 @@ export namespace KiloToolRegistry {
/** Kilo-specific tools to append to the builtin list */
export function extra(
tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def },
tools: {
codebase: Tool.Def
semantic?: Tool.Def
recall: Tool.Def
manager: Tool.Def
process: Tool.Def
notebookRead: Tool.Def
notebookEdit: Tool.Def
notebookExecute: Tool.Def
},
cfg: { experimental?: { codebase_search?: boolean } },
): Tool.Def[] {
return [
@@ -108,7 +132,9 @@ export namespace KiloToolRegistry {
tools.recall,
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
// The extension is the only client that can consume the Agent Manager start event.
...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []),
...(Flag.KILO_CLIENT === "vscode"
? [tools.manager, tools.notebookRead, tools.notebookEdit, tools.notebookExecute]
: []),
]
}
@@ -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,
+2 -1
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
@@ -150,7 +151,7 @@ export const layer: Layer.Layer<
const agent = yield* Agent.Service
// kilocode_change start
const suggesttool = yield* SuggestTool
const kiloToolInfos = yield* KiloToolRegistry.infos()
const kiloToolInfos = yield* KiloToolRegistry.infos().pipe(Effect.provide(Notebook.defaultLayer))
// kilocode_change end
const state = yield* InstanceState.make<State>(
@@ -0,0 +1,176 @@
import { expect } from "bun:test"
import { Bus } from "@/bus"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Notebook, HostError } from "@/kilocode/notebook/service"
import { Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol"
import { SessionID } from "@/session/schema"
import { Effect, Fiber, Layer, Queue, Schema } from "effect"
import { TestInstance } from "../fixture/fixture"
import { disposeInstance } from "@/effect/instance-registry"
import { testEffect } from "../lib/effect"
const it = testEffect(Notebook.layer("20 millis").pipe(Layer.provideMerge(Bus.layer)))
const sessionID = SessionID.make("ses_notebook_test")
function request(notebook: Notebook.Interface) {
return notebook.request({ operation: "read", sessionID, path: "analysis.ipynb", includeOutputs: false })
}
it.instance(
"publishes, lists, and completes a correlated request",
() =>
Effect.gen(function* () {
const notebook = yield* Notebook.Service
const bus = yield* Bus.Service
const instance = yield* TestInstance
const events = yield* Queue.unbounded<{ properties: Request }>()
const global = yield* Queue.unbounded<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", version: 3, cells: [] },
})
expect(yield* Fiber.join(fiber)).toEqual({ operation: "read", path: "analysis.ipynb", version: 3, cells: [] })
expect(yield* notebook.list()).toEqual([])
const late = yield* notebook
.reply({
requestID: event.properties.id,
result: { operation: "read", path: "analysis.ipynb", version: 3, cells: [] },
})
.pipe(Effect.flip)
expect(late._tag).toBe("Notebook.NotFoundError")
}),
{ git: true },
)
it.instance(
"propagates structured host rejection and removes pending state",
() =>
Effect.gen(function* () {
const notebook = yield* Notebook.Service
const fiber = yield* request(notebook).pipe(Effect.forkChild)
const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 }))
yield* notebook.reject({
requestID: pending[0].id,
error: { code: "stale_version", message: "Expected version 4 but found 5" },
})
const err = yield* Fiber.join(fiber).pipe(Effect.flip)
expect(err).toBeInstanceOf(HostError)
expect(err.code).toBe("stale_version")
expect(err.message).toContain("version 4")
expect(yield* notebook.list()).toEqual([])
}),
{ git: true },
)
it.instance(
"cancels interrupted requests and rejects operation-mismatched replies",
() =>
Effect.gen(function* () {
const notebook = yield* Notebook.Service
const bus = yield* Bus.Service
const cancelled = yield* Queue.unbounded<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", version: 2, index: 0, action: "delete" },
})
.pipe(Effect.flip)
expect(mismatch._tag).toBe("Notebook.InvalidReplyError")
const wrongPath = yield* notebook
.reply({
requestID: pending[0].id,
result: { operation: "read", path: "other.ipynb", version: 2, cells: [] },
})
.pipe(Effect.flip)
expect(wrongPath._tag).toBe("Notebook.InvalidReplyError")
yield* Fiber.interrupt(fiber)
expect(yield* Queue.take(cancelled).pipe(Effect.timeout("2 seconds"))).toBe("cancelled")
expect(yield* notebook.list()).toEqual([])
}),
{ git: true },
)
it.instance(
"times out pending requests",
() =>
Effect.gen(function* () {
const notebook = yield* Notebook.Service
const err = yield* request(notebook).pipe(Effect.flip)
expect(err.code).toBe("timeout")
expect(yield* notebook.list()).toEqual([])
}),
{ git: true },
)
it.instance(
"rejects escaping paths and oversized aggregate results",
() =>
Effect.gen(function* () {
const path = yield* Schema.decodeUnknownEffect(ReadRequest)({
id: "nbr_test",
sessionID,
operation: "read",
path: "../outside.ipynb",
includeOutputs: false,
}).pipe(Effect.flip)
expect(String(path)).toContain("workspace-relative")
const cells = Array.from({ length: 11 }, (_, index) => ({
index,
kind: "code" as const,
language: "python",
source: "x".repeat(200_000),
}))
const output = yield* Schema.decodeUnknownEffect(ReadResult)({
operation: "read",
path: "analysis.ipynb",
version: 1,
cells,
}).pipe(Effect.flip)
expect(String(output)).toContain("aggregate output limit")
}),
{ git: true },
)
it.instance(
"fails pending requests when the instance is disposed",
() =>
Effect.gen(function* () {
const notebook = yield* Notebook.Service
const instance = yield* TestInstance
const fiber = yield* request(notebook).pipe(Effect.forkChild)
yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 }))
yield* Effect.promise(() => disposeInstance(instance.directory))
const err = yield* Fiber.join(fiber).pipe(Effect.flip)
expect(err.code).toBe("disconnected")
}),
{ git: true },
)
@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@/agent/agent"
import { Notebook } from "@/kilocode/notebook/service"
import * as KiloAgent from "@/kilocode/agent"
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "@/kilocode/tool/notebook-host"
import { MessageID, SessionID } from "@/session/schema"
import * as Tool from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
const calls: Notebook.Input[] = []
const notebook = Layer.mock(Notebook.Service, {
request: (input) => {
calls.push(input)
if (input.operation === "read")
return Effect.succeed({
operation: "read" as const,
path: input.path,
version: 7,
cells: [{ index: 0, kind: "code" as const, language: "python", source: "x".repeat(200_000) }],
})
if (input.operation === "edit")
return Effect.succeed({
operation: "edit" as const,
path: input.path,
version: input.version + 1,
index: input.index,
action: input.edit.action,
})
return Effect.succeed({
operation: "execute" as const,
path: input.path,
version: input.version,
index: input.index,
status: "success" as const,
outputs: [],
})
},
})
const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer))
function context(asks: Parameters<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_version: 7,
index: 0,
action: "replace",
kind: "code",
language: "python",
source: "print(42)",
},
ctx,
)
const executeResult = yield* execute.execute({ path: "analysis.ipynb", expected_version: 8, index: 0 }, ctx)
expect(asks.map((item) => item.permission)).toEqual(["notebook_read", "notebook_edit", "notebook_execute"])
expect(asks.every((item) => item.patterns[0] === "analysis.ipynb")).toBe(true)
expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"])
expect(readResult.output.length).toBeLessThanOrEqual(20_100)
expect(readResult.output).toContain("notebook result truncated")
expect(editResult.metadata.version).toBe(8)
expect(executeResult.metadata.index).toBe(0)
}),
{ git: true },
)
})
test("uses dedicated VS Code notebook permission defaults", () => {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = "vscode"
const rules = KiloAgent.prepare({}).defaultsPatch
expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("allow")
expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask")
expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask")
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
if (prev !== undefined) process.env.KILO_CLIENT = prev
}
})
@@ -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 {
@@ -218,12 +221,24 @@ describe("kilocode tool registry indexing", () => {
process.env["KILO_CLIENT"] = "vscode"
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"],
[
"codebase_search",
"semantic_search",
"recall",
"background_process",
"agent_manager",
"notebook_read",
"notebook_edit",
"notebook_execute",
],
)
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
"recall",
"background_process",
"agent_manager",
"notebook_read",
"notebook_edit",
"notebook_execute",
])
process.env["KILO_CLIENT"] = "desktop"
@@ -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"),
}
}
+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,
@@ -6962,6 +6971,128 @@ export class Heap extends HeyApiClient {
}
}
export class Notebook extends HeyApiClient {
/**
* List pending notebook requests
*
* List pending native notebook requests for the routed workspace.
*/
public list<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
@@ -7430,6 +7561,11 @@ export class Kilocode extends HeyApiClient {
return (this._heap ??= new Heap({ client: this.client }))
}
private _notebook?: Notebook
get notebook(): Notebook {
return (this._notebook ??= new Notebook({ client: this.client }))
}
private _sessionImport?: SessionImport
get sessionImport(): SessionImport {
return (this._sessionImport ??= new SessionImport({ client: this.client }))
+416 -149
View File
@@ -5,18 +5,10 @@ export type ClientOptions = {
}
export type Event =
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -24,6 +16,10 @@ export type Event =
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -37,6 +33,7 @@ export type Event =
| EventBackgroundProcessDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSandboxStatusChanged
| EventSessionDiff
| EventSessionError
| EventTodoUpdated
@@ -50,6 +47,9 @@ export type Event =
| EventCommandExecuted
| EventProjectUpdated
| EventSessionCompacted
| EventKilocodeAgentManagerStart
| EventKilocodeNotebookRequested
| EventKilocodeNotebookCancelled
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -94,9 +94,11 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventIndexingStatus
| EventIndexingWarning
| EventModelsDevRefreshed
| EventPluginAdded
| EventCatalogModelUpdated
| EventModelsDevRefreshed
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
@@ -137,76 +139,6 @@ export type InvalidRequestError = {
field?: string
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type IndexingWarning = {
code: "qdrant.version-incompatible" | "qdrant.version-unavailable"
message: string
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -269,6 +201,61 @@ export type QuestionRejected = {
requestID: string
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type SessionNetworkWait = {
id: string
sessionID: string
@@ -473,6 +460,64 @@ export type Project = {
sandboxes: Array<string>
}
export type NotebookRequestId = string
export type NotebookReadRequest = {
id: NotebookRequestId
sessionID: string
path: string
operation: "read"
includeOutputs: boolean
}
export type NotebookEditRequest = {
id: NotebookRequestId
sessionID: string
path: string
operation: "edit"
/**
* Expected VS Code notebook document version
*/
version: number
/**
* Zero-based cell index
*/
index: number
edit:
| {
action: "insert"
kind: "code" | "markdown"
language?: string
source: string
}
| {
action: "replace"
kind: "code" | "markdown"
language?: string
source: string
}
| {
action: "delete"
}
}
export type NotebookExecuteRequest = {
id: NotebookRequestId
sessionID: string
path: string
operation: "execute"
/**
* Expected VS Code notebook document version
*/
version: number
/**
* Zero-based cell index
*/
index: number
}
export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest
export type Pty = {
id: string
title: string
@@ -919,23 +964,30 @@ export type Prompt = {
references?: Array<PromptReferenceAttachment>
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type IndexingWarning = {
code: "qdrant.version-incompatible" | "qdrant.version-unavailable"
message: string
}
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload:
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -943,6 +995,10 @@ export type GlobalEvent = {
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -956,6 +1012,7 @@ export type GlobalEvent = {
| EventBackgroundProcessDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSandboxStatusChanged
| EventSessionDiff
| EventSessionError
| EventTodoUpdated
@@ -969,6 +1026,9 @@ export type GlobalEvent = {
| EventCommandExecuted
| EventProjectUpdated
| EventSessionCompacted
| EventKilocodeAgentManagerStart
| EventKilocodeNotebookRequested
| EventKilocodeNotebookCancelled
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -1013,9 +1073,11 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventIndexingStatus
| EventIndexingWarning
| EventModelsDevRefreshed
| EventPluginAdded
| EventCatalogModelUpdated
| EventModelsDevRefreshed
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
@@ -1183,6 +1245,9 @@ export type PermissionConfig =
doom_loop?: PermissionActionConfig
skill?: PermissionRuleConfig
agent_manager?: PermissionRuleConfig
notebook_read?: PermissionRuleConfig
notebook_edit?: PermissionRuleConfig
notebook_execute?: PermissionRuleConfig
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
}
@@ -2422,6 +2487,92 @@ export type EffectHttpApiErrorServiceUnavailable = {
_tag: "ServiceUnavailable"
}
export type NotebookOutput = {
mime: string
text?: string
name?: string
message?: string
stack?: string
omitted?: boolean
truncated?: boolean
}
export type NotebookCell = {
/**
* Zero-based cell index
*/
index: number
kind: "code" | "markdown"
language: string
source: string
execution?: {
order?: number
success?: boolean
started?: number
ended?: number
}
outputs?: Array<NotebookOutput>
}
export type NotebookReadResult = {
operation: "read"
path: string
/**
* Expected VS Code notebook document version
*/
version: number
cells: Array<NotebookCell>
truncated?: boolean
}
export type NotebookEditResult = {
operation: "edit"
path: string
/**
* Expected VS Code notebook document version
*/
version: number
/**
* Zero-based cell index
*/
index: number
action: "insert" | "replace" | "delete"
}
export type NotebookExecuteResult = {
operation: "execute"
path: string
/**
* Expected VS Code notebook document version
*/
version: number
/**
* Zero-based cell index
*/
index: number
status: "success" | "error" | "cancelled"
outputs: Array<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_version"
| "timeout"
| "unsupported"
message: string
}
export type KilocodeSessionImportResult = {
ok: boolean
id: string
@@ -2967,6 +3118,14 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
@@ -2991,57 +3150,6 @@ export type EventGlobalConfigUpdated = {
}
}
export type EventSandboxStatusChanged = {
id: string
type: "sandbox.status.changed"
properties: {
sessionID: string
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type EventIndexingWarning = {
id: string
type: "indexing.warning"
properties: IndexingWarning
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventFileEdited = {
id: string
type: "file.edited"
@@ -3208,6 +3316,19 @@ export type EventSessionTurnClose = {
}
}
export type EventSandboxStatusChanged = {
id: string
type: "sandbox.status.changed"
properties: {
sessionID: string
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
}
export type EventSessionDiff = {
id: string
type: "session.diff"
@@ -3336,6 +3457,38 @@ export type EventSessionCompacted = {
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventKilocodeNotebookRequested = {
id: string
type: "kilocode.notebook.requested"
properties: NotebookRequest
}
export type EventKilocodeNotebookCancelled = {
id: string
type: "kilocode.notebook.cancelled"
properties: {
requestID: NotebookRequestId
sessionID: string
reason: "cancelled" | "disposed" | "timeout"
}
}
export type EventVcsBranchUpdated = {
id: string
type: "vcs.branch.updated"
@@ -3874,6 +4027,28 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type EventIndexingWarning = {
id: string
type: "indexing.warning"
properties: IndexingWarning
}
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
properties: {
[key: string]: unknown
}
}
export type EventPluginAdded = {
id: string
type: "plugin.added"
@@ -3988,14 +4163,6 @@ export type EventCatalogModelUpdated = {
}
}
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
properties: {
[key: string]: unknown
}
}
export type AccountV2oAuthCredential = {
type: "oauth"
refresh: string
@@ -10546,6 +10713,106 @@ export type KilocodeRemoveAgentResponses = {
export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses]
export type KilocodeNotebookListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilocode/notebook"
}
export type KilocodeNotebookListErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type KilocodeNotebookListError = KilocodeNotebookListErrors[keyof KilocodeNotebookListErrors]
export type KilocodeNotebookListResponses = {
/**
* Pending notebook host requests
*/
200: Array<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
+1289 -455
View File
File diff suppressed because it is too large Load Diff