fix(cli): limit indexing safeguards to home and root

This commit is contained in:
marius-kilocode
2026-08-28 10:36:50 +02:00
parent 02a2f723bd
commit 08467dba44
23 changed files with 247 additions and 695 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/cli": patch
---
Use on-demand file search and disable background filesystem watchers by default while preserving explicit codebase indexing.
Prevent automatic indexing of home directories and filesystem roots, and show a warning to open a project folder instead.
+40 -25
View File
@@ -13,7 +13,7 @@ import { RelativePath } from "../schema"
import { Flag } from "../flag/flag"
// kilocode_change start
import * as SearchTarget from "../kilocode/search-target"
import { allowed } from "../kilocode/fff"
import { allowed, message } from "../kilocode/fff"
// kilocode_change end
export interface Interface {
@@ -30,6 +30,7 @@ export const ripgrepLayer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
// kilocode_change start - confine every search to the canonical active Location.
const inspect = Effect.fnUntraced(function* (input?: string) {
const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie)
@@ -39,15 +40,38 @@ export const ripgrepLayer = Layer.effect(
const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie)
if (root.type !== "directory" || !FSUtil.contains(root.path, target.path))
return yield* Effect.die(new Error("Path escapes the location"))
return { root, target }
return target
})
const list = yield* SearchTarget.listing(fs, ripgrep, location.vcs ? Number.MAX_SAFE_INTEGER : 100_000)
// kilocode_change end
const state = {
files: [] as string[],
directories: [] as string[],
}
const directories = new Set<string>()
// kilocode_change start - never eagerly enumerate a filesystem root.
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (real && allowed(real)) {
yield* ripgrep
.find({
cwd: real,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
}
// kilocode_change end
return Service.of({
glob: (input) =>
Effect.gen(function* () {
// kilocode_change start
const { root, target } = yield* inspect(input.path)
const target = yield* inspect(input.path)
const cwd = target.type === "file" ? path.dirname(target.path) : target.path
// kilocode_change end
return yield* ripgrep
@@ -64,7 +88,7 @@ export const ripgrepLayer = Layer.effect(
(entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(root.path, path.resolve(cwd, entry.path))), // kilocode_change
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
@@ -74,7 +98,7 @@ export const ripgrepLayer = Layer.effect(
grep: (input) =>
Effect.gen(function* () {
// kilocode_change start
const { root, target } = yield* inspect(input.path)
const target = yield* inspect(input.path)
const cwd = target.type === "file" ? path.dirname(target.path) : target.path
// kilocode_change end
return yield* ripgrep
@@ -95,7 +119,7 @@ export const ripgrepLayer = Layer.effect(
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(root.path, path.resolve(cwd, match.entry.path))), // kilocode_change
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
@@ -105,22 +129,13 @@ export const ripgrepLayer = Layer.effect(
}),
find: (input) =>
Effect.gen(function* () {
// kilocode_change start
const { target } = yield* inspect()
if (!allowed(target.path)) return []
const found = yield* list(target)
const files = found.map((entry) => entry.path)
const directories = new Set<string>()
if (input.type !== "file") {
for (const file of files) {
const parts = file.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
}
}
const items =
input.type === "file" ? files : input.type === "directory" ? [...directories] : [...files, ...directories]
return fuzzysort.go(input.query.trim(), items, { all: true, limit: input.limit ?? 50 }).map((item) => {
// kilocode_change end
input.type === "file"
? state.files
: input.type === "directory"
? state.directories
: [...state.files, ...state.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
@@ -128,7 +143,7 @@ export const ripgrepLayer = Layer.effect(
type,
})
})
}).pipe(Effect.scoped), // kilocode_change
}),
})
}),
)
@@ -167,7 +182,7 @@ export const fffLayer = Layer.effect(
const make = Effect.uninterruptible(
Effect.gen(function* () {
const real = yield* fs.realPath(location.directory).pipe(Effect.orDie)
if (!allowed(real)) return yield* Effect.die(new Error("FFF indexing is disabled for filesystem roots."))
if (!allowed(real)) return yield* Effect.die(new Error(message))
const result = yield* Effect.try({
try: () =>
Fff.create({
@@ -327,7 +342,7 @@ const layer = Layer.unwrap(
const location = yield* Location.Service
const fs = yield* FSUtil.Service
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
return real !== undefined && allowed(real) ? fffLayer : ripgrepLayer
return real && allowed(real) ? fffLayer : ripgrepLayer
}),
)
// kilocode_change end
+4 -1
View File
@@ -16,6 +16,7 @@ import { Location } from "../location"
import { lazy } from "../util/lazy"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
import { allowed } from "../kilocode/fff" // kilocode_change
declare const KILO_LIBC: string | undefined
@@ -106,11 +107,13 @@ const layer = Layer.effect(
const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs && (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER)) {
// kilocode_change start
if (location.vcs && (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) && allowed(location.directory)) {
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
}
// kilocode_change end
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
+2 -2
View File
@@ -74,7 +74,7 @@ export const Flag = {
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), // kilocode_change
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
Config.withDefault(true), // kilocode_change
Config.withDefault(false),
),
KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), // kilocode_change
@@ -126,7 +126,7 @@ export const Flag = {
KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change
KILO_DISABLE_FFF: fff === undefined ? true : truthy("KILO_DISABLE_FFF"), // kilocode_change
KILO_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("KILO_DISABLE_FFF"), // kilocode_change
get KILO_DISABLE_PROJECT_CONFIG() {
return truthy("KILO_DISABLE_PROJECT_CONFIG")
+23 -2
View File
@@ -1,13 +1,34 @@
import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
export const message =
"Automatic indexing is disabled in home and filesystem root directories. Open a project folder to enable indexing. File tools remain available."
function root(directory: string, api: typeof path.posix) {
if (!api.isAbsolute(directory)) return false
return api.normalize(directory) === api.normalize(api.parse(directory).root)
}
export function allowed(directory: string) {
function real(directory: string) {
try {
return realpathSync.native(directory)
} catch {
return path.resolve(directory)
}
}
export function allowed(directory: string, home = (process.env.KILO_TEST_HOME ?? os.homedir()).trim()) {
const value = path.win32.normalize(directory)
const prefix = "\\\\?\\UNC\\"
const windows = value.toUpperCase().startsWith(prefix.toUpperCase()) ? `\\\\${value.slice(prefix.length)}` : value
return !root(directory, path.posix) && !root(windows, path.win32)
if (root(directory, path.posix) || root(windows, path.win32)) return false
const resolved = real(directory)
if (root(resolved, path)) return false
const base = real(home)
return process.platform === "win32" ? resolved.toLowerCase() !== base.toLowerCase() : resolved !== base
}
export function notices(directory: string) {
return allowed(directory) ? [] : [{ path: directory, message }]
}
+1 -18
View File
@@ -1,7 +1,6 @@
import path from "path"
import { Data, Duration, Effect, Option, RcMap } from "effect"
import { Effect, Option } from "effect"
import { FSUtil } from "../fs-util"
import type { Ripgrep } from "../ripgrep"
import { ToolOutputStore } from "../tool-output-store"
export interface Target {
@@ -11,8 +10,6 @@ export interface Target {
readonly ino: number
}
class Key extends Data.Class<Target> {}
export const inspect = Effect.fn("SearchTarget.inspect")(function* (fs: FSUtil.Interface, input: string) {
const target = yield* fs.realPath(input)
const info = yield* fs.stat(target)
@@ -31,20 +28,6 @@ export const validate = Effect.fn("SearchTarget.validate")(function* (fs: FSUtil
yield* Effect.fail(new Error("Search target changed after approval"))
})
export const listing = Effect.fn("SearchTarget.listing")(function* (
fs: FSUtil.Interface,
ripgrep: Ripgrep.Interface,
limit: number,
) {
const pending = yield* RcMap.make({
lookup: (target: Target) =>
ripgrep.find({ cwd: target.path, pattern: "*", limit, validate: validate(fs, target) }).pipe(Effect.orDie),
capacity: 1,
idleTimeToLive: Duration.zero,
})
return (target: Target) => RcMap.get(pending, new Key(target)).pipe(Effect.orDie)
})
export const managed = Effect.fn("SearchTarget.managed")(function* (
fs: FSUtil.Interface,
data: string,
-2
View File
@@ -59,7 +59,6 @@ export interface FindInput {
readonly follow?: boolean
readonly signal?: AbortSignal
readonly onEntry?: (entry: Entry) => Effect.Effect<void>
readonly validate?: Effect.Effect<void, unknown> // kilocode_change
}
export interface GlobInput {
@@ -229,7 +228,6 @@ const layer = Layer.effect(
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
validate: input.validate, // kilocode_change
args: [
"--no-config",
"--files",
+87 -255
View File
@@ -1,295 +1,127 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import { FileFinder, type InitOptions } from "@ff-labs/fff-bun"
import "@opencode-ai/core/filesystem"
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
import type { Interface as Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import fs from "node:fs/promises"
import path from "path"
import { Cause, Context, Effect, Layer, Scope } from "effect"
import { allowed } from "@opencode-ai/core/kilocode/fff"
import { allowed, message, notices } from "@opencode-ai/core/kilocode/fff"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
async function search(fn: (service: Search, directory: string) => Promise<void>, opts: { alias?: boolean } = {}) {
await using tmp = await tmpdir()
const directory = opts.alias ? path.join(tmp.path, "link") : tmp.path
if (opts.alias) {
const target = path.join(tmp.path, "project")
await fs.mkdir(target)
await fs.symlink(target, directory, process.platform === "win32" ? "junction" : "dir")
}
const { FileSystemSearch } = await import("@opencode-ai/core/filesystem/search")
const layer = FileSystemSearch.ripgrepLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(LayerNode.compile(Ripgrep.node)),
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
)
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const context = yield* Layer.build(layer)
yield* Effect.promise(() => fn(Context.get(context, FileSystemSearch.Service), directory))
}),
),
)
}
describe("FFF scanning boundaries", () => {
test("disables background indexing and watching by default while retaining explicit opt-in", async () => {
const file = new URL("../../src/flag/flag.ts", import.meta.url).href
const script = `import { Flag } from ${JSON.stringify(file)}; import { Effect } from "effect"; console.log(JSON.stringify({ fff: Flag.KILO_DISABLE_FFF, watcher: await Effect.runPromise(Effect.gen(function* () { return yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER })) }))`
for (const value of [undefined, "false"]) {
const child = Bun.spawn([process.execPath, "--eval", script], {
cwd: path.resolve(import.meta.dir, "../.."),
env: {
...process.env,
KILO_DISABLE_FFF: value,
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: value,
},
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
const result = await new Response(child.stdout).json()
expect(await child.exited).toBe(0)
expect(result).toEqual({ fff: value === undefined, watcher: value === undefined })
}
})
test.each(["/", "/workspace/..", "C:\\", "D:/", "\\\\server\\share\\", "\\\\?\\C:\\", "\\\\?\\UNC\\server\\share\\"])(
"blocks filesystem root %s",
(directory) => expect(allowed(directory)).toBe(false),
)
test("rejects POSIX, Windows drive, and UNC roots", () => {
expect(allowed("/")).toBe(false)
expect(allowed("C:\\")).toBe(false)
expect(allowed("D:/")).toBe(false)
expect(allowed("\\\\server\\share\\")).toBe(false)
expect(allowed("\\\\?\\C:\\workspace\\..")).toBe(false)
expect(allowed("\\\\?\\UNC\\server\\share\\")).toBe(false)
})
test("allows ordinary project directories", () => {
expect(allowed("/workspace")).toBe(true)
expect(allowed("C:\\workspace")).toBe(true)
expect(allowed("D:/workspace")).toBe(true)
expect(allowed("\\\\server\\share\\workspace")).toBe(true)
})
test("only searches safe project directories on request", async () => {
const root = path.parse(process.cwd()).root
test("blocks broad scans and warns for home/root aliases without blocking scoped file tools", async () => {
await using tmp = await tmpdir()
const link = path.join(tmp.path, "root")
await fs.symlink(root, link, process.platform === "win32" ? "junction" : "dir")
const create = FileFinder.create
const home = process.env.KILO_TEST_HOME
const disabled = Flag.KILO_DISABLE_FFF
Flag.KILO_DISABLE_FFF = true
const calls = { fff: 0, ripgrep: 0 }
const create = FileFinder.create
const calls = { native: 0, walk: 0 }
const root = path.parse(tmp.path).root
const project = path.join(tmp.path, "project")
const alias = path.join(tmp.path, "alias")
const link = path.join(tmp.path, "root")
const kind = process.platform === "win32" ? "junction" : "dir"
await fs.mkdir(project)
await fs.writeFile(path.join(project, "file.ts"), "needle\n")
await fs.symlink(tmp.path, alias, kind)
await fs.symlink(root, link, kind)
process.env.KILO_TEST_HOME = tmp.path
FileFinder.create = () => {
calls.fff++
return { ok: false, error: "FFF must not start at a filesystem root" }
calls.native++
return { ok: false, error: "unexpected native index" }
}
const ripgrep = Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: () => {
calls.ripgrep++
return Effect.succeed([])
},
glob: () => Effect.succeed({ items: [], truncated: false, partial: false }),
grep: () => Effect.succeed({ items: [], truncated: false, partial: false }),
}),
)
try {
for (const directory of [root, link, tmp.path]) {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const layer = FileSystemSearch.locationLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(ripgrep),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
expect(allowed(project)).toBe(true)
expect(notices(project)).toEqual([])
await Effect.runPromise(
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const native = yield* Ripgrep.Service
const source = FileSystemSearch.locationLayer.pipe(
Layer.provide(
Layer.succeed(Ripgrep.Service, {
...native,
find: () => Effect.sync(() => (calls.walk++, [])),
}),
),
Layer.provide(FSUtil.defaultLayer),
)
for (const value of [false, true]) {
Flag.KILO_DISABLE_FFF = value
for (const directory of [root, tmp.path, alias, link]) {
expect(allowed(directory)).toBe(false)
expect(notices(directory)).toEqual([{ path: directory, message }])
const context = yield* Layer.build(
source.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
),
Layer.fresh,
),
)
const context = yield* Layer.build(layer)
yield* Effect.yieldNow
const service = Context.get(context, FileSystemSearch.Service)
expect(calls).toEqual({ fff: 0, ripgrep: 0 })
expect(yield* service.find({ query: "", type: "file", limit: 1 })).toEqual([])
}),
),
)
}
expect(calls).toEqual({ fff: 0, ripgrep: 1 })
expect(yield* service.find({ query: "file" })).toEqual([])
if (directory !== tmp.path) continue
const path = RelativePath.make("project")
expect((yield* service.glob({ pattern: "*.ts", path })).map((item) => item.path)).toHaveLength(1)
expect((yield* service.grep({ pattern: "needle", path })).map((item) => item.text)).toEqual(["needle\n"])
}
}
expect(calls).toEqual({ native: 0, walk: 0 })
yield* Effect.promise(() => fs.unlink(alias).then(() => fs.symlink(project, alias, kind)))
const context = yield* Layer.build(
FileSystemSearch.fffLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(alias) }))),
),
),
)
const service = Context.get(context, FileSystemSearch.Service)
for (const directory of [root, tmp.path]) {
yield* Effect.promise(() => fs.unlink(alias).then(() => fs.symlink(directory, alias, kind)))
for (const request of [
service.find({ query: "" }).pipe(Effect.asVoid),
service.glob({ pattern: "*" }).pipe(Effect.asVoid),
service.grep({ pattern: "needle" }).pipe(Effect.asVoid),
]) {
const result = yield* Effect.exit(request)
expect(result._tag).toBe("Failure")
if (result._tag === "Failure") expect(Cause.pretty(result.cause)).toContain(message)
}
}
expect(calls).toEqual({ native: 0, walk: 0 })
}).pipe(Effect.provide(LayerNode.compile(Ripgrep.node)), Effect.scoped),
)
} finally {
FileFinder.create = create
Flag.KILO_DISABLE_FFF = disabled
if (home === undefined) delete process.env.KILO_TEST_HOME
if (home !== undefined) process.env.KILO_TEST_HOME = home
}
})
})
describe("on-demand file search", () => {
test("finds files and directories without a warmup scan", async () => {
await search(async (service, directory) => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "alpha.ts"), "export const alpha = 1\n")
await fs.writeFile(path.join(directory, "beta.ts"), "export const beta = 2\n")
const files = await Effect.runPromise(service.find({ query: "", type: "file" }))
expect(files.map((item) => String(item.path)).sort()).toEqual(["beta.ts", "src/alpha.ts"])
const match = await Effect.runPromise(service.find({ query: " alpts ", type: "file", limit: 1 }))
expect(match.map((item) => String(item.path))).toEqual(["src/alpha.ts"])
const directories = await Effect.runPromise(service.find({ query: "src", type: "directory" }))
expect(directories).toEqual([{ path: RelativePath.make(`src${path.sep}`), type: "directory" }])
const mixed = await Effect.runPromise(service.find({ query: "" }))
expect(mixed.map((item) => item.type).sort()).toEqual(["directory", "file", "file"])
})
})
test("sees added, renamed, and deleted files on the next request", async () => {
await search(async (service, directory) => {
expect(await Effect.runPromise(service.find({ query: "", type: "file" }))).toEqual([])
await fs.writeFile(path.join(directory, "added.ts"), "export const added = 1\n")
expect(
(await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)),
).toEqual(["added.ts"])
await fs.rename(path.join(directory, "added.ts"), path.join(directory, "renamed.ts"))
expect(
(await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)),
).toEqual(["renamed.ts"])
await fs.unlink(path.join(directory, "renamed.ts"))
expect(await Effect.runPromise(service.find({ query: "", type: "file" }))).toEqual([])
})
})
test("reloads ignore rules and keeps hidden files out of file suggestions", async () => {
await search(async (service, directory) => {
await $`git init --quiet`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, ".gitignore"), "ignored.ts\n")
await fs.writeFile(path.join(directory, "ignored.ts"), "export const ignored = 1\n")
await fs.writeFile(path.join(directory, ".hidden.ts"), "export const hidden = 2\n")
await fs.writeFile(path.join(directory, "visible.ts"), "export const visible = 3\n")
expect(
(await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)),
).toEqual(["visible.ts"])
await fs.writeFile(path.join(directory, ".gitignore"), "")
expect(
(await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)).sort(),
).toEqual(["ignored.ts", "visible.ts"])
})
})
test("keeps project-relative search results through a symlinked workspace", async () => {
await search(
async (service, directory) => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "match.ts"), "needle\n")
const files = await Effect.runPromise(service.find({ query: "match", type: "file" }))
const glob = await Effect.runPromise(service.glob({ pattern: "*.ts", path: RelativePath.make("src") }))
const grep = await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") }))
expect(files.map((item) => String(item.path))).toEqual(["src/match.ts"])
expect(glob.map((item) => String(item.path))).toEqual([path.join("src", "match.ts")])
expect(grep.map((item) => String(item.entry.path))).toEqual([path.join("src", "match.ts")])
},
{ alias: true },
)
})
test("keeps glob and grep results current and scoped", async () => {
await search(async (service, directory) => {
await fs.mkdir(path.join(directory, "src"))
const input = { pattern: "**/*.ts", path: RelativePath.make("src") }
expect(await Effect.runPromise(service.glob(input))).toEqual([])
await fs.writeFile(path.join(directory, "src", "match.ts"), "needle\n")
await fs.writeFile(path.join(directory, "outside.ts"), "needle\n")
expect((await Effect.runPromise(service.glob(input))).map((item) => String(item.path))).toEqual([
path.join("src", "match.ts"),
])
const matches = await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") }))
expect(matches.map((item) => String(item.entry.path))).toEqual([path.join("src", "match.ts")])
await fs.writeFile(path.join(directory, "src", "match.ts"), "changed\n")
expect(await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") }))).toEqual([])
})
})
})
describe("FFF lifecycle", () => {
test("rechecks a changed symlink before creating a picker and retries with the canonical path", async () => {
await using tmp = await tmpdir()
const project = path.join(tmp.path, "project")
const link = path.join(tmp.path, "link")
const type = process.platform === "win32" ? "junction" : "dir"
await fs.mkdir(project)
await fs.symlink(project, link, type)
const create = FileFinder.create
const calls: InitOptions[] = []
FileFinder.create = (opts) => {
calls.push(opts)
return { ok: false, error: "native creation intercepted" }
}
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const layer = FileSystemSearch.fffLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(link) }))),
),
)
const context = yield* Layer.build(layer)
const service = Context.get(context, FileSystemSearch.Service)
expect(calls).toEqual([])
yield* Effect.promise(async () => {
await fs.unlink(link)
await fs.symlink(path.parse(project).root, link, type)
})
for (const effect of [
service.find({ query: "", type: "file", limit: 1 }).pipe(Effect.asVoid),
service.glob({ pattern: "*", limit: 1 }).pipe(Effect.asVoid),
service.grep({ pattern: "needle", limit: 1 }).pipe(Effect.asVoid),
]) {
const result = yield* Effect.exit(effect)
expect(result._tag).toBe("Failure")
if (result._tag === "Failure") {
expect(Cause.pretty(result.cause)).toContain("FFF indexing is disabled for filesystem roots.")
}
}
expect(calls).toEqual([])
yield* Effect.promise(async () => {
await fs.unlink(link)
await fs.symlink(project, link, type)
})
yield* Effect.exit(service.find({ query: "", type: "file", limit: 1 }))
expect(calls).toHaveLength(1)
expect(calls[0].basePath).toBe(project)
}),
),
)
} finally {
FileFinder.create = create
}
})
test("retries a failed first search and reuses one picker", async () => {
if (!Fff.available()) return
const dir = await tmpdir()
expect(allowed(dir.path)).toBe(true)
const create = FileFinder.create
const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined }
try {
@@ -1,12 +1,11 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Effect, Exit, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as SearchTarget from "@opencode-ai/core/kilocode/search-target"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -30,84 +29,11 @@ describe("search target confinement", () => {
yield* Effect.promise(() => fs.mkdir(target))
yield* Effect.promise(() => fs.writeFile(path.join(target, "secret.txt"), "secret"))
const ripgrep = yield* Ripgrep.Service
const input = { cwd: target, pattern: "secret", limit: 10, validate: SearchTarget.validate(fsys, approved) }
expect(Exit.isFailure(yield* ripgrep.find(input).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* ripgrep.glob(input).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* ripgrep.grep(input).pipe(Effect.exit))).toBe(true)
}),
),
)
const result = yield* (yield* Ripgrep.Service)
.grep({ cwd: target, pattern: "secret", limit: 10, validate: SearchTarget.validate(fsys, approved) })
.pipe(Effect.exit)
it.live("shares active listings and drops them when requests finish", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const target = yield* SearchTarget.inspect(fsys, tmp)
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let calls = 0
const list = yield* SearchTarget.listing(
fsys,
{
...ripgrep,
find: () =>
Effect.gen(function* () {
calls++
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
return [{ path: RelativePath.make(`scan-${calls}.ts`), type: "file" as const }]
}),
},
100_000,
)
expect(calls).toBe(0)
const one = yield* Scope.make()
const two = yield* Scope.make()
const first = yield* Scope.provide(one)(list(target)).pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* Scope.provide(two)(list({ ...target })).pipe(Effect.forkChild)
yield* Deferred.succeed(release, undefined)
const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(calls).toBe(1)
expect(results.map((items) => String(items[0].path))).toEqual(["scan-1.ts", "scan-1.ts"])
const replaced = { ...target, ino: target.ino === 0 ? 1 : 0 }
expect(Exit.isFailure(yield* list(replaced).pipe(Effect.scoped, Effect.exit))).toBe(true)
expect(calls).toBe(1)
yield* Scope.close(one, Exit.void)
yield* Scope.close(two, Exit.void)
const fresh = yield* list(target).pipe(Effect.scoped)
expect(calls).toBe(2)
expect(String(fresh[0].path)).toBe("scan-2.ts")
}),
),
)
it.live("interrupts an abandoned file scan", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const target = yield* SearchTarget.inspect(fsys, tmp)
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const list = yield* SearchTarget.listing(
fsys,
{
...ripgrep,
find: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
},
100_000,
)
const pending = yield* list(target).pipe(Effect.scoped, Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(pending)
yield* Deferred.await(stopped).pipe(Effect.timeout("5 seconds"))
expect(Exit.isFailure(result)).toBe(true)
}),
),
)
+2 -1
View File
@@ -8,6 +8,7 @@ import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { Flag } from "@opencode-ai/core/flag/flag"
import { notices } from "@opencode-ai/core/kilocode/fff" // kilocode_change
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc
@@ -479,7 +480,7 @@ const layer = Layer.effect(
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
// kilocode_change start - warning accumulator and legacy Kilo config
const warnings: Warning[] = []
const warnings: Warning[] = notices(ctx.directory)
// Untrusted project config may only read files inside this root (worktree, or directory for non-git projects).
const projectRoot = ctx.worktree === "/" ? ctx.directory : ctx.worktree
const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -80,6 +80,16 @@ export namespace KilocodeBootstrap {
Effect.sync(() => log.warn("session export bootstrap failed", { err: Cause.squash(cause) })),
),
)
if (process.env["KILO_PLATFORM"] !== "vscode") {
yield* EffectBridge.fromPromise(() =>
import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()),
).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("indexing bootstrap failed", { err: Cause.squash(cause) })),
),
Effect.forkDetach,
)
}
})
return Service.of({ init })
@@ -6,7 +6,6 @@
*/
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { reconcile } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import * as Clipboard from "@tui/clipboard"
@@ -27,9 +26,6 @@ import { useIndexingWarnings } from "@/kilocode/cli/cmd/tui/indexing-warning"
import { KiloTerminalTitle } from "./terminal-title"
import type { KiloTitleIcon } from "./title-icon"
import { Session as SessionApi } from "@/session/session"
import { useProject } from "@tui/context/project"
import * as Branch from "./branch-refresh"
import * as Log from "@opencode-ai/core/util/log"
// Re-export so upstream can render the route without importing directly
export { KiloClawView } from "@/kilocode/claw/view"
@@ -84,27 +80,14 @@ export function useSessionEffects(deps: {
const pty = process.env.KILO_PTY_ID
const viewerId = crypto.randomUUID()
const renderer = useRenderer()
const project = useProject()
const session = createMemo(() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined))
let active = true
const meta = { prev: "" }
const log = Log.create({ service: "tui-branch" })
const branch = Branch.create({
get: (input) => deps.sdk.client.vcs.get(input, { throwOnError: true }),
apply: (data) => deps.sync.set("vcs", reconcile(data)),
scope: () => ({
workspace: project.workspace.current(),
directory: project.instance.directory() || deps.sdk.directory,
project: project.project() ?? undefined,
}),
ready: () => deps.sync.data.vcs !== undefined,
})
function send() {
const id = session()
const ids = id ? [id] : []
deps.sdk.client.session.viewed({ viewer: { id: viewerId, active }, attached: ids, visible: ids }).catch(() => {})
if (active) void branch.refresh().catch((err) => log.warn("branch refresh failed", { err }))
}
createEffect(() => send())
@@ -152,7 +135,6 @@ export function useSessionEffects(deps: {
)
onCleanup(() => {
branch.dispose()
renderer.off("focus", onFocus)
renderer.off("blur", onBlur)
offConnected()
@@ -1,48 +0,0 @@
import type { VcsInfo } from "@kilocode/sdk/v2"
type Scope = {
workspace?: string
directory?: string
project?: string
}
type Input = {
get: (input: { workspace?: string; directory?: string }) => Promise<{ data?: VcsInfo }>
apply: (data: VcsInfo) => void
scope: () => Scope
ready: () => boolean
}
export function create(input: Input) {
const state = { version: 0, disposed: false }
async function refresh() {
if (state.disposed) return
const version = ++state.version
const scope = input.scope()
if (!scope.directory || !scope.project || !input.ready()) return
const route = scope.workspace ? { workspace: scope.workspace } : { directory: scope.directory }
const result = await input.get(route)
const current = input.scope()
if (
!result.data ||
!input.ready() ||
state.disposed ||
version !== state.version ||
current.workspace !== scope.workspace ||
current.directory !== scope.directory ||
current.project !== scope.project
)
return
input.apply(result.data)
}
function dispose() {
state.disposed = true
state.version += 1
}
return { refresh, dispose }
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { IndexingStatus, disabledIndexingStatus } from "@kilocode/kilo-indexing/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { allowed } from "@opencode-ai/core/kilocode/fff"
import { allowed, message } from "@opencode-ai/core/kilocode/fff"
import { Instance } from "@/kilocode/instance"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
@@ -34,7 +34,7 @@ const consent = new Map<string, boolean>()
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
const noWorkspace = () =>
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
const unsafeRoot = () => disabledIndexingStatus("Codebase indexing is disabled for filesystem roots.")
const unsafeRoot = () => disabledIndexingStatus(message)
const noConsent = () =>
disabledIndexingStatus("Codebase indexing is disabled until you enable it for this project in Kilo Settings.")
+8 -3
View File
@@ -57,8 +57,6 @@ export namespace KilocodeWatcher {
return Service.of({
init: Effect.fn("KilocodeWatcher.init")(function* () {
if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => true))))
return
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git" || active.has(ctx.directory)) return
@@ -79,5 +77,12 @@ export namespace KilocodeWatcher {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(locationServiceMapLayer))
// Gate the whole layer so LocationServiceMap is only warmed for clients that consume branch-update events.
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false))))
return Layer.succeed(Service, Service.of({ init: () => Effect.void }))
return layer.pipe(Layer.provide(locationServiceMapLayer))
}),
)
}
+12 -20
View File
@@ -302,23 +302,15 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
const events = yield* EventV2Bridge.Service
const scope = yield* Scope.Scope
// kilocode_change start
const refresh = Effect.fnUntraced(function* (value: State, directory: string) {
const next = yield* git.branch(directory)
if (next !== value.current) {
value.current = next
yield* events.publish(Event.BranchUpdated, { branch: next })
}
return next
})
// kilocode_change end
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")(function* (ctx) {
if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined }
}
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
@@ -329,7 +321,13 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
return Effect.void
const data = event.data as EventV2.Data<typeof Watcher.Event.Updated>
if (!data.file.endsWith("HEAD")) return Effect.void
return refresh(value, ctx.directory) // kilocode_change
return Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
value.current = next
yield* events.publish(Event.BranchUpdated, { branch: next })
}
})
})
yield* Effect.addFinalizer(() => unsubscribe)
@@ -341,14 +339,9 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state).pipe(Effect.forkIn(scope))
}),
// kilocode_change start
branch: Effect.fn("Vcs.branch")(function* () {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return
return yield* refresh(value, ctx.directory)
return yield* InstanceState.use(state, (x) => x.current)
}),
// kilocode_change end
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
@@ -386,8 +379,7 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
}
if (!value.root) return []
const current = yield* refresh(value, ctx.directory) // kilocode_change
if (current && current === value.root.name) return [] // kilocode_change
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref, options)
@@ -1,77 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { VcsInfo } from "@kilocode/sdk/v2"
import { create } from "../../../../src/kilocode/cli/cmd/tui/branch-refresh"
function setup(workspace?: string) {
const state = {
scope: { workspace, directory: "/repo", project: "project" },
vcs: { branch: "main", default_branch: "main" } as VcsInfo | undefined,
}
const calls: Array<{ workspace?: string; directory?: string }> = []
const pending = Promise.withResolvers<{ data?: VcsInfo }>()
const refresh = create({
get: async (route) => {
calls.push(route)
return pending.promise
},
apply: (data) => (state.vcs = data),
scope: () => state.scope,
ready: () => state.vcs !== undefined,
})
return { state, calls, pending, refresh }
}
describe("TUI branch refresh", () => {
test("waits for bootstrap and then applies the complete VCS snapshot", async () => {
const value = setup()
value.state.vcs = undefined
await value.refresh.refresh()
expect(value.calls).toEqual([])
value.state.vcs = { branch: "main", default_branch: "main" }
const run = value.refresh.refresh()
value.pending.resolve({ data: { branch: "feature", default_branch: "main" } })
await run
expect(value.state.vcs).toEqual({ branch: "feature", default_branch: "main" })
})
test.each(["ws", undefined])(
"routes %s refreshes and updates metadata when the branch is unchanged",
async (workspace) => {
const value = setup(workspace)
const run = value.refresh.refresh()
expect(value.calls).toEqual([workspace ? { workspace } : { directory: "/repo" }])
value.pending.resolve({ data: { branch: "main", default_branch: "develop" } })
await run
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "develop" })
},
)
test("ignores responses after the scope changes", async () => {
const value = setup("ws-a")
const run = value.refresh.refresh()
value.state.scope = { workspace: "ws-b", directory: "/repo/b", project: "project" }
value.pending.resolve({ data: { branch: "stale", default_branch: "main" } })
await run
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
})
test("ignores responses after disposal", async () => {
const value = setup()
const run = value.refresh.refresh()
value.refresh.dispose()
value.pending.resolve({ data: { branch: "stale", default_branch: "main" } })
await run
await value.refresh.refresh()
expect(value.calls).toHaveLength(1)
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
})
test("keeps the current VCS snapshot when the response has no data", async () => {
const value = setup()
const run = value.refresh.refresh()
value.pending.resolve({})
await run
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
})
})
@@ -8,6 +8,8 @@ import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import type { Config } from "../../src/config/config"
import { GlobalBus } from "../../src/bus/global"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Global } from "@opencode-ai/core/global"
import { message } from "@opencode-ai/core/kilocode/fff"
import { WorkspaceContext } from "../../src/control-plane/workspace-context"
import { KiloIndexing, IndexingModelError } from "../../src/kilocode/indexing"
import { indexingWarningKey } from "../../src/kilocode/indexing-warning"
@@ -533,35 +535,37 @@ describe("indexing startup degradation", () => {
})
})
test("does not allocate an indexing worker for a filesystem root", async () => {
test("warns for home/root workspaces and aliases without allocating an indexing worker", async () => {
const created: string[] = []
IndexingWorker.override((directory, root, hooks) => {
IndexingWorker.override((directory) => {
created.push(directory)
return inline(directory, root, hooks)
throw new Error("unsafe workspaces must not allocate an indexing worker")
})
const root = path.parse(process.cwd()).root
await using tmp = await tmpdir()
const link = process.platform === "win32" ? undefined : path.join(tmp.path, "root")
if (link) await fs.symlink(root, link)
for (const directory of [root, link].filter((item): item is string => item !== undefined)) {
await provideTestInstance({
directory,
fn: async () => {
const status = await KiloIndexing.current()
expect(status).toMatchObject({
state: "Disabled",
message: "Codebase indexing is disabled for filesystem roots.",
})
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("filesystem root")).toEqual([])
expect(created).toEqual([])
},
})
const app = Server.Default().app
for (const target of [path.parse(process.cwd()).root, Global.Path.home]) {
const link = path.join(tmp.path, target === Global.Path.home ? "home" : "root")
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir")
for (const directory of [target, link]) {
await provideTestInstance({
directory,
fn: async () => {
expect(await KiloIndexing.current()).toMatchObject({ state: "Disabled", message })
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("filesystem root")).toEqual([])
const warnings = await app.request("/config/warnings", { headers: { "x-kilo-directory": directory } })
expect(warnings.status).toBe(200)
expect(await warnings.json()).toContainEqual(expect.objectContaining({ message }))
expect(created).toEqual([])
},
})
}
}
const warnings = await app.request("/config/warnings", { headers: { "x-kilo-directory": tmp.path } })
expect(warnings.status).toBe(200)
expect(await warnings.json()).not.toContainEqual(expect.objectContaining({ message }))
})
test.each([false, true])("handles removed directories with no-workspace flag %s", async (disabled) => {
@@ -1,5 +1,4 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { describe, expect, test } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ConfigProvider, Context, Deferred, Effect, Fiber, Layer, LayerMap } from "effect"
@@ -8,13 +7,11 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { InstanceRef } from "../../src/effect/instance-ref"
import { disposeInstance } from "../../src/effect/instance-registry"
import { Git } from "../../src/git"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Vcs } from "../../src/project/vcs"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { KilocodeWatcher } from "../../src/kilocode/watcher"
import type { InstanceContext } from "../../src/project/instance-context"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
@@ -28,9 +25,6 @@ const config = ConfigProvider.layerAdd(ConfigProvider.fromUnknown({ KILO_EXPERIM
asPrimary: true,
})
const it = testEffect(layer.pipe(Layer.provideMerge(config)))
const direct = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, CrossSpawnSpawner.node])),
)
// The watcher is unreliable on Windows CI, so this test only runs on unix.
const live = process.platform === "win32" ? it.live.skip : it.live
@@ -97,43 +91,6 @@ live(
20_000,
)
direct.instance(
"refreshes branch reads, events, and diffs without native watchers",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const vcs = yield* Vcs.Service
const events = yield* EventV2Bridge.Service
const git = Effect.fn(function* (args: string[]) {
const result = yield* Git.Service.use((git) => git.run(args, { cwd: test.directory }))
expect(result.exitCode).toBe(0)
})
yield* git(["branch", "-M", "main"])
expect(yield* vcs.branch()).toBe("main")
const updated = yield* Deferred.make<string | undefined>()
const off = yield* events.listen((event) => {
if (event.type === Vcs.Event.BranchUpdated.type)
Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch))
return Effect.void
})
yield* Effect.addFinalizer(() => off)
yield* git(["switch", "-c", "feature"])
expect(yield* vcs.branch()).toBe("feature")
expect(yield* awaitWithTimeout(Deferred.await(updated), "timed out waiting for branch update")).toBe("feature")
yield* git(["switch", "main"])
expect(yield* vcs.branch()).toBe("main")
yield* git(["switch", "feature"])
yield* Effect.promise(() => Bun.write(`${test.directory}/branch.txt`, "branch\n"))
yield* git(["add", "branch.txt"])
yield* git(["commit", "--no-gpg-sign", "-m", "branch change"])
const diff = yield* vcs.diff("branch")
expect(diff.find((item) => item.file === "branch.txt")).toMatchObject({ status: "added" })
}),
{ git: true },
)
test.serial(
"isolates location lifetimes between instances",
async () => {
@@ -166,12 +123,6 @@ test.serial(
} as InstanceContext),
)
yield* init(one).pipe(
Effect.provide(
ConfigProvider.layer(ConfigProvider.fromUnknown({ KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" })),
),
)
expect(warmed.size).toBe(0)
yield* init(one)
yield* init(one)
yield* init(two)
@@ -188,7 +139,7 @@ test.serial(
expect(invalidated).toEqual([one, two])
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), TestConsole.layer, config)),
Effect.provide(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), TestConsole.layer)),
),
)
},
@@ -454,9 +454,11 @@ describe("kilocode tool registry indexing", () => {
}
})
test("does not start indexing during session bootstrap", async () => {
test("logs indexing bootstrap failures without blocking session bootstrap", async () => {
const platform = process.env["KILO_PLATFORM"]
process.env["KILO_PLATFORM"] = "cli"
const logger = Log.create({ service: "kilocode-bootstrap" })
const err = new Error("indexing init failed")
const calls: string[] = []
const sessions = Layer.succeed(
KiloSessions.Service,
@@ -481,7 +483,8 @@ describe("kilocode tool registry indexing", () => {
const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface)
const provider = Layer.succeed(Provider.Service, {} as Provider.Interface)
const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void }))
const indexing = spyOn(KiloIndexing, "init").mockResolvedValue(undefined)
const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err)
const warn = spyOn(logger, "warn").mockImplementation(() => {})
try {
await Effect.runPromise(
@@ -495,11 +498,13 @@ describe("kilocode tool registry indexing", () => {
await new Promise((resolve) => setTimeout(resolve, 0))
expect(calls).toEqual(["sessions"])
expect(indexing).not.toHaveBeenCalled()
expect(indexing).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith("indexing bootstrap failed", { err })
} finally {
if (platform === undefined) delete process.env["KILO_PLATFORM"]
else process.env["KILO_PLATFORM"] = platform
indexing.mockRestore()
warn.mockRestore()
}
})
})
@@ -69,36 +69,8 @@ describe("TUI session presence contract", () => {
expect(cleanup()).toContain("offConnected()")
})
test("refreshes branch state from the existing session effect triggers", () => {
const body = flat(effects())
expect(body).toContain("const branch = Branch.create")
expect(body).toContain("if (active) void branch.refresh().catch")
expect(body).toContain("get: (input) => deps.sdk.client.vcs.get(input, { throwOnError: true })")
expect(body).toContain('apply: (data) => deps.sync.set("vcs", reconcile(data))')
expect(body).toContain("ready: () => deps.sync.data.vcs !== undefined")
})
test("file autocomplete cancels superseded and closed requests", () => {
const source = flat(
fs.readFileSync(path.resolve(import.meta.dir, "../../../tui/src/component/prompt/autocomplete.tsx"), "utf8"),
)
expect(source).toContain("visible: store.visible")
expect(source).toContain("onCleanup(() => request?.abort())")
expect(source).toContain("request?.abort()")
expect(source).toContain("{ signal: controller.signal }")
expect(source).toContain("if (controller.signal.aborted || !result) return []")
})
test("scopes branch events by directory when there is no workspace ID", () => {
const source = fs.readFileSync(path.resolve(import.meta.dir, "../../../tui/src/context/sync.tsx"), "utf8")
expect(flat(source)).toContain(
"workspace === project.workspace.current() && (workspace !== undefined || directory === project.instance.directory())",
)
})
test("cleanup removes focus/blur listeners and sends a final inactive empty snapshot", () => {
const tail = cleanup()
expect(tail).toContain("branch.dispose()")
expect(tail).toContain('renderer.off("focus", onFocus)')
expect(tail).toContain('renderer.off("blur", onBlur)')
expect(flat(tail)).toContain(".viewed({ viewer: { id: viewerId, active: false }, attached: [], visible: [] })")
@@ -321,40 +321,22 @@ export function Autocomplete(props: {
insertPart(filename, part)
}
// kilocode_change start
let request: AbortController | undefined
onCleanup(() => request?.abort())
// kilocode_change end
const [files] = createResource(
() => ({ query: search(), location: location(), visible: store.visible }), // kilocode_change
() => ({ query: search(), location: location() }),
async (input) => {
request?.abort() // kilocode_change
if (!input.visible || input.visible === "/") return [] // kilocode_change
if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
// kilocode_change start
const controller = new AbortController()
request = controller
const result = await sdk.client.v2.fs
.find(
{
query: baseQuery,
limit: "20",
location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(),
},
},
{ signal: controller.signal },
)
.catch((err) => {
if (controller.signal.aborted) return
throw err
})
if (controller.signal.aborted || !result) return []
// kilocode_change end
// Get files from SDK
const result = await sdk.client.v2.fs.find({
query: baseQuery,
limit: "20",
location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(),
},
})
const options: AutocompleteOption[] = []
+2 -7
View File
@@ -614,15 +614,10 @@ export const {
}
case "vcs.branch.updated": {
// kilocode_change start
if (
workspace === project.workspace.current() &&
(workspace !== undefined || directory === project.instance.directory())
) {
vcsVersion += 1
if (workspace === project.workspace.current()) {
vcsVersion += 1 // kilocode_change
setStore("vcs", { branch: event.properties.branch })
}
// kilocode_change end
break
}
// kilocode_change start