Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-greps-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Add bounded, context-aware grep controls without leaving agents waiting on completed searches.
47 changes: 37 additions & 10 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
import * as NodePath from "@effect/platform-node/NodePath"
import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change
import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close
import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change
import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change
import { settle } from "./kilocode/exit-code" // kilocode_change - settle signal termination as 128 + signum
import * as Deferred from "effect/Deferred"
Expand Down Expand Up @@ -268,7 +269,7 @@ export const make = Effect.gen(function* () {
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}

const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions, settle: boolean) =>
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
Expand All @@ -280,6 +281,7 @@ export const make = Effect.gen(function* () {
})
proc.on("exit", (...args) => {
exit = args
if (settle) Deferred.doneUnsafe(signal, Exit.succeed(args)) // kilocode_change - bounded grep must not await inherited pipes
})
proc.on("close", (...args) => {
if (end) return
Expand Down Expand Up @@ -326,6 +328,18 @@ export const make = Effect.gen(function* () {
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
})

// kilocode_change start - inspect descendants owned by commands that settle on direct exit
const groupAlive = (proc: NodeChildProcess.ChildProcess) => {
if (process.platform === "win32") return false
try {
process.kill(-proc.pid!, 0)
return true
} catch {
return false
}
}
// kilocode_change end

const timeout =
(
proc: NodeChildProcess.ChildProcess,
Expand Down Expand Up @@ -370,6 +384,7 @@ export const make = Effect.gen(function* () {
switch (command._tag) {
case "StandardCommand": {
const validation = SpawnValidation.take(command) // kilocode_change - retain target validation through preparation
const direct = SpawnExit.take(command) // kilocode_change - opt selected commands into direct-exit settlement
const dir = yield* cwd(command.options)
// kilocode_change start - prepare agent-scoped commands through the selected sandbox backend
const target = yield* prepareSandbox(command, dir, env(command.options))
Expand All @@ -396,21 +411,33 @@ export const make = Effect.gen(function* () {

const [proc, signal] = yield* Effect.acquireRelease(
// kilocode_change start - spawn the prepared command and options
spawn(target, {
cwd: dir,
env: env(target.options),
stdio: stdios(sin, sout, serr, extra),
detached: target.options.detached ?? process.platform !== "win32",
shell: target.options.shell,
// kilocode_change end
windowsHide: process.platform === "win32",
}),
spawn(
target,
{
cwd: dir,
env: env(target.options),
stdio: stdios(sin, sout, serr, extra),
detached: target.options.detached ?? process.platform !== "win32",
shell: target.options.shell,
// kilocode_change end
windowsHide: process.platform === "win32",
},
direct, // kilocode_change
),
Effect.fnUntraced(function* ([proc, signal]) {
const done = yield* Deferred.isDone(signal)
const kill = timeout(proc, command, target.options) // kilocode_change
if (done) {
const [code] = yield* Deferred.await(signal)
if (process.platform === "win32") return yield* Effect.void
// kilocode_change start - clean up only descendants owned by direct-settling commands
if (direct && groupAlive(proc)) {
yield* Effect.ignore(killGroup(command, proc, target.options.killSignal ?? "SIGTERM"))
yield* Effect.sleep("100 millis")
if (groupAlive(proc)) yield* Effect.ignore(killGroup(command, proc, "SIGKILL"))
return yield* Effect.void
}
// kilocode_change end
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/kilocode/ripgrep-grep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Match } from "@opencode-ai/schema/filesystem"

export interface Options {
readonly context?: number
readonly literal?: boolean
readonly ignoreCase?: boolean
}

export type GrepMatch = Match & {
readonly context: boolean
readonly textTruncated: boolean
}

export const flags = (input: Options) => [
...(input.literal ? ["--fixed-strings"] : []),
...(input.ignoreCase ? ["--ignore-case"] : []),
...(input.context ? [`--context=${input.context}`] : []),
]

export const stop = (limit: number) => {
let matches = 0
return (row: { readonly context: boolean }) => !row.context && ++matches > limit
}

export const select = <
A extends {
readonly context: boolean
readonly path: { readonly text: string }
readonly line_number: number
},
>(
input: { readonly limit: number; readonly context?: number },
items: readonly A[],
) => {
let count = 0
const overflow = items.findIndex((row) => !row.context && ++count > input.limit)
const selected = items.slice(0, overflow === -1 ? items.length : overflow)
const matches = selected.filter((row) => !row.context)
return selected.filter(
(row) =>
!row.context ||
matches.some(
(match) =>
match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0),
),
)
}

export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch =>
Object.assign(match, { context, textTruncated })
14 changes: 14 additions & 0 deletions packages/core/src/kilocode/spawn-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ChildProcess } from "effect/unstable/process"

const commands = new WeakSet<object>()

export function attach(command: ChildProcess.StandardCommand) {
commands.add(command)
return command
}

export function take(command: ChildProcess.StandardCommand) {
const found = commands.has(command)
commands.delete(command)
return found
}
48 changes: 35 additions & 13 deletions packages/core/src/ripgrep.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export * as Ripgrep from "./ripgrep"

import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { makeGlobalNode } from "./effect/app-node"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change
import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change
import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
Expand All @@ -21,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024
const MAX_SUBMATCHES = 100

const RawMatch = Schema.Struct({
type: Schema.Literal("match"),
type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records
data: Schema.Struct({
path: Schema.Struct({ text: Schema.String }),
lines: Schema.Struct({ text: Schema.String }),
Expand All @@ -37,7 +39,7 @@ const RawMatch = Schema.Struct({
}),
})

type RawMatchData = (typeof RawMatch.Type)["data"]
type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change

export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
Expand Down Expand Up @@ -69,7 +71,8 @@ export interface GlobInput {
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - bind approved searches at spawn
}

export interface GrepInput {
export interface GrepInput extends KiloGrep.Options {
// kilocode_change
readonly cwd: string
readonly pattern: string
readonly file?: string
Expand All @@ -82,7 +85,7 @@ export interface GrepInput {
export interface Interface {
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<KiloGrep.GrepMatch>, Error | InvalidPatternError> // kilocode_change
}

// kilocode_change start - retain truncation state through model-facing tools
Expand Down Expand Up @@ -114,6 +117,7 @@ const layer = Layer.effect(
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - spawn-bound target validation
}) => {
const program = Effect.scoped(
Expand All @@ -124,16 +128,24 @@ const layer = Layer.effect(
cwd: input.cwd,
extendEnv: true,
stdin: "ignore",
forceKillAfter: input.stop ? Duration.seconds(1) : undefined, // kilocode_change - bound grep interruption
})
const handle = yield* process.spawn(
input.validate ? SpawnValidation.attach(command, input.validate) : command,
)
const validated = input.validate ? SpawnValidation.attach(command, input.validate) : command
const spawned = input.stop ? SpawnExit.attach(validated) : validated // kilocode_change
const handle = yield* process.spawn(spawned)
// kilocode_change end
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
let stopped = false // kilocode_change
const take = input.stop // kilocode_change start
? Stream.takeUntil<A>((row) => {
stopped = input.stop?.(row) ?? false
return stopped
})
: Stream.take(input.limit + 1) // kilocode_change end
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Expand All @@ -143,11 +155,12 @@ const layer = Layer.effect(
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
take, // kilocode_change
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change
const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop owns truncation
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }

const code = yield* handle.exitCode
Expand Down Expand Up @@ -244,11 +257,13 @@ const layer = Layer.effect(
grep: (input) =>
run<RawMatchData>({
...input,
stop: KiloGrep.stop(input.limit), // kilocode_change
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...KiloGrep.flags(input), // kilocode_change
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
Expand All @@ -264,13 +279,19 @@ const layer = Layer.effect(
})
).pipe(
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
if (
!json ||
typeof json !== "object" ||
!("type" in json) ||
(json.type !== "match" && json.type !== "context") // kilocode_change
)
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
context: match.type === "context", // kilocode_change
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
Expand All @@ -280,12 +301,12 @@ const layer = Layer.effect(
// kilocode_change start - retain spawn metadata after mapping matches
Effect.map((result) => ({
...result,
items: result.items.map((match) => {
items: KiloGrep.select(input, result.items).map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({
const item = Match.make({
entry: Entry.make({
path: RelativePath.make(relative),
type: "file",
Expand All @@ -299,6 +320,7 @@ const layer = Layer.effect(
end: submatch.end,
})),
})
return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000)
}),
})),
// kilocode_change end
Expand Down
Loading
Loading