mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(vscode): add native notebook tools
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code.
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user