feat: background process

This commit is contained in:
Catriel Müller
2026-05-21 09:13:55 -03:00
parent 794189be2e
commit cd009c3d8f
35 changed files with 2876 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support tracked background processes in the CLI so agents can start long-running dev servers, inspect their status and logs from `/process`, see them in the sidebar, and stop them automatically when switching sessions or exiting the TUI.
@@ -197,7 +197,9 @@
[data-slot="user-message-text"] [data-highlight="file"][data-clickable] {
cursor: pointer;
transition: background-color 0.15s, box-shadow 0.15s;
transition:
background-color 0.15s,
box-shadow 0.15s;
}
[data-slot="user-message-text"] [data-highlight="file"][data-clickable]:hover {
@@ -19,6 +19,7 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
BackgroundProcessInfo, // kilocode_change
} from "@kilocode/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
@@ -77,6 +78,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
todo: {
[sessionID: string]: Todo[]
}
// kilocode_change start
background_process: {
[sessionID: string]: BackgroundProcessInfo[]
}
// kilocode_change end
message: {
[sessionID: string]: Message[]
}
@@ -119,6 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session_status: {},
session_diff: {},
todo: {},
background_process: {}, // kilocode_change
message: {},
part: {},
lsp: [],
@@ -149,6 +156,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
delete draft.session_diff[sessionID]
delete draft.session_status[sessionID]
delete draft.todo[sessionID]
delete draft.background_process[sessionID] // kilocode_change
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.suggestion[sessionID]
@@ -369,6 +377,45 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
// kilocode_change start
case "background_process.updated": {
const info = event.properties.info
const list = store.background_process[info.sessionID]
if (!list) {
setStore("background_process", info.sessionID, [info])
break
}
const result = Binary.search(list, info.id, (item) => item.id)
if (result.found) {
setStore("background_process", info.sessionID, result.index, reconcile(info))
break
}
setStore(
"background_process",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
}),
)
break
}
case "background_process.deleted": {
const list = store.background_process[event.properties.sessionID]
if (!list) break
const result = Binary.search(list, event.properties.processID, (item) => item.id)
if (!result.found) break
setStore(
"background_process",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
// kilocode_change end
// kilocode_change start
case "message.updated": {
const info = strip(event.properties.info)
@@ -601,6 +648,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
setStore("network", reconcile(next))
}),
sdk.client.backgroundProcess.list({ workspace }).then((x) => {
const next: Record<string, BackgroundProcessInfo[]> = {}
for (const item of x.data ?? []) {
if (!next[item.sessionID]) next[item.sessionID] = []
next[item.sessionID].push(item)
}
setStore("background_process", reconcile(next))
}),
// kilocode_change end
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
@@ -159,6 +159,11 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
todo(sessionID) {
return sync.data.todo[sessionID] ?? []
},
// kilocode_change start
processes(sessionID) {
return sync.data.background_process[sessionID] ?? []
},
// kilocode_change end
messages(sessionID) {
return sync.data.message[sessionID] ?? []
},
@@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news"
import HomeOnboarding from "@/kilocode/plugins/home-onboarding"
import KiloHomeFooter from "@/kilocode/plugins/home-footer"
import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer"
import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-background-processes"
import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
// kilocode_change end
@@ -29,6 +30,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeOnboarding, // kilocode_change
KiloHomeFooter, // kilocode_change
KiloSidebarFooter, // kilocode_change
KiloSidebarBackgroundProcesses, // kilocode_change
KiloSidebarPr, // kilocode_change
KiloSidebarUsage, // kilocode_change
HomeFooter,
@@ -3,6 +3,7 @@ import {
createContext,
createEffect,
createMemo,
onCleanup, // kilocode_change
createSignal,
For,
Match,
@@ -25,6 +26,7 @@ import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes,
import { Prompt, type PromptRef } from "@tui/component/prompt"
// kilocode_change start
import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
// kilocode_change end
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
@@ -43,7 +45,10 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change
// kilocode_change start
import type { BackgroundProcessTool } from "@/kilocode/tool/background-process"
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search"
// kilocode_change end
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
@@ -270,6 +275,25 @@ export function Session() {
const sdk = useSDK()
const editor = useEditorContext()
// kilocode_change start - background processes are scoped to the visible session
function stopProcesses(sessionID: string) {
void sdk.client.backgroundProcess.stopSession({ sessionID }).catch((err) => {
Log.Default.warn("failed to stop session background processes", { sessionID, err })
})
}
let processSessionID = route.sessionID
createEffect(() => {
const next = route.sessionID
if (processSessionID === next) return
stopProcesses(processSessionID)
processSessionID = next
})
onCleanup(() => {
stopProcesses(processSessionID)
})
// kilocode_change end
createEffect(() => {
const sessionID = route.sessionID
void (async () => {
@@ -1707,6 +1731,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Grep {...toolprops} />
</Match>
{/* kilocode_change start */}
<Match when={props.part.tool === "background_process"}>
<BackgroundProcess {...toolprops} />
</Match>
<Match when={props.part.tool === "semantic_search"}>
<SemanticSearch {...toolprops} />
</Match>
@@ -2120,6 +2147,51 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
}
// kilocode_change start
function BackgroundProcess(props: ToolProps<typeof BackgroundProcessTool>) {
const sync = useSync()
const running = createMemo(() => props.part.state.status === "running")
const cmd = createMemo(() => (typeof props.input.command === "string" ? props.input.command : ""))
const action = createMemo(() => props.input.action ?? "start")
const desc = createMemo(() => props.input.description || cmd() || props.input.id || "background process")
const dir = createMemo(() => {
const raw = props.input.workdir
if (!raw || raw === ".") return undefined
const base = sync.path.directory
if (!base) return normalizePath(raw)
const abs = path.resolve(base, raw)
if (abs === base) return undefined
return normalizePath(abs)
})
const status = createMemo(() => {
if (typeof props.metadata.status === "string") return props.metadata.status
if (typeof props.metadata.count === "number") return `${props.metadata.count} running`
return undefined
})
const title = createMemo(() => {
if (action() === "list") return "List background processes"
if (action() === "logs") return `View background logs: ${desc()}`
if (action() === "status") return `Check background process: ${desc()}`
if (action() === "stop") return `Stop background process: ${desc()}`
if (action() === "restart") return `Restart background process: ${desc()}`
return `Start background process: ${desc()}`
})
return (
<InlineTool
icon="$"
pending="Preparing background process..."
complete={desc()}
spinner={running()}
part={props.part}
>
{title()}
<Show when={dir()}> in {dir()}</Show>
<Show when={cmd()}> · $ {cmd()}</Show>
<Show when={status()}> ({status()})</Show>
</InlineTool>
)
}
function SemanticSearch(props: ToolProps<typeof SemanticSearchTool>) {
const meta = createMemo(() => props.metadata as { results?: { length: number }[] })
const args = createMemo(() => props.input as { query?: string; path?: string })
@@ -309,9 +309,20 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
if (permission === ShellID.ToolID) {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = normalizeUrls(typeof data.command === "string" ? data.command : "") // kilocode_change
// kilocode_change start
const meta = props.request.metadata ?? {}
const desc =
typeof data.description === "string" && data.description
? data.description
: typeof meta.description === "string" && meta.description
? meta.description
: undefined
const bg = meta.backgroundProcess === true
const title = bg ? `Start background process${desc ? `: ${desc}` : ""}` : (desc ?? "Shell command")
const command = normalizeUrls(
typeof data.command === "string" ? data.command : typeof meta.command === "string" ? meta.command : "",
)
// kilocode_change end
return {
icon: "#",
title,
+1 -3
View File
@@ -5,9 +5,7 @@ import { logo as glyphs } from "./logo"
import { tui } from "../kilocode/cli/logo" // kilocode_change
// kilocode_change start
const wordmark = [
...tui(),
]
const wordmark = [...tui()]
// kilocode_change end
export const CancelledError = NamedError.create("UICancelledError", z.void())
@@ -0,0 +1,461 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/project/instance"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
import { spawn, type ChildProcess } from "child_process"
import { Context, Effect, Layer, Schema, Types } from "effect"
import net from "net"
import path from "path"
import z from "zod"
export namespace BackgroundProcess {
const log = Log.create({ service: "background-process" })
const MAX = 200 * 1024
const KILL_MS = 3_000
const READY_MS = 30_000
const PUBLISH_MS = 500
const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe(
Schema.brand("BackgroundProcessID"),
)
export type ID = typeof idSchema.Type
export const ID = idSchema.pipe(
withStatics((schema: typeof idSchema) => ({
ascending: (id?: string) => {
if (id && !id.startsWith("bgp")) throw new Error(`Background process ID must start with bgp: ${id}`)
return schema.make(id ?? Identifier.create("bgp", "ascending"))
},
zod: zod(schema),
})),
)
export const Status = Schema.Literals(["starting", "running", "ready", "exited", "failed", "stopping", "stopped"])
export type Status = Schema.Schema.Type<typeof Status>
export const Ready = Schema.Struct({
pattern: optionalOmitUndefined(Schema.String).annotate({
description: "Regular expression matched against output to mark the process ready",
}),
port: optionalOmitUndefined(PositiveInt).annotate({
description: "Local TCP port to probe until accepting connections",
}),
timeout: optionalOmitUndefined(PositiveInt).annotate({
description: "Milliseconds to wait for readiness before returning the process as running",
}),
})
.annotate({ identifier: "BackgroundProcessReady" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Ready = Types.DeepMutable<Schema.Schema.Type<typeof Ready>>
export const Info = Schema.Struct({
id: ID,
sessionID: SessionID,
pid: optionalOmitUndefined(PositiveInt),
command: Schema.String,
cwd: Schema.String,
description: optionalOmitUndefined(Schema.String),
status: Status,
ready: Schema.Boolean,
exitCode: optionalOmitUndefined(Schema.NullOr(NonNegativeInt)),
signal: optionalOmitUndefined(Schema.NullOr(Schema.String)),
output: Schema.String,
time: Schema.Struct({
started: NonNegativeInt,
updated: NonNegativeInt,
ended: optionalOmitUndefined(NonNegativeInt),
}),
})
.annotate({ identifier: "BackgroundProcessInfo" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const StartInput = Schema.Struct({
sessionID: SessionID,
command: Schema.String.annotate({ description: "Command to run in the configured shell" }),
cwd: optionalOmitUndefined(Schema.String).annotate({
description: "Working directory. Defaults to the project directory",
}),
description: optionalOmitUndefined(Schema.String).annotate({ description: "Short human readable process label" }),
ready: optionalOmitUndefined(Ready),
})
.annotate({ identifier: "BackgroundProcessStartInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type StartInput = Types.DeepMutable<Schema.Schema.Type<typeof StartInput>>
export const Logs = Schema.Struct({
id: ID,
sessionID: SessionID,
output: Schema.String,
})
.annotate({ identifier: "BackgroundProcessLogs" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Logs = Types.DeepMutable<Schema.Schema.Type<typeof Logs>>
export const Event = {
Updated: BusEvent.define(
"background_process.updated",
Schema.Struct({
info: Info,
}),
),
Deleted: BusEvent.define(
"background_process.deleted",
Schema.Struct({
sessionID: SessionID,
processID: ID,
}),
),
}
type Active = {
ctx: InstanceContext
info: Info
proc: ChildProcess
start: StartInput
pattern?: RegExp
resolve?: (ready: boolean) => void
notify?: ReturnType<typeof setTimeout>
}
type State = {
ctx: InstanceContext
dir: string
processes: Map<ID, Active>
}
class StateService extends Context.Service<StateService, { readonly get: () => Effect.Effect<State> }>()(
"@kilocode/BackgroundProcess.State",
) {}
function clone(info: Info): Info {
return {
...info,
time: { ...info.time },
}
}
function terminal(status: Status) {
return status === "exited" || status === "failed" || status === "stopped"
}
function clamp(text: string) {
const buf = Buffer.from(text, "utf-8")
if (buf.length <= MAX) return text
let start = buf.length - MAX
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
return buf.subarray(start).toString("utf-8")
}
function publish(active: Active) {
Instance.restore(active.ctx, () => {
void Bus.publish(Event.Updated, { info: clone(active.info) }).catch((err) => {
log.warn("failed to publish process update", { err, id: active.info.id })
})
})
}
function schedule(active: Active) {
if (active.notify) return
active.notify = setTimeout(() => {
active.notify = undefined
publish(active)
}, PUBLISH_MS)
}
function ready(active: Active) {
if (active.info.ready) return
active.info.ready = true
active.info.status = "ready"
active.info.time.updated = Date.now()
active.resolve?.(true)
active.resolve = undefined
publish(active)
}
function append(active: Active, chunk: string) {
active.info.output = clamp(active.info.output + chunk)
active.info.time.updated = Date.now()
if (active.pattern?.test(active.info.output)) ready(active)
schedule(active)
}
function exited(active: Active, code: number | null, signal: NodeJS.Signals | null) {
if (terminal(active.info.status)) return
if (active.notify) clearTimeout(active.notify)
active.notify = undefined
active.info.exitCode = code
active.info.signal = signal
active.info.ready = active.info.ready && code === 0
active.info.status = active.info.status === "stopping" ? "stopped" : code === 0 ? "exited" : "failed"
active.info.time.updated = Date.now()
active.info.time.ended = active.info.time.updated
active.resolve?.(false)
active.resolve = undefined
publish(active)
}
function failed(active: Active, err: unknown) {
append(active, `\n${err instanceof Error ? err.message : String(err)}\n`)
exited(active, 1, null)
}
function connected(port: number) {
return new Promise<boolean>((resolve) => {
const socket = net.createConnection({ port, host: "127.0.0.1" })
const done = (ok: boolean) => {
socket.removeAllListeners()
socket.destroy()
resolve(ok)
}
socket.setTimeout(500)
socket.once("connect", () => done(true))
socket.once("error", () => done(false))
socket.once("timeout", () => done(false))
})
}
async function wait(active: Active, input: Ready) {
if (!input.pattern && !input.port) return false
if (input.pattern && active.pattern?.test(active.info.output)) {
ready(active)
return true
}
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
if (active.info.status === "starting") {
active.info.status = "running"
active.info.time.updated = Date.now()
publish(active)
}
active.resolve = undefined
resolve(false)
}, input.timeout ?? READY_MS)
active.resolve = (ok) => {
clearTimeout(timeout)
resolve(ok)
}
const poll = async () => {
if (!input.port) return
while (!terminal(active.info.status) && !active.info.ready && active.resolve) {
if (await connected(input.port)) {
ready(active)
return
}
await Bun.sleep(250)
}
}
void poll().catch((err) => {
log.warn("port readiness check failed", { err, id: active.info.id, port: input.port })
})
})
}
function env() {
const result: NodeJS.ProcessEnv = {
...process.env,
TERM: "dumb",
}
delete result.KILO_SERVER_PASSWORD
delete result.KILO_SERVER_USERNAME
return result
}
function stopped(proc: ChildProcess) {
return proc.exitCode !== null || proc.signalCode !== null
}
function waitExit(proc: ChildProcess, ms: number) {
if (stopped(proc)) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, ms)
function done() {
clearTimeout(timer)
proc.off("exit", done)
proc.off("error", done)
resolve()
}
proc.once("exit", done)
proc.once("error", done)
})
}
async function kill(active: Active) {
const pid = active.proc.pid
if (!pid || stopped(active.proc)) return
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const child = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
child.once("exit", () => resolve())
child.once("error", () => resolve())
})
return
}
try {
process.kill(-pid, "SIGTERM")
} catch (err) {
log.warn("failed to terminate process group", { err, pid })
active.proc.kill("SIGTERM")
}
await waitExit(active.proc, KILL_MS)
if (stopped(active.proc)) return
try {
process.kill(-pid, "SIGKILL")
} catch (err) {
log.warn("failed to kill process group", { err, pid })
active.proc.kill("SIGKILL")
}
}
async function terminate(state: State, active: Active, opts?: { remove?: boolean; silent?: boolean }) {
if (!terminal(active.info.status)) {
active.info.status = "stopping"
active.info.time.updated = Date.now()
if (!opts?.silent) publish(active)
await kill(active)
if (!terminal(active.info.status)) exited(active, active.proc.exitCode, active.proc.signalCode)
}
if (!opts?.remove) return
state.processes.delete(active.info.id)
if (active.notify) clearTimeout(active.notify)
active.resolve?.(false)
active.resolve = undefined
if (opts.silent) return
await Instance.restore(active.ctx, () =>
Bus.publish(Event.Deleted, { sessionID: active.info.sessionID, processID: active.info.id }).catch((err) => {
log.warn("failed to publish process deletion", { err, id: active.info.id })
}),
)
}
async function launch(state: State, input: StartInput, id = ID.ascending()) {
const sh = Shell.acceptable()
const cwd = path.resolve(state.dir, input.cwd ?? state.dir)
const pattern = input.ready?.pattern ? new RegExp(input.ready.pattern) : undefined
const args = Shell.args(sh, input.command, cwd)
const proc = spawn(sh, args, {
cwd,
env: env(),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: true,
})
const now = Date.now()
const active: Active = {
ctx: state.ctx,
info: {
id,
sessionID: input.sessionID,
pid: proc.pid,
command: input.command,
cwd,
description: input.description,
status: input.ready ? "starting" : "running",
ready: false,
output: "",
time: {
started: now,
updated: now,
},
},
proc,
start: { ...input, cwd },
pattern,
}
state.processes.set(id, active)
proc.stdout?.on("data", (chunk) => append(active, chunk.toString("utf-8")))
proc.stderr?.on("data", (chunk) => append(active, chunk.toString("utf-8")))
proc.once("error", (err) => failed(active, err))
proc.once("exit", (code, signal) => {
if (state.processes.get(id) !== active) return
exited(active, code, signal)
})
publish(active)
if (input.ready) await wait(active, input.ready)
return clone(active.info)
}
const stateLayer = Layer.effect(
StateService,
Effect.gen(function* () {
const ref = yield* InstanceState.make(
Effect.fn("BackgroundProcess.state")(function* (ctx) {
const state: State = { ctx, dir: ctx.directory, processes: new Map() }
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(
Array.from(state.processes.values()).map((active) => terminate(state, active, { silent: true })),
)
state.processes.clear()
}),
)
return state
}),
)
return StateService.of({ get: () => InstanceState.get(ref) })
}),
)
const runtime = makeRuntime(StateService, stateLayer)
function state() {
return runtime.runPromise((svc) => svc.get())
}
export async function start(input: StartInput) {
return launch(await state(), input)
}
export async function list(input?: { sessionID?: SessionID }) {
const current = await state()
return Array.from(current.processes.values())
.map((active) => clone(active.info))
.filter((info) => !input?.sessionID || info.sessionID === input.sessionID)
.toSorted((a, b) => a.time.started - b.time.started || a.id.localeCompare(b.id))
}
export async function get(id: ID) {
const current = await state()
const active = current.processes.get(id)
return active ? clone(active.info) : undefined
}
export async function logs(id: ID): Promise<Logs | undefined> {
const current = await state()
const active = current.processes.get(id)
if (!active) return
return { id: active.info.id, sessionID: active.info.sessionID, output: active.info.output }
}
export async function stop(id: ID) {
const current = await state()
const active = current.processes.get(id)
if (!active) return
await terminate(current, active)
return clone(active.info)
}
export async function restart(id: ID) {
const current = await state()
const active = current.processes.get(id)
if (!active) return
const input = active.start
await terminate(current, active, { remove: true })
return launch(current, input, id)
}
export async function stopSession(sessionID: SessionID) {
const current = await state()
const list = Array.from(current.processes.values()).filter((active) => active.info.sessionID === sessionID)
await Promise.all(list.map((active) => terminate(current, active, { remove: true })))
}
}
@@ -22,6 +22,7 @@ import { Link } from "@tui/ui/link"
import { isKiloError, showKiloErrorToast } from "@/kilocode/kilo-errors"
import { registerKiloCommands } from "@/kilocode/kilo-commands"
import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui"
import { DialogProcessList } from "@/kilocode/cli/cmd/tui/component/dialog-process-list"
// Re-export so upstream can render the route without importing directly
export { KiloClawView } from "@/kilocode/claw/view"
@@ -132,6 +133,7 @@ export function init() {
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const dialog = useDialog()
// Inject TUI dependencies for kilo-gateway
initializeTUIDependencies({
@@ -154,6 +156,16 @@ export function init() {
// Register auto-approve toggle
command.register(() => [
{
title: "Background processes",
description: "List and manage tracked background processes",
value: "background_process.list",
category: "Kilo",
slash: { name: "process", aliases: ["processes"] },
onSelect: () => {
dialog.replace(() => <DialogProcessList />)
},
},
{
get title() {
return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode"
@@ -0,0 +1,344 @@
import type { BackgroundProcessInfo } from "@kilocode/sdk/v2"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useKeybind } from "@tui/context/keybind"
import { useRoute } from "@tui/context/route"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { useTuiConfig } from "@tui/context/tui-config"
import { useToast } from "@tui/ui/toast"
import { getScrollAcceleration } from "@tui/util/scroll"
import { errorMessage } from "@/util/error"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { createMemo, createSignal, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi"
type Info = BackgroundProcessInfo
type Status = Info["status"]
type Scope = "session" | "all"
type Kind = "stop" | "restart"
type Theme = ReturnType<typeof useTheme>["theme"]
const stopKey = Keybind.parse("ctrl+x")[0]
const restartKey = Keybind.parse("ctrl+r")[0]
const allKey = Keybind.parse("ctrl+a")[0]
function terminal(status: Status) {
return status === "exited" || status === "failed" || status === "stopped"
}
function rank(status: Status) {
if (status === "starting" || status === "stopping") return 0
if (status === "ready" || status === "running") return 1
if (status === "failed") return 2
return 3
}
function tone(status: Status, theme: Theme) {
if (status === "ready" || status === "running") return theme.success
if (status === "starting" || status === "stopping") return theme.warning
if (status === "failed") return theme.error
return theme.textMuted
}
function label(item: Info) {
return item.description?.trim() || item.command
}
function last(item: Info) {
return stripAnsi(item.output).trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ""
}
function short(text: string, max = 64) {
return Locale.truncate(text, max)
}
function useActions() {
const sdk = useSDK()
const toast = useToast()
const [busy, setBusy] = createSignal<{ id: string; kind: Kind }>()
async function run(kind: Kind, item: Info) {
if (busy()) return
if (kind === "stop" && terminal(item.status)) return
setBusy({ id: item.id, kind })
try {
const result =
kind === "stop"
? await sdk.client.backgroundProcess.stop({ processID: item.id })
: await sdk.client.backgroundProcess.restart({ processID: item.id })
if (result.error) {
toast.show({
variant: "error",
title: kind === "stop" ? "Failed to stop process" : "Failed to restart process",
message: errorMessage(result.error),
})
}
} catch (err) {
toast.show({
variant: "error",
title: kind === "stop" ? "Failed to stop process" : "Failed to restart process",
message: errorMessage(err),
})
} finally {
setBusy(undefined)
}
}
return { busy, run }
}
function StatusMark(props: { status: Status }) {
const { theme } = useTheme()
return <text fg={tone(props.status, theme)}>*</text>
}
function Hint(props: { title: string; keys: string; disabled?: boolean; onClick?: () => void }) {
const { theme } = useTheme()
return (
<text fg={props.disabled ? theme.textMuted : theme.text} onMouseUp={() => !props.disabled && props.onClick?.()}>
<span style={{ attributes: TextAttributes.BOLD }}>{props.title}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.keys}</span>
</text>
)
}
function session(sync: ReturnType<typeof useSync>, id: string) {
return sync.data.session.find((item) => item.id === id)?.title
}
function sort(list: Info[]) {
return list.toSorted(
(a, b) => rank(a.status) - rank(b.status) || b.time.updated - a.time.updated || a.id.localeCompare(b.id),
)
}
function all(sync: ReturnType<typeof useSync>) {
return Object.values(sync.data.background_process).flat()
}
export function DialogProcessList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const actions = useActions()
const sid = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const [scope, setScope] = createSignal<Scope>(sid() ? "session" : "all")
const mode = createMemo<Scope>(() => (scope() === "session" && sid() ? "session" : "all"))
const list = createMemo(() => {
const current = sid()
const items = mode() === "session" && current ? (sync.data.background_process[current] ?? []) : all(sync)
return sort(items)
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const busy = actions.busy()
return list().map((item) => {
const title = label(item)
const tail = last(item)
const note = mode() === "all" ? session(sync, item.sessionID) : undefined
const footer =
busy?.id === item.id
? `${busy.kind === "stop" ? "stopping" : "restarting"}...`
: `${item.status}${item.pid ? ` pid:${item.pid}` : ""}`
const command = note ? `(${short(note, 28)}) ${short(item.command, 40)}` : short(item.command, 58)
return {
title: short(title, 61),
value: item.id,
description: tail ? `${command} > ${short(tail, 32)}` : command,
footer,
category: item.status,
gutter: () => <StatusMark status={item.status} />,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={mode() === "session" ? "Background Processes (current session)" : "Background Processes (all sessions)"}
options={options()}
skipFilter={options().length === 0}
onSelect={(option) => {
dialog.replace(() => (
<DialogProcessDetail id={option.value} back={() => dialog.replace(() => <DialogProcessList />)} />
))
}}
keybind={[
{
keybind: stopKey,
title: "stop",
onTrigger: (option) => {
const item = list().find((proc) => proc.id === option.value)
if (item) void actions.run("stop", item)
},
},
{
keybind: restartKey,
title: "restart",
onTrigger: (option) => {
const item = list().find((proc) => proc.id === option.value)
if (item) void actions.run("restart", item)
},
},
{
keybind: allKey,
title: mode() === "session" ? "all" : "current",
disabled: !sid(),
side: "right",
onTrigger: () => {
setScope((value) => (value === "session" ? "all" : "session"))
},
},
]}
/>
)
}
function DialogProcessDetail(props: { id: string; back: () => void }) {
const dialog = useDialog()
const sync = useSync()
const keybind = useKeybind()
const actions = useActions()
const dimensions = useTerminalDimensions()
const config = useTuiConfig()
const { theme } = useTheme()
const item = createMemo(() => all(sync).find((proc) => proc.id === props.id))
const output = createMemo(() => stripAnsi(item()?.output ?? ""))
const height = createMemo(() => Math.max(4, Math.floor(dimensions().height / 2) - 14))
const busy = createMemo(() => actions.busy()?.id === props.id)
const stopped = createMemo(() => {
const proc = item()
return proc ? terminal(proc.status) : true
})
const scroll = createMemo(() => getScrollAcceleration(config))
let box: ScrollBoxRenderable | undefined
onMount(() => {
dialog.setSize("large")
})
useKeyboard((evt) => {
if (evt.defaultPrevented) return
const proc = item()
if (keybind.match("backspace", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.back()
return
}
if (keybind.match("ctrl+x", evt)) {
evt.preventDefault()
evt.stopPropagation()
if (proc) void actions.run("stop", proc)
return
}
if (keybind.match("ctrl+r", evt)) {
evt.preventDefault()
evt.stopPropagation()
if (proc) void actions.run("restart", proc)
return
}
if (evt.name === "pageup") {
evt.preventDefault()
evt.stopPropagation()
box?.scrollBy(-height())
return
}
if (evt.name === "pagedown") {
evt.preventDefault()
evt.stopPropagation()
box?.scrollBy(height())
}
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{item() ? short(label(item()!), 92) : "Background Process"}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={item()} fallback={<text fg={theme.textMuted}>Process is no longer tracked.</text>}>
{(proc) => (
<>
<box>
<text fg={theme.textMuted}>
status{" "}
<span style={{ fg: tone(proc().status, theme), attributes: TextAttributes.BOLD }}>{proc().status}</span>
{proc().pid ? ` pid ${proc().pid}` : ""}
{proc().exitCode !== undefined ? ` exit ${proc().exitCode}` : ""}
{proc().signal ? ` signal ${proc().signal}` : ""}
</text>
<text fg={theme.textMuted}>started {Locale.datetime(proc().time.started)}</text>
<text fg={theme.textMuted}>updated {Locale.datetime(proc().time.updated)}</text>
<Show when={proc().time.ended}>
{(ended) => <text fg={theme.textMuted}>ended {Locale.datetime(ended())}</text>}
</Show>
<text fg={theme.textMuted} wrapMode="word">
cwd {proc().cwd}
</text>
<text fg={theme.textMuted} wrapMode="word">
command {proc().command}
</text>
</box>
<box>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Output Buffer
</text>
<scrollbox
ref={(ref: ScrollBoxRenderable) => (box = ref)}
height={height()}
scrollAcceleration={scroll()}
stickyScroll={true}
stickyStart="bottom"
verticalScrollbarOptions={{ visible: true }}
>
<Show when={output()} fallback={<text fg={theme.textMuted}>No output yet</text>}>
{(text) => (
<text fg={theme.text} wrapMode="word">
{text()}
</text>
)}
</Show>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between" paddingTop={1}>
<box flexDirection="row" gap={2}>
<Hint title="back" keys={keybind.print("backspace")} onClick={props.back} />
<Hint
title={busy() ? "stopping" : "stop"}
keys={Keybind.toString(stopKey)}
disabled={busy() || stopped()}
onClick={() => void actions.run("stop", proc())}
/>
<Hint
title={busy() ? "restarting" : "restart"}
keys={Keybind.toString(restartKey)}
disabled={busy()}
onClick={() => void actions.run("restart", proc())}
/>
</box>
<text fg={theme.textMuted}>pageup/pagedown scroll</text>
</box>
</>
)}
</Show>
</box>
)
}
+3 -16
View File
@@ -14,11 +14,7 @@ const modern = {
`████🬺🬏 ██ ██ ██ ██ ██ ██ ██ `,
`██ ██ ██████ 🬁🬬████ 🬁🬬██ 🬁🬬████ 🬁🬬████ ██████ `,
],
exit: [
` ██ ██ ██🬺🬏 ██ ██ ██🬺🬏 `,
` ████🬺🬏 ██ ██ ██ ██ `,
` ██ ██ ██████ 🬁🬬████ 🬁🬬██ `,
],
exit: [` ██ ██ ██🬺🬏 ██ ██ ██🬺🬏 `, ` ████🬺🬏 ██ ██ ██ ██ `, ` ██ ██ ██████ 🬁🬬████ 🬁🬬██ `],
}
const fallback = {
@@ -33,11 +29,7 @@ const fallback = {
`████ ██ ██ ██ ██ ██ ██ ██ `,
`██ ██ ██████ ██████ ██ ████ ██████ ██████ `,
],
exit: [
` ██ ██ ████ ██ ██ ██ `,
` ████ ██ ██ ██ ██ `,
` ██ ██ ██████ ██████ ██ `,
],
exit: [` ██ ██ ████ ██ ██ ██ `, ` ████ ██ ██ ██ ██ `, ` ██ ██ ██████ ██████ ██ `],
}
function flag(value: string | undefined) {
@@ -77,10 +69,5 @@ export function session(
platform = process.platform,
) {
const logo = supports(env, platform) ? modern.exit : fallback.exit
return [
``,
`${logo[0]}${dim}${title}${normal}`,
`${logo[1]}${dim}kilo -s ${id}${normal}`,
logo[2],
].join("\n")
return [``, `${logo[0]}${dim}${title}${normal}`, `${logo[1]}${dim}kilo -s ${id}${normal}`, logo[2]].join("\n")
}
@@ -0,0 +1,83 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiSidebarBackgroundProcessItem } from "@kilocode/plugin/tui"
import { createMemo, createSignal, For, Show } from "solid-js"
const id = "internal:kilo-sidebar-background-processes"
function short(text: string, max = 34) {
if (text.length <= max) return text
return text.slice(0, max - 3) + "..."
}
function last(text: string) {
return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ""
}
function tone(item: TuiSidebarBackgroundProcessItem, api: TuiPluginApi) {
const theme = api.theme.current
if (item.status === "ready" || item.status === "running") return theme.success
if (item.status === "starting" || item.status === "stopping") return theme.warning
if (item.status === "failed") return theme.error
return theme.textMuted
}
function label(item: TuiSidebarBackgroundProcessItem) {
return item.description || item.command
}
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.session.processes(props.session_id))
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>Background Processes</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box>
<text fg={theme().textMuted} wrapMode="none">
<span style={{ fg: tone(item, props.api) }}>●</span> {short(label(item))}
</text>
<text fg={theme().textMuted}>
{item.status}
{item.pid ? ` pid:${item.pid}` : ""}
</text>
<text fg={theme().textMuted}>{short(item.command)}</text>
<Show when={last(item.output)}>
<text fg={theme().textMuted}>{"> " + short(last(item.output))}</text>
</Show>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 250,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -0,0 +1,120 @@
import { BackgroundProcess } from "@/kilocode/background-process"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/background-process"
export const BackgroundProcessPaths = {
list: root,
create: root,
get: `${root}/:processID`,
logs: `${root}/:processID/logs`,
stop: `${root}/:processID/stop`,
restart: `${root}/:processID/restart`,
stopSession: `${root}/session/:sessionID/stop`,
} as const
export const Params = Schema.Struct({ processID: BackgroundProcess.ID })
export const SessionParams = Schema.Struct({ sessionID: SessionID })
export const BackgroundProcessApi = HttpApi.make("background-process")
.add(
HttpApiGroup.make("background-process")
.add(
HttpApiEndpoint.get("list", BackgroundProcessPaths.list, {
success: described(Schema.Array(BackgroundProcess.Info), "List of background processes"),
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.list",
summary: "List background processes",
description: "List tracked background processes for the current instance.",
}),
),
HttpApiEndpoint.post("create", BackgroundProcessPaths.create, {
payload: BackgroundProcess.StartInput,
success: described(BackgroundProcess.Info, "Created background process"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.create",
summary: "Create background process",
description: "Start a tracked background process.",
}),
),
HttpApiEndpoint.get("get", BackgroundProcessPaths.get, {
params: { processID: BackgroundProcess.ID },
success: described(BackgroundProcess.Info, "Background process info"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.get",
summary: "Get background process",
description: "Get status and retained output for one background process.",
}),
),
HttpApiEndpoint.get("logs", BackgroundProcessPaths.logs, {
params: { processID: BackgroundProcess.ID },
success: described(BackgroundProcess.Logs, "Background process logs"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.logs",
summary: "Get background process logs",
description: "Get the retained output tail for one background process.",
}),
),
HttpApiEndpoint.post("stop", BackgroundProcessPaths.stop, {
params: { processID: BackgroundProcess.ID },
success: described(BackgroundProcess.Info, "Stopped background process"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.stop",
summary: "Stop background process",
description: "Terminate a background process and its child process tree.",
}),
),
HttpApiEndpoint.post("restart", BackgroundProcessPaths.restart, {
params: { processID: BackgroundProcess.ID },
success: described(BackgroundProcess.Info, "Restarted background process"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.restart",
summary: "Restart background process",
description: "Stop and restart a background process with its original command.",
}),
),
HttpApiEndpoint.post("stopSession", BackgroundProcessPaths.stopSession, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Stopped session background processes"),
}).annotateMerge(
OpenApi.annotations({
identifier: "backgroundProcess.stopSession",
summary: "Stop session background processes",
description: "Terminate and forget all background processes associated with one session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "background-process",
description: "Kilo background process routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "kilo HttpApi",
version: "0.0.1",
description: "Kilo HttpApi surface.",
}),
)
@@ -0,0 +1,69 @@
import { BackgroundProcess } from "@/kilocode/background-process"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { SessionID } from "@/session/schema"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
const missing = () => new HttpApiError.NotFound({})
export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, "background-process", (handlers) =>
Effect.gen(function* () {
const list = Effect.fn("BackgroundProcessHttpApi.list")(function* () {
return yield* Effect.promise(() => BackgroundProcess.list())
})
const create = Effect.fn("BackgroundProcessHttpApi.create")(function* (ctx: {
payload: BackgroundProcess.StartInput
}) {
return yield* Effect.promise(() => BackgroundProcess.start(ctx.payload))
})
const get = Effect.fn("BackgroundProcessHttpApi.get")(function* (ctx: {
params: { processID: BackgroundProcess.ID }
}) {
const info = yield* Effect.promise(() => BackgroundProcess.get(ctx.params.processID))
if (!info) return yield* missing()
return info
})
const logs = Effect.fn("BackgroundProcessHttpApi.logs")(function* (ctx: {
params: { processID: BackgroundProcess.ID }
}) {
const info = yield* Effect.promise(() => BackgroundProcess.logs(ctx.params.processID))
if (!info) return yield* missing()
return info
})
const stop = Effect.fn("BackgroundProcessHttpApi.stop")(function* (ctx: {
params: { processID: BackgroundProcess.ID }
}) {
const info = yield* Effect.promise(() => BackgroundProcess.stop(ctx.params.processID))
if (!info) return yield* missing()
return info
})
const restart = Effect.fn("BackgroundProcessHttpApi.restart")(function* (ctx: {
params: { processID: BackgroundProcess.ID }
}) {
const info = yield* Effect.promise(() => BackgroundProcess.restart(ctx.params.processID))
if (!info) return yield* missing()
return info
})
const stopSession = Effect.fn("BackgroundProcessHttpApi.stopSession")(function* (ctx: {
params: { sessionID: SessionID }
}) {
yield* Effect.promise(() => BackgroundProcess.stopSession(ctx.params.sessionID))
return true
})
return handlers
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("logs", logs)
.handle("stop", stop)
.handle("restart", restart)
.handle("stopSession", stopSession)
}),
)
@@ -1,5 +1,6 @@
import type { Context } from "effect"
import type { Hono } from "hono"
import { BackgroundProcessPaths } from "./groups/background-process"
import { IndexingPaths } from "./groups/indexing"
import { KiloGatewayPaths } from "./groups/kilo-gateway"
import { KilocodePaths } from "./groups/kilocode"
@@ -12,6 +13,13 @@ import { TelemetryPaths } from "./groups/telemetry"
type Handler = (request: Request, context: Context.Context<unknown>) => Promise<Response>
export function register(app: Hono, handler: Handler, context: Context.Context<unknown>) {
app.get(BackgroundProcessPaths.list, (c) => handler(c.req.raw, context))
app.post(BackgroundProcessPaths.create, (c) => handler(c.req.raw, context))
app.get(BackgroundProcessPaths.get, (c) => handler(c.req.raw, context))
app.get(BackgroundProcessPaths.logs, (c) => handler(c.req.raw, context))
app.post(BackgroundProcessPaths.stop, (c) => handler(c.req.raw, context))
app.post(BackgroundProcessPaths.restart, (c) => handler(c.req.raw, context))
app.post(BackgroundProcessPaths.stopSession, (c) => handler(c.req.raw, context))
app.post("/permission/allow-everything", (c) => handler(c.req.raw, context))
app.post("/enhance-prompt", (c) => handler(c.req.raw, context))
app.post("/commit-message", (c) => handler(c.req.raw, context))
@@ -1,5 +1,6 @@
import { Layer } from "effect"
import { backgroundProcessHandlers } from "./handlers/background-process"
import { commitMessageHandlers } from "./handlers/commit-message"
import { enhancePromptHandlers } from "./handlers/enhance-prompt"
import { indexingHandlers } from "./handlers/indexing"
@@ -12,6 +13,7 @@ import { suggestionHandlers } from "./handlers/suggestion"
import { telemetryHandlers } from "./handlers/telemetry"
export const provide = Layer.provide([
backgroundProcessHandlers,
commitMessageHandlers,
enhancePromptHandlers,
indexingHandlers,
@@ -14,6 +14,7 @@ import { RemoteRoutes } from "../../server/routes/instance/remote"
import { NetworkRoutes } from "../../server/routes/instance/network"
import { SuggestionRoutes } from "../suggestion/routes"
import { IndexingRoutes } from "./routes/indexing"
import { BackgroundProcessRoutes } from "./routes/background-process"
import { createKiloRoutes } from "@kilocode/kilo-gateway"
import { Auth } from "../../auth"
import { errors } from "../../server/error"
@@ -28,6 +29,7 @@ import { Bus } from "@/bus"
export function register(app: Hono): Hono {
return app
.route("/background-process", BackgroundProcessRoutes())
.route("/permission", PermissionKilocodeRoutes())
.route("/network", NetworkRoutes())
.route("/indexing", IndexingRoutes()) // kilocode_change
@@ -0,0 +1,147 @@
import { BackgroundProcess } from "@/kilocode/background-process"
import { errors } from "@/server/error"
import { NotFoundError } from "@/storage/storage"
import { SessionID } from "@/session/schema"
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { lazy } from "@/util/lazy"
import z from "zod"
export const BackgroundProcessRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List background processes",
description: "List tracked background processes for the current instance.",
operationId: "backgroundProcess.list",
responses: {
200: {
description: "List of background processes",
content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod.array()) } },
},
},
}),
async (c) => c.json(await BackgroundProcess.list()),
)
.post(
"/",
describeRoute({
summary: "Create background process",
description: "Start a tracked background process.",
operationId: "backgroundProcess.create",
responses: {
200: {
description: "Created background process",
content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } },
},
...errors(400),
},
}),
validator("json", BackgroundProcess.StartInput.zod),
async (c) => c.json(await BackgroundProcess.start(c.req.valid("json") as BackgroundProcess.StartInput)),
)
.get(
"/:processID",
describeRoute({
summary: "Get background process",
description: "Get status and retained output for one background process.",
operationId: "backgroundProcess.get",
responses: {
200: {
description: "Background process info",
content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } },
},
...errors(404),
},
}),
validator("param", z.object({ processID: BackgroundProcess.ID.zod })),
async (c) => {
const info = await BackgroundProcess.get(c.req.valid("param").processID)
if (!info) throw new NotFoundError({ message: "Background process not found" })
return c.json(info)
},
)
.get(
"/:processID/logs",
describeRoute({
summary: "Get background process logs",
description: "Get the retained output tail for one background process.",
operationId: "backgroundProcess.logs",
responses: {
200: {
description: "Background process logs",
content: { "application/json": { schema: resolver(BackgroundProcess.Logs.zod) } },
},
...errors(404),
},
}),
validator("param", z.object({ processID: BackgroundProcess.ID.zod })),
async (c) => {
const info = await BackgroundProcess.logs(c.req.valid("param").processID)
if (!info) throw new NotFoundError({ message: "Background process not found" })
return c.json(info)
},
)
.post(
"/:processID/stop",
describeRoute({
summary: "Stop background process",
description: "Terminate a background process and its child process tree.",
operationId: "backgroundProcess.stop",
responses: {
200: {
description: "Stopped background process",
content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } },
},
...errors(404),
},
}),
validator("param", z.object({ processID: BackgroundProcess.ID.zod })),
async (c) => {
const info = await BackgroundProcess.stop(c.req.valid("param").processID)
if (!info) throw new NotFoundError({ message: "Background process not found" })
return c.json(info)
},
)
.post(
"/:processID/restart",
describeRoute({
summary: "Restart background process",
description: "Stop and restart a background process with its original command.",
operationId: "backgroundProcess.restart",
responses: {
200: {
description: "Restarted background process",
content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } },
},
...errors(404),
},
}),
validator("param", z.object({ processID: BackgroundProcess.ID.zod })),
async (c) => {
const info = await BackgroundProcess.restart(c.req.valid("param").processID)
if (!info) throw new NotFoundError({ message: "Background process not found" })
return c.json(info)
},
)
.post(
"/session/:sessionID/stop",
describeRoute({
summary: "Stop session background processes",
description: "Terminate and forget all background processes associated with one session.",
operationId: "backgroundProcess.stopSession",
responses: {
200: {
description: "Stopped session background processes",
content: { "application/json": { schema: resolver(z.boolean()) } },
},
},
}),
validator("param", z.object({ sessionID: SessionID.zod })),
async (c) => {
await BackgroundProcess.stopSession(c.req.valid("param").sessionID)
return c.json(true)
},
),
)
@@ -0,0 +1,159 @@
import { BackgroundProcess } from "@/kilocode/background-process"
import { Tool } from "@/tool/tool"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { containsPath } from "@/project/instance-context"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Schema } from "effect"
import DESCRIPTION from "./background-process.txt"
import path from "path"
const Action = Schema.Literals(["start", "list", "status", "logs", "stop", "restart"])
type Action = Schema.Schema.Type<typeof Action>
export const Params = Schema.Struct({
action: Action.annotate({ description: "Operation to perform" }),
command: Schema.optional(Schema.String).annotate({
description: "Required for start. Command to run as a tracked background process.",
}),
id: Schema.optional(BackgroundProcess.ID.annotate({ description: "Required for status, logs, stop, and restart" })),
workdir: Schema.optional(Schema.String).annotate({
description: "Working directory for start. Defaults to the project directory.",
}),
description: Schema.optional(Schema.String).annotate({ description: "Short label shown in the sidebar" }),
ready: Schema.optional(BackgroundProcess.Ready).annotate({ description: "Optional readiness probe for start" }),
}).check(
Schema.makeFilter((params: { action: Action; command?: string; id?: BackgroundProcess.ID }) => {
if (params.action === "start") {
if (params.command?.trim()) return undefined
return "command is required when action is start"
}
if (params.action === "list") return undefined
if (params.id) return undefined
return "id is required when action is status, logs, stop, or restart"
}),
)
export type Params = Schema.Schema.Type<typeof Params>
type Meta = {
processID?: BackgroundProcess.ID
status?: BackgroundProcess.Status
count?: number
}
function title(info: BackgroundProcess.Info) {
return info.description ?? info.command
}
function last(text: string) {
const lines = text.trim().split(/\r?\n/).filter(Boolean)
return lines.at(-1) ?? ""
}
function format(info: BackgroundProcess.Info) {
return [
`id: ${info.id}`,
`status: ${info.status}`,
info.pid ? `pid: ${info.pid}` : undefined,
`cwd: ${info.cwd}`,
`command: ${info.command}`,
last(info.output) ? `last_output: ${last(info.output)}` : undefined,
]
.filter(Boolean)
.join("\n")
}
function invalid(action: Action, message: string) {
return {
title: "Invalid background process input",
output: `${message} for action: ${action}`,
metadata: {},
}
}
export const BackgroundProcessTool = Tool.define<typeof Params, Meta, never, "background_process">(
"background_process",
Effect.succeed({
description: DESCRIPTION,
parameters: Params,
execute: (params, ctx) =>
Effect.gen(function* () {
if (params.action === "list") {
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID: ctx.sessionID }))
return {
title: "Background processes",
output: list.length
? list.map(format).join("\n\n")
: "No background processes are running for this session.",
metadata: { count: list.length },
}
}
if (params.action !== "start") {
const id = params.id
if (!id) return invalid(params.action, "Missing id")
const info =
params.action === "stop"
? yield* Effect.promise(() => BackgroundProcess.stop(id))
: params.action === "restart"
? yield* Effect.promise(() => BackgroundProcess.restart(id))
: yield* Effect.promise(() => BackgroundProcess.get(id))
if (!info) {
return {
title: "Background process not found",
output: `Background process not found: ${id}`,
metadata: { processID: id },
}
}
if (params.action === "logs") {
const logs = yield* Effect.promise(() => BackgroundProcess.logs(id))
return {
title: `Logs: ${title(info)}`,
output: logs?.output || "(no output)",
metadata: { processID: info.id, status: info.status },
}
}
return {
title: `${params.action}: ${title(info)}`,
output: format(info),
metadata: { processID: info.id, status: info.status },
}
}
const command = params.command
if (!command?.trim()) return invalid(params.action, "Missing command")
const inst = yield* InstanceState.context
const cwd = path.resolve(inst.directory, params.workdir ?? inst.directory)
if (!containsPath(cwd, inst)) {
const pattern =
process.platform === "win32" ? AppFileSystem.normalizePathPattern(path.join(cwd, "*")) : path.join(cwd, "*")
yield* ctx.ask({
permission: "external_directory",
patterns: [pattern],
always: [pattern],
metadata: { command, access: "unknown" },
})
}
yield* ctx.ask({
permission: "bash",
patterns: [command],
always: [command.split(/\s+/, 1)[0] + " *"],
metadata: { command, description: params.description, action: "start", backgroundProcess: true },
})
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID: ctx.sessionID,
command,
cwd,
description: params.description,
ready: params.ready,
}),
)
return {
title: `Started: ${title(info)}`,
output: format(info),
metadata: { processID: info.id, status: info.status },
}
}),
}),
)
@@ -0,0 +1,20 @@
Run and manage long-running background processes for the current session.
Use this tool for development servers, file watchers, local services, and commands that are expected to keep running, such as `npm run dev`, `next dev`, `vite`, `bun --watch`, or test watchers.
Do not use the shell tool with `&`, `nohup`, `disown`, `setsid`, `Start-Process`, or similar backgrounding patterns. Processes started with this tool are tracked, shown in the CLI sidebar, and automatically stopped when the user switches sessions or exits the TUI.
Actions:
- `start`: start a new background process. Include `command`, optional `workdir`, optional `description`, and optional `ready` detection.
- `list`: list background processes for this session.
- `status`: inspect one process by `id`.
- `logs`: return the retained tail output for one process.
- `stop`: terminate one process and its child process tree.
- `restart`: stop and restart one process with its original command.
Only include `id` for `status`, `logs`, `stop`, and `restart`. Do not invent or pass an `id` when starting a process.
Readiness:
- Use `ready.pattern` when the process prints a recognizable line like `ready`, `Local:`, or `started server`.
- Use `ready.port` when a local server should accept TCP connections on a known port.
- If readiness is not known, omit `ready`; the process is returned as running immediately.
@@ -2,6 +2,7 @@
import { CodebaseSearchTool } from "../../tool/warpgrep"
import { RecallTool } from "../../tool/recall"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import * as Tool from "../../tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
@@ -23,18 +24,23 @@ export namespace KiloToolRegistry {
const codebase = yield* CodebaseSearchTool
const recall = yield* RecallTool
const manager = yield* AgentManagerTool
return { codebase, recall, manager }
const process = yield* BackgroundProcessTool
return { codebase, recall, manager, process }
})
}
/** 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 }, deps: Deps) {
export function build(
tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info },
deps: Deps,
) {
return Effect.gen(function* () {
const base = yield* Effect.all({
codebase: Tool.init(tools.codebase),
recall: Tool.init(tools.recall),
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
})
const semantic = yield* semanticTool(deps)
return { ...base, semantic }
@@ -76,13 +82,14 @@ 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 },
tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def },
cfg: { experimental?: { codebase_search?: boolean; agent_manager_tool?: boolean } },
): Tool.Def[] {
return [
...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []),
...(tools.semantic ? [tools.semantic] : []),
tools.recall,
...(Flag.KILO_CLIENT === "cli" ? [tools.process] : []),
// The extension is the only client that can consume the Agent Manager start event.
...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.agent_manager_tool === true ? [tools.manager] : []),
]
@@ -22,6 +22,7 @@ import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2"
// kilocode_change start - Kilo HttpApi groups
import { CommitMessageApi } from "@/kilocode/server/httpapi/groups/commit-message"
import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/background-process"
import { EnhancePromptApi } from "@/kilocode/server/httpapi/groups/enhance-prompt"
import { IndexingApi } from "@/kilocode/server/httpapi/groups/indexing"
import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway"
@@ -57,6 +58,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
// kilocode_change start - Kilo HttpApi groups
.addHttpApi(BackgroundProcessApi)
.addHttpApi(CommitMessageApi)
.addHttpApi(EnhancePromptApi)
.addHttpApi(IndexingApi)
+2
View File
@@ -32,6 +32,7 @@ import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
// kilocode_change start - legacy promise helpers + kilocode extensions
import { makeRuntime } from "@/effect/run-service"
import { BackgroundProcess } from "@/kilocode/background-process"
import { KiloSession, kiloSessionFork } from "@/kilocode/session"
import { fn } from "@/util/fn"
// kilocode_change end
@@ -605,6 +606,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore)
KiloSession.clearPlatformOverride(sessionID)
if (hasInstance) {
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}),
)
@@ -285,6 +285,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []),
todo: opts.state?.session?.todo ?? (() => []),
processes: opts.state?.session?.processes ?? (() => []),
messages: opts.state?.session?.messages ?? (() => []),
status: opts.state?.session?.status ?? (() => undefined),
permission: opts.state?.session?.permission ?? (() => []),
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { Params } from "@/kilocode/tool/background-process"
import { toJsonSchema } from "@/util/effect-zod"
const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input))
describe("BackgroundProcessTool", () => {
test("emits a root object JSON schema", () => {
const json = toJsonSchema(Params) as { type?: unknown; anyOf?: unknown; properties?: Record<string, unknown> }
expect(json.type).toBe("object")
expect(json.anyOf).toBeUndefined()
expect(json.properties?.action).toEqual(
expect.objectContaining({ enum: ["start", "list", "status", "logs", "stop", "restart"] }),
)
})
test("validates action-specific required fields", () => {
expect(accepts({ action: "list" })).toBe(true)
expect(accepts({ action: "start", command: "bun run dev", ready: { pattern: "ready" } })).toBe(true)
expect(accepts({ action: "start" })).toBe(false)
expect(accepts({ action: "stop", id: "bgp01" })).toBe(true)
expect(accepts({ action: "stop" })).toBe(false)
})
})
@@ -0,0 +1,92 @@
import { describe, expect } from "bun:test"
import { Bus } from "@/bus"
import { BackgroundProcess } from "@/kilocode/background-process"
import { SessionID } from "@/session/schema"
import { Effect } from "effect"
import { TestInstance } from "../fixture/fixture"
import { it } from "../lib/effect"
function update(sessionID: SessionID) {
const state: { off?: () => void; timer?: ReturnType<typeof setTimeout> } = {}
const promise = new Promise<BackgroundProcess.Info>((resolve, reject) => {
state.timer = setTimeout(() => {
state.off?.()
reject(new Error("timed out waiting for process update"))
}, 5_000)
state.off = Bus.subscribe(BackgroundProcess.Event.Updated, (event) => {
const info = event.properties.info
if (info.sessionID !== sessionID) return
if (!info.output.includes("tick")) return
state.off?.()
if (state.timer) clearTimeout(state.timer)
resolve(info)
})
})
return {
promise,
dispose() {
state.off?.()
if (state.timer) clearTimeout(state.timer)
},
}
}
describe("BackgroundProcess", () => {
it.instance("starts, reports readiness, and stops a process", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = "printf 'ready\\n'; while true; do sleep 1; done"
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID,
command,
cwd: test.directory,
description: "test server",
ready: { pattern: "ready", timeout: 5_000 },
}),
)
expect(info.status).toBe("ready")
expect(info.output).toContain("ready")
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
expect(list.map((item) => item.id)).toContain(info.id)
const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id))
expect(stopped?.status).toBe("stopped")
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
expect(next).toEqual([])
}),
)
it.instance("publishes output updates from process callbacks", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = "printf 'ready\n'; sleep 0.2; printf 'tick\n'; while true; do sleep 1; done"
const wait = update(sessionID)
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID,
command,
cwd: test.directory,
ready: { pattern: "ready", timeout: 5_000 },
}),
)
try {
const event = yield* Effect.promise(() => wait.promise)
expect(event.id).toBe(info.id)
expect(event.output).toContain("tick")
} finally {
wait.dispose()
yield* Effect.promise(() => BackgroundProcess.stop(info.id))
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
}
}),
)
})
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process"
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental"
import { PublicApi } from "../../../src/server/routes/instance/httpapi/public"
@@ -79,6 +80,13 @@ describe("Kilo HttpApi bridge", () => {
const hono = new Set(openApiRouteKeys(await Server.openapiHono()))
const effect = new Set(openApiRouteKeys(effectOpenApi()))
const kilo = [
`GET ${BackgroundProcessPaths.list}`,
`POST ${BackgroundProcessPaths.create}`,
"GET /background-process/{processID}",
"GET /background-process/{processID}/logs",
"POST /background-process/{processID}/stop",
"POST /background-process/{processID}/restart",
"POST /background-process/session/{sessionID}/stop",
"POST /permission/allow-everything",
"POST /enhance-prompt",
"POST /commit-message",
@@ -189,16 +189,21 @@ describe("kilocode tool registry indexing", () => {
semantic: def("semantic_search"),
recall: def("recall"),
manager: def("agent_manager"),
process: def("background_process"),
}
try {
process.env["KILO_CLIENT"] = "cli"
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"])
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual([
"semantic_search",
"recall",
"background_process",
])
expect(
KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, agent_manager_tool: true } }).map(
(tool) => tool.id,
),
).toEqual(["codebase_search", "semantic_search", "recall"])
).toEqual(["codebase_search", "semantic_search", "recall", "background_process"])
process.env["KILO_CLIENT"] = "vscode"
expect(
+2 -1
View File
@@ -22,7 +22,8 @@ describe("ServerAuth", () => {
expect(ServerAuth.headers()).toBeUndefined()
})
test("defaults to the kilo username", () => { // kilocode_change
test("defaults to the kilo username", () => {
// kilocode_change
Flag.KILO_SERVER_PASSWORD = "secret"
Flag.KILO_SERVER_USERNAME = undefined
+7
View File
@@ -12,6 +12,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
BackgroundProcessInfo,
TextPart,
Config as SdkConfig,
} from "@kilocode/sdk/v2"
@@ -275,6 +276,7 @@ export type TuiState = {
count: () => number
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
processes: (sessionID: string) => ReadonlyArray<TuiSidebarBackgroundProcessItem>
messages: (sessionID: string) => ReadonlyArray<Message>
status: (sessionID: string) => SessionStatus | undefined
permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
@@ -312,6 +314,11 @@ export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
export type TuiSidebarBackgroundProcessItem = Pick<
BackgroundProcessInfo,
"id" | "pid" | "command" | "cwd" | "description" | "status" | "output"
>
export type TuiSidebarFileItem = {
file: string
additions: number
+267
View File
@@ -13,6 +13,19 @@ import type {
AuthRemoveResponses,
AuthSetErrors,
AuthSetResponses,
BackgroundProcessCreateErrors,
BackgroundProcessCreateResponses,
BackgroundProcessGetErrors,
BackgroundProcessGetResponses,
BackgroundProcessListResponses,
BackgroundProcessLogsErrors,
BackgroundProcessLogsResponses,
BackgroundProcessRestartErrors,
BackgroundProcessRestartResponses,
BackgroundProcessStartInput,
BackgroundProcessStopErrors,
BackgroundProcessStopResponses,
BackgroundProcessStopSessionResponses,
CommandListResponses,
CommitMessageGenerateErrors,
CommitMessageGenerateResponses,
@@ -4962,6 +4975,255 @@ export class Tui extends HeyApiClient {
}
}
export class BackgroundProcess extends HeyApiClient {
/**
* List background processes
*
* List tracked background processes for the current instance.
*/
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<BackgroundProcessListResponses, unknown, ThrowOnError>({
url: "/background-process",
...options,
...params,
})
}
/**
* Create background process
*
* Start a tracked background process.
*/
public create<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
backgroundProcessStartInput?: BackgroundProcessStartInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "backgroundProcessStartInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<
BackgroundProcessCreateResponses,
BackgroundProcessCreateErrors,
ThrowOnError
>({
url: "/background-process",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Get background process
*
* Get status and retained output for one background process.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
processID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "processID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
BackgroundProcessGetResponses,
BackgroundProcessGetErrors,
ThrowOnError
>({
url: "/background-process/{processID}",
...options,
...params,
})
}
/**
* Get background process logs
*
* Get the retained output tail for one background process.
*/
public logs<ThrowOnError extends boolean = false>(
parameters: {
processID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "processID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
BackgroundProcessLogsResponses,
BackgroundProcessLogsErrors,
ThrowOnError
>({
url: "/background-process/{processID}/logs",
...options,
...params,
})
}
/**
* Stop background process
*
* Terminate a background process and its child process tree.
*/
public stop<ThrowOnError extends boolean = false>(
parameters: {
processID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "processID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<
BackgroundProcessStopResponses,
BackgroundProcessStopErrors,
ThrowOnError
>({
url: "/background-process/{processID}/stop",
...options,
...params,
})
}
/**
* Restart background process
*
* Stop and restart a background process with its original command.
*/
public restart<ThrowOnError extends boolean = false>(
parameters: {
processID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "processID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<
BackgroundProcessRestartResponses,
BackgroundProcessRestartErrors,
ThrowOnError
>({
url: "/background-process/{processID}/restart",
...options,
...params,
})
}
/**
* Stop session background processes
*
* Terminate and forget all background processes associated with one session.
*/
public stopSession<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<BackgroundProcessStopSessionResponses, unknown, ThrowOnError>({
url: "/background-process/session/{sessionID}/stop",
...options,
...params,
})
}
}
export class CommitMessage extends HeyApiClient {
/**
* Generate commit message
@@ -6540,6 +6802,11 @@ export class KiloClient extends HeyApiClient {
return (this._tui ??= new Tui({ client: this.client }))
}
private _backgroundProcess?: BackgroundProcess
get backgroundProcess(): BackgroundProcess {
return (this._backgroundProcess ??= new BackgroundProcess({ client: this.client }))
}
private _commitMessage?: CommitMessage
get commitMessage(): CommitMessage {
return (this._commitMessage ??= new CommitMessage({ client: this.client }))
+253
View File
@@ -27,6 +27,8 @@ export type Event =
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventBackgroundProcessUpdated
| EventBackgroundProcessDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSessionDiff
@@ -260,6 +262,25 @@ export type PermissionRequest = {
}
}
export type BackgroundProcessInfo = {
id: string
sessionID: string
pid?: number
command: string
cwd: string
description?: string
status: "starting" | "running" | "ready" | "exited" | "failed" | "stopping" | "stopped"
ready: boolean
exitCode?: number
signal?: string
output: string
time: {
started: number
updated: number
ended?: number
}
}
export type SnapshotFileDiff = {
file: string
patch: string
@@ -873,6 +894,8 @@ export type GlobalEvent = {
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventBackgroundProcessUpdated
| EventBackgroundProcessDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSessionDiff
@@ -1930,6 +1953,29 @@ export type Workspace = {
projectID: string
}
export type BackgroundProcessReady = {
pattern?: string
port?: number
timeout?: number
}
export type BackgroundProcessStartInput = {
sessionID: string
/**
* Command to run in the configured shell
*/
command: string
cwd?: string
description?: string
ready?: BackgroundProcessReady
}
export type BackgroundProcessLogs = {
id: string
sessionID: string
output: string
}
export type EffectHttpApiErrorUnauthorized = {
_tag: "Unauthorized"
}
@@ -2616,6 +2662,23 @@ export type EventPermissionReplied = {
}
}
export type EventBackgroundProcessUpdated = {
id: string
type: "background_process.updated"
properties: {
info: BackgroundProcessInfo
}
}
export type EventBackgroundProcessDeleted = {
id: string
type: "background_process.deleted"
properties: {
sessionID: string
processID: string
}
}
export type EventSessionTurnOpen = {
id: string
type: "session.turn.open"
@@ -7241,6 +7304,196 @@ export type ExperimentalWorkspaceSessionRestoreResponses = {
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type BackgroundProcessListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/background-process"
}
export type BackgroundProcessListResponses = {
/**
* List of background processes
*/
200: Array<BackgroundProcessInfo>
}
export type BackgroundProcessListResponse = BackgroundProcessListResponses[keyof BackgroundProcessListResponses]
export type BackgroundProcessCreateData = {
body?: BackgroundProcessStartInput
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/background-process"
}
export type BackgroundProcessCreateErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type BackgroundProcessCreateError = BackgroundProcessCreateErrors[keyof BackgroundProcessCreateErrors]
export type BackgroundProcessCreateResponses = {
/**
* Created background process
*/
200: BackgroundProcessInfo
}
export type BackgroundProcessCreateResponse = BackgroundProcessCreateResponses[keyof BackgroundProcessCreateResponses]
export type BackgroundProcessGetData = {
body?: never
path: {
processID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/background-process/{processID}"
}
export type BackgroundProcessGetErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type BackgroundProcessGetError = BackgroundProcessGetErrors[keyof BackgroundProcessGetErrors]
export type BackgroundProcessGetResponses = {
/**
* Background process info
*/
200: BackgroundProcessInfo
}
export type BackgroundProcessGetResponse = BackgroundProcessGetResponses[keyof BackgroundProcessGetResponses]
export type BackgroundProcessLogsData = {
body?: never
path: {
processID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/background-process/{processID}/logs"
}
export type BackgroundProcessLogsErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type BackgroundProcessLogsError = BackgroundProcessLogsErrors[keyof BackgroundProcessLogsErrors]
export type BackgroundProcessLogsResponses = {
/**
* Background process logs
*/
200: BackgroundProcessLogs
}
export type BackgroundProcessLogsResponse = BackgroundProcessLogsResponses[keyof BackgroundProcessLogsResponses]
export type BackgroundProcessStopData = {
body?: never
path: {
processID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/background-process/{processID}/stop"
}
export type BackgroundProcessStopErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type BackgroundProcessStopError = BackgroundProcessStopErrors[keyof BackgroundProcessStopErrors]
export type BackgroundProcessStopResponses = {
/**
* Stopped background process
*/
200: BackgroundProcessInfo
}
export type BackgroundProcessStopResponse = BackgroundProcessStopResponses[keyof BackgroundProcessStopResponses]
export type BackgroundProcessRestartData = {
body?: never
path: {
processID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/background-process/{processID}/restart"
}
export type BackgroundProcessRestartErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type BackgroundProcessRestartError = BackgroundProcessRestartErrors[keyof BackgroundProcessRestartErrors]
export type BackgroundProcessRestartResponses = {
/**
* Restarted background process
*/
200: BackgroundProcessInfo
}
export type BackgroundProcessRestartResponse =
BackgroundProcessRestartResponses[keyof BackgroundProcessRestartResponses]
export type BackgroundProcessStopSessionData = {
body?: never
path: {
sessionID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/background-process/session/{sessionID}/stop"
}
export type BackgroundProcessStopSessionResponses = {
/**
* Stopped session background processes
*/
200: boolean
}
export type BackgroundProcessStopSessionResponse =
BackgroundProcessStopSessionResponses[keyof BackgroundProcessStopSessionResponses]
export type CommitMessageGenerateData = {
body?: {
/**
+596
View File
@@ -8949,6 +8949,417 @@
]
}
},
"/background-process": {
"get": {
"tags": ["background-process"],
"operationId": "backgroundProcess.list",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of background processes",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
},
"description": "List of background processes"
}
}
}
}
},
"description": "List tracked background processes for the current instance.",
"summary": "List background processes",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.list({\n ...\n})"
}
]
},
"post": {
"tags": ["background-process"],
"operationId": "backgroundProcess.create",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Created background process",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "Start a tracked background process.",
"summary": "Create background process",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessStartInput"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.create({\n ...\n})"
}
]
}
},
"/background-process/{processID}": {
"get": {
"tags": ["background-process"],
"operationId": "backgroundProcess.get",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "processID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Background process info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Get status and retained output for one background process.",
"summary": "Get background process",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.get({\n ...\n})"
}
]
}
},
"/background-process/{processID}/logs": {
"get": {
"tags": ["background-process"],
"operationId": "backgroundProcess.logs",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "processID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Background process logs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessLogs"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Get the retained output tail for one background process.",
"summary": "Get background process logs",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.logs({\n ...\n})"
}
]
}
},
"/background-process/{processID}/stop": {
"post": {
"tags": ["background-process"],
"operationId": "backgroundProcess.stop",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "processID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Stopped background process",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Terminate a background process and its child process tree.",
"summary": "Stop background process",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stop({\n ...\n})"
}
]
}
},
"/background-process/{processID}/restart": {
"post": {
"tags": ["background-process"],
"operationId": "backgroundProcess.restart",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "processID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Restarted background process",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Stop and restart a background process with its original command.",
"summary": "Restart background process",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.restart({\n ...\n})"
}
]
}
},
"/background-process/session/{sessionID}/stop": {
"post": {
"tags": ["background-process"],
"operationId": "backgroundProcess.stopSession",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses.*"
},
"required": true
}
],
"responses": {
"200": {
"description": "Stopped session background processes",
"content": {
"application/json": {
"schema": {
"type": "boolean",
"description": "Stopped session background processes"
}
}
}
}
},
"description": "Terminate and forget all background processes associated with one session.",
"summary": "Stop session background processes",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stopSession({\n ...\n})"
}
]
}
},
"/commit-message": {
"post": {
"tags": ["commit-message"],
@@ -12192,6 +12603,12 @@
{
"$ref": "#/components/schemas/EventPermissionReplied"
},
{
"$ref": "#/components/schemas/EventBackground_processUpdated"
},
{
"$ref": "#/components/schemas/EventBackground_processDeleted"
},
{
"$ref": "#/components/schemas/EventSessionTurnOpen"
},
@@ -12807,6 +13224,68 @@
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"],
"additionalProperties": false
},
"BackgroundProcessInfo": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"pid": {
"type": "integer",
"exclusiveMinimum": 0
},
"command": {
"type": "string"
},
"cwd": {
"type": "string"
},
"description": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["starting", "running", "ready", "exited", "failed", "stopping", "stopped"]
},
"ready": {
"type": "boolean"
},
"exitCode": {
"type": "integer",
"minimum": 0
},
"signal": {
"type": "string"
},
"output": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
"started": {
"type": "integer",
"minimum": 0
},
"updated": {
"type": "integer",
"minimum": 0
},
"ended": {
"type": "integer",
"minimum": 0
}
},
"required": ["started", "updated"],
"additionalProperties": false
}
},
"required": ["id", "sessionID", "command", "cwd", "status", "ready", "output", "time"],
"additionalProperties": false
},
"SnapshotFileDiff": {
"type": "object",
"properties": {
@@ -14628,6 +15107,12 @@
{
"$ref": "#/components/schemas/EventPermissionReplied"
},
{
"$ref": "#/components/schemas/EventBackground_processUpdated"
},
{
"$ref": "#/components/schemas/EventBackground_processDeleted"
},
{
"$ref": "#/components/schemas/EventSessionTurnOpen"
},
@@ -17647,6 +18132,62 @@
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
"additionalProperties": false
},
"BackgroundProcessReady": {
"type": "object",
"properties": {
"pattern": {
"type": "string"
},
"port": {
"type": "integer",
"exclusiveMinimum": 0
},
"timeout": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"additionalProperties": false
},
"BackgroundProcessStartInput": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"command": {
"type": "string",
"description": "Command to run in the configured shell"
},
"cwd": {
"type": "string"
},
"description": {
"type": "string"
},
"ready": {
"$ref": "#/components/schemas/BackgroundProcessReady"
}
},
"required": ["sessionID", "command"],
"additionalProperties": false
},
"BackgroundProcessLogs": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"output": {
"type": "string"
}
},
"required": ["id", "sessionID", "output"],
"additionalProperties": false
},
"effect_HttpApiError_Unauthorized": {
"type": "object",
"properties": {
@@ -19884,6 +20425,57 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventBackground_processUpdated": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["background_process.updated"]
},
"properties": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/BackgroundProcessInfo"
}
},
"required": ["info"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventBackground_processDeleted": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["background_process.deleted"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"processID": {
"type": "string"
}
},
"required": ["sessionID", "processID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionTurnOpen": {
"type": "object",
"properties": {
@@ -22845,6 +23437,10 @@
"name": "workspace",
"description": "Experimental HttpApi workspace routes."
},
{
"name": "background-process",
"description": "Kilo background process routes."
},
{
"name": "commit-message",
"description": "Kilo commit message routes."
@@ -7,12 +7,12 @@ export const iconNames = [
"zai",
"zai-coding-plan",
"xiaomi",
"xiaomi-token-plan-sgp", // kilocode_change
"xiaomi-token-plan-cn", // kilocode_change
"xiaomi-token-plan-ams", // kilocode_change
"xiaomi-token-plan-sgp",
"xiaomi-token-plan-cn",
"xiaomi-token-plan-ams",
"xai",
"wandb",
"wafer.ai", // kilocode_change
"wafer.ai",
"vultr",
"vivgrid",
"vercel",
@@ -20,8 +20,8 @@ export const iconNames = [
"v0",
"upstage",
"togetherai",
"the-grid-ai", // kilocode_change
"tencent-tokenhub", // kilocode_change
"the-grid-ai",
"tencent-tokenhub",
"tencent-coding-plan",
"synthetic",
"submodel",
@@ -32,7 +32,7 @@ export const iconNames = [
"scaleway",
"sap-ai-core",
"requesty",
"regolo-ai", // kilocode_change
"regolo-ai",
"qiniu-ai",
"qihang-ai",
"privatemode-ai",
@@ -48,7 +48,7 @@ export const iconNames = [
"nvidia",
"novita-ai",
"nova",
"neuralwatt", // kilocode_change
"neuralwatt",
"nebius",
"nano-gpt",
"morph",
@@ -56,7 +56,7 @@ export const iconNames = [
"moonshotai-cn",
"modelscope",
"moark",
"mixlayer", // kilocode_change
"mixlayer",
"mistral",
"minimax",
"minimax-coding-plan",
@@ -65,10 +65,10 @@ export const iconNames = [
"meganova",
"lucidquery",
"lmstudio",
"llmgateway", // kilocode_change
"llmgateway",
"llama",
"kuae-cloud-coding-plan",
"kiro", // kilocode_change
"kiro",
"kimi-for-coding",
"kilo",
"jiekou",
@@ -77,7 +77,7 @@ export const iconNames = [
"inception",
"iflowcn",
"huggingface",
"hpc-ai", // kilocode_change
"hpc-ai",
"helicone",
"groq",
"google",
@@ -86,7 +86,7 @@ export const iconNames = [
"gitlab",
"github-models",
"github-copilot",
"frogbot", // kilocode_change
"frogbot",
"friendli",
"firmware",
"fireworks-ai",
@@ -94,7 +94,7 @@ export const iconNames = [
"evroc",
"drun",
"dinference",
"digitalocean", // kilocode_change
"digitalocean",
"deepseek",
"deepinfra",
"cortecs",
@@ -117,7 +117,7 @@ export const iconNames = [
"alibaba-coding-plan-cn",
"alibaba-cn",
"aihubmix",
"abliteration-ai", // kilocode_change
"abliteration-ai",
"abacus",
"302ai",
] as const