mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
refactor: improve implementation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show detected ports for tracked background processes in the TUI sidebar and process detail dialog.
|
||||
@@ -654,6 +654,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (!next[item.sessionID]) next[item.sessionID] = []
|
||||
next[item.sessionID].push(item)
|
||||
}
|
||||
for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id))
|
||||
setStore("background_process", reconcile(next))
|
||||
}),
|
||||
// kilocode_change end
|
||||
|
||||
@@ -276,18 +276,36 @@ export function Session() {
|
||||
const editor = useEditorContext()
|
||||
|
||||
// kilocode_change start - background processes are scoped to the visible session
|
||||
function processGroup(sessionID: string) {
|
||||
const info = sync.session.get(sessionID)
|
||||
return info?.parentID ?? info?.id ?? sessionID
|
||||
}
|
||||
|
||||
function processSessions(sessionID: string) {
|
||||
const group = processGroup(sessionID)
|
||||
const ids = new Set([sessionID, group])
|
||||
for (const item of sync.data.session) {
|
||||
if (item.id === group || item.parentID === group) ids.add(item.id)
|
||||
}
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
function stopProcesses(sessionID: string) {
|
||||
void sdk.client.backgroundProcess.stopSession({ sessionID }).catch((err) => {
|
||||
Log.Default.warn("failed to stop session background processes", { sessionID, err })
|
||||
})
|
||||
for (const id of processSessions(sessionID)) {
|
||||
void sdk.client.backgroundProcess.stopSession({ sessionID: id }).catch((err) => {
|
||||
Log.Default.warn("failed to stop session background processes", { sessionID: id, err })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let processSessionID = route.sessionID
|
||||
createEffect(() => {
|
||||
const next = route.sessionID
|
||||
if (processSessionID === next) return
|
||||
stopProcesses(processSessionID)
|
||||
const prev = processSessionID
|
||||
processSessionID = next
|
||||
if (processGroup(prev) === processGroup(next)) return
|
||||
stopProcesses(prev)
|
||||
})
|
||||
onCleanup(() => {
|
||||
stopProcesses(processSessionID)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import net from "net"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import * as Ports from "./ports"
|
||||
|
||||
export namespace BackgroundProcess {
|
||||
const log = Log.create({ service: "background-process" })
|
||||
@@ -21,6 +22,7 @@ export namespace BackgroundProcess {
|
||||
const KILL_MS = 3_000
|
||||
const READY_MS = 30_000
|
||||
const PUBLISH_MS = 500
|
||||
const PORT_MS = 2_000
|
||||
|
||||
const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe(
|
||||
Schema.brand("BackgroundProcessID"),
|
||||
@@ -61,6 +63,7 @@ export namespace BackgroundProcess {
|
||||
command: Schema.String,
|
||||
cwd: Schema.String,
|
||||
description: optionalOmitUndefined(Schema.String),
|
||||
ports: Schema.mutable(Schema.Array(PositiveInt)),
|
||||
status: Status,
|
||||
ready: Schema.Boolean,
|
||||
exitCode: optionalOmitUndefined(Schema.NullOr(NonNegativeInt)),
|
||||
@@ -122,6 +125,9 @@ export namespace BackgroundProcess {
|
||||
pattern?: RegExp
|
||||
resolve?: (ready: boolean) => void
|
||||
notify?: ReturnType<typeof setTimeout>
|
||||
poll?: ReturnType<typeof setTimeout>
|
||||
scan?: Promise<boolean>
|
||||
disposed?: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -137,6 +143,7 @@ export namespace BackgroundProcess {
|
||||
function clone(info: Info): Info {
|
||||
return {
|
||||
...info,
|
||||
ports: [...info.ports],
|
||||
time: { ...info.time },
|
||||
}
|
||||
}
|
||||
@@ -153,7 +160,26 @@ export namespace BackgroundProcess {
|
||||
return buf.subarray(start).toString("utf-8")
|
||||
}
|
||||
|
||||
function publish(active: Active) {
|
||||
function same(a: number[], b: number[]) {
|
||||
return a.length === b.length && a.every((port, index) => port === b[index])
|
||||
}
|
||||
|
||||
async function refresh(active: Active) {
|
||||
const pid = active.proc.pid
|
||||
if (!pid || terminal(active.info.status)) {
|
||||
const changed = active.info.ports.length > 0
|
||||
active.info.ports = []
|
||||
return changed
|
||||
}
|
||||
const fallback = active.info.ready && active.start.ready?.port ? [active.start.ready.port] : []
|
||||
const next = Array.from(new Set([...(await Ports.list(pid)), ...fallback])).toSorted((a, b) => a - b)
|
||||
if (same(active.info.ports, next)) return false
|
||||
active.info.ports = next
|
||||
active.info.time.updated = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
function emit(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 })
|
||||
@@ -161,7 +187,50 @@ export namespace BackgroundProcess {
|
||||
})
|
||||
}
|
||||
|
||||
function publish(active: Active) {
|
||||
if (active.disposed) return
|
||||
active.scan = (active.scan ?? refresh(active))
|
||||
.then(() => {
|
||||
active.scan = undefined
|
||||
emit(active)
|
||||
poll(active)
|
||||
return false
|
||||
})
|
||||
.catch((err) => {
|
||||
active.scan = undefined
|
||||
log.debug("failed to refresh process ports", { err, id: active.info.id })
|
||||
emit(active)
|
||||
poll(active)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function poll(active: Active) {
|
||||
if (active.disposed) return
|
||||
if (terminal(active.info.status)) return
|
||||
if (active.poll) return
|
||||
active.poll = setTimeout(() => {
|
||||
active.poll = undefined
|
||||
if (active.disposed) return
|
||||
if (terminal(active.info.status)) return
|
||||
active.scan = (active.scan ?? refresh(active))
|
||||
.then((changed) => {
|
||||
active.scan = undefined
|
||||
if (changed) emit(active)
|
||||
poll(active)
|
||||
return changed
|
||||
})
|
||||
.catch((err) => {
|
||||
active.scan = undefined
|
||||
log.debug("failed to refresh process ports", { err, id: active.info.id })
|
||||
poll(active)
|
||||
return false
|
||||
})
|
||||
}, PORT_MS)
|
||||
}
|
||||
|
||||
function schedule(active: Active) {
|
||||
if (active.disposed) return
|
||||
if (active.notify) return
|
||||
active.notify = setTimeout(() => {
|
||||
active.notify = undefined
|
||||
@@ -170,6 +239,7 @@ export namespace BackgroundProcess {
|
||||
}
|
||||
|
||||
function ready(active: Active) {
|
||||
if (active.disposed) return
|
||||
if (active.info.ready) return
|
||||
active.info.ready = true
|
||||
active.info.status = "ready"
|
||||
@@ -180,6 +250,7 @@ export namespace BackgroundProcess {
|
||||
}
|
||||
|
||||
function append(active: Active, chunk: string) {
|
||||
if (active.disposed) return
|
||||
active.info.output = clamp(active.info.output + chunk)
|
||||
active.info.time.updated = Date.now()
|
||||
if (active.pattern?.test(active.info.output)) ready(active)
|
||||
@@ -187,11 +258,15 @@ export namespace BackgroundProcess {
|
||||
}
|
||||
|
||||
function exited(active: Active, code: number | null, signal: NodeJS.Signals | null) {
|
||||
if (active.disposed) return
|
||||
if (terminal(active.info.status)) return
|
||||
if (active.notify) clearTimeout(active.notify)
|
||||
if (active.poll) clearTimeout(active.poll)
|
||||
active.notify = undefined
|
||||
active.poll = undefined
|
||||
active.info.exitCode = code
|
||||
active.info.signal = signal
|
||||
active.info.ports = []
|
||||
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()
|
||||
@@ -202,10 +277,20 @@ export namespace BackgroundProcess {
|
||||
}
|
||||
|
||||
function failed(active: Active, err: unknown) {
|
||||
if (active.disposed) return
|
||||
append(active, `\n${err instanceof Error ? err.message : String(err)}\n`)
|
||||
exited(active, 1, null)
|
||||
}
|
||||
|
||||
function pattern(input?: string) {
|
||||
if (!input) return
|
||||
try {
|
||||
return new RegExp(input)
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function connected(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ port, host: "127.0.0.1" })
|
||||
@@ -325,8 +410,10 @@ export namespace BackgroundProcess {
|
||||
if (!terminal(active.info.status)) exited(active, active.proc.exitCode, active.proc.signalCode)
|
||||
}
|
||||
if (!opts?.remove) return
|
||||
active.disposed = true
|
||||
state.processes.delete(active.info.id)
|
||||
if (active.notify) clearTimeout(active.notify)
|
||||
if (active.poll) clearTimeout(active.poll)
|
||||
active.resolve?.(false)
|
||||
active.resolve = undefined
|
||||
if (opts.silent) return
|
||||
@@ -340,7 +427,7 @@ export namespace BackgroundProcess {
|
||||
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 readyPattern = pattern(input.ready?.pattern)
|
||||
const args = Shell.args(sh, input.command, cwd)
|
||||
const proc = spawn(sh, args, {
|
||||
cwd,
|
||||
@@ -359,6 +446,7 @@ export namespace BackgroundProcess {
|
||||
command: input.command,
|
||||
cwd,
|
||||
description: input.description,
|
||||
ports: [],
|
||||
status: input.ready ? "starting" : "running",
|
||||
ready: false,
|
||||
output: "",
|
||||
@@ -369,7 +457,7 @@ export namespace BackgroundProcess {
|
||||
},
|
||||
proc,
|
||||
start: { ...input, cwd },
|
||||
pattern,
|
||||
pattern: readyPattern,
|
||||
}
|
||||
state.processes.set(id, active)
|
||||
proc.stdout?.on("data", (chunk) => append(active, chunk.toString("utf-8")))
|
||||
@@ -393,7 +481,9 @@ export namespace BackgroundProcess {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(
|
||||
Array.from(state.processes.values()).map((active) => terminate(state, active, { silent: true })),
|
||||
Array.from(state.processes.values()).map((active) =>
|
||||
terminate(state, active, { remove: true, silent: true }),
|
||||
),
|
||||
)
|
||||
state.processes.clear()
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Process } from "@/util/process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
function sorted(items: Iterable<number>) {
|
||||
return Array.from(items).toSorted((a, b) => a - b)
|
||||
}
|
||||
|
||||
function parse(addr: string) {
|
||||
const raw = addr.split(":").at(-1)
|
||||
if (!raw) return
|
||||
const port = Number.parseInt(raw, 16)
|
||||
if (!Number.isFinite(port) || port <= 0) return
|
||||
return port
|
||||
}
|
||||
|
||||
async function ppid(pid: number) {
|
||||
const text = await fs.readFile(`/proc/${pid}/stat`, "utf8")
|
||||
const match = text.match(/^\d+ \(.+\) \S+ (\d+)/)
|
||||
if (!match) return
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
async function tree(root: number) {
|
||||
const names = await fs.readdir("/proc")
|
||||
const rows = await Promise.all(
|
||||
names
|
||||
.filter((name) => /^\d+$/.test(name))
|
||||
.map(async (name) => {
|
||||
const pid = Number(name)
|
||||
const parent = await ppid(pid).catch(() => undefined)
|
||||
if (!parent) return
|
||||
return { pid, parent }
|
||||
}),
|
||||
)
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
if (!row) continue
|
||||
children.set(row.parent, [...(children.get(row.parent) ?? []), row.pid])
|
||||
}
|
||||
const result = new Set([root])
|
||||
const stack = [root]
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (!pid) continue
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
if (result.has(child)) continue
|
||||
result.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function sockets(pids: Set<number>) {
|
||||
const all = await Promise.all(
|
||||
Array.from(pids).map(async (pid) => {
|
||||
const dir = `/proc/${pid}/fd`
|
||||
const files = await fs.readdir(dir).catch(() => [])
|
||||
const links = await Promise.allSettled(files.map((file) => fs.readlink(path.join(dir, file))))
|
||||
return links.flatMap((item) => {
|
||||
if (item.status !== "fulfilled") return []
|
||||
const match = item.value.match(/^socket:\[(\d+)\]$/)
|
||||
return match ? [match[1]] : []
|
||||
})
|
||||
}),
|
||||
)
|
||||
return new Set(all.flat())
|
||||
}
|
||||
|
||||
async function file(name: string, inodes: Set<string>) {
|
||||
const text = await fs.readFile(name, "utf8").catch(() => "")
|
||||
return text
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.flatMap((line) => {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts[3] !== "0A") return []
|
||||
if (!inodes.has(parts[9])) return []
|
||||
const port = parse(parts[1])
|
||||
return port ? [port] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function linux(root: number) {
|
||||
const pids = await tree(root)
|
||||
const found = await sockets(pids)
|
||||
if (found.size === 0) return []
|
||||
const ports = await Promise.all([file("/proc/net/tcp", found), file("/proc/net/tcp6", found)])
|
||||
return sorted(new Set(ports.flat()))
|
||||
}
|
||||
|
||||
async function ps(root: number) {
|
||||
const rows = await Process.lines(["ps", "-axo", "pid=,ppid="], { nothrow: true })
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
const [pid, parent] = row.trim().split(/\s+/).map(Number)
|
||||
if (!pid || !parent) continue
|
||||
children.set(parent, [...(children.get(parent) ?? []), pid])
|
||||
}
|
||||
const result = new Set([root])
|
||||
const stack = [root]
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (!pid) continue
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
if (result.has(child)) continue
|
||||
result.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function lsof(root: number) {
|
||||
const pids = await ps(root).catch(() => new Set([root]))
|
||||
const rows = await Process.lines(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", Array.from(pids).join(",")], {
|
||||
nothrow: true,
|
||||
})
|
||||
return sorted(
|
||||
new Set(
|
||||
rows.flatMap((row) => {
|
||||
const match = row.match(/:(\d+)\s+\(LISTEN\)$/)
|
||||
return match ? [Number(match[1])] : []
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function list(root: number) {
|
||||
if (process.platform === "linux") {
|
||||
const ports = await linux(root).catch(() => [])
|
||||
if (ports.length > 0) return ports
|
||||
}
|
||||
if (process.platform === "win32") return []
|
||||
return lsof(root).catch(() => [])
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type Scope = "session" | "all"
|
||||
type Kind = "stop" | "restart"
|
||||
type Theme = ReturnType<typeof useTheme>["theme"]
|
||||
|
||||
const stopKey = Keybind.parse("ctrl+x")[0]
|
||||
const stopKey = Keybind.parse("ctrl+o")[0]
|
||||
const restartKey = Keybind.parse("ctrl+r")[0]
|
||||
const allKey = Keybind.parse("ctrl+a")[0]
|
||||
|
||||
@@ -49,14 +49,14 @@ 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 ports(item: Info) {
|
||||
return item.ports.length > 0 ? item.ports.join(", ") : "none"
|
||||
}
|
||||
|
||||
function useActions() {
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
@@ -142,21 +142,14 @@ export function DialogProcessList() {
|
||||
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)
|
||||
const footer = busy?.id === item.id ? `${busy.kind === "stop" ? "stopping" : "restarting"}...` : item.pid?.toString()
|
||||
const title = `${note ? `(${note}) ` : ""}${label(item)} - ${item.command}`
|
||||
|
||||
return {
|
||||
title: short(title, 61),
|
||||
title: short(title, 92),
|
||||
value: item.id,
|
||||
description: tail ? `${command} > ${short(tail, 32)}` : command,
|
||||
footer,
|
||||
category: item.status,
|
||||
gutter: () => <StatusMark status={item.status} />,
|
||||
}
|
||||
})
|
||||
@@ -239,7 +232,7 @@ function DialogProcessDetail(props: { id: string; back: () => void }) {
|
||||
props.back()
|
||||
return
|
||||
}
|
||||
if (keybind.match("ctrl+x", evt)) {
|
||||
if (keybind.match("ctrl+o", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
if (proc) void actions.run("stop", proc)
|
||||
@@ -278,23 +271,31 @@ function DialogProcessDetail(props: { id: string; back: () => void }) {
|
||||
{(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 fg={theme.textMuted} wrapMode="word">
|
||||
Name: {label(proc())}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>started {Locale.datetime(proc().time.started)}</text>
|
||||
<text fg={theme.textMuted}>updated {Locale.datetime(proc().time.updated)}</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Status:{" "}
|
||||
<span style={{ fg: tone(proc().status, theme), attributes: TextAttributes.BOLD }}>{proc().status}</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>PID: {proc().pid ?? "none"}</text>
|
||||
<text fg={theme.textMuted}>Ports: {ports(proc())}</text>
|
||||
<Show when={proc().exitCode !== undefined}>
|
||||
<text fg={theme.textMuted}>Exit: {proc().exitCode}</text>
|
||||
</Show>
|
||||
<Show when={proc().signal}>
|
||||
{(signal) => <text fg={theme.textMuted}>Signal: {signal()}</text>}
|
||||
</Show>
|
||||
<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>}
|
||||
{(ended) => <text fg={theme.textMuted}>Ended: {Locale.datetime(ended())}</text>}
|
||||
</Show>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
cwd {proc().cwd}
|
||||
CWD: {proc().cwd}
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
command {proc().command}
|
||||
Command: {proc().command}
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
|
||||
@@ -8,10 +8,6 @@ function short(text: string, max = 34) {
|
||||
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
|
||||
@@ -21,7 +17,7 @@ function tone(item: TuiSidebarBackgroundProcessItem, api: TuiPluginApi) {
|
||||
}
|
||||
|
||||
function label(item: TuiSidebarBackgroundProcessItem) {
|
||||
return item.description || item.command
|
||||
return item.description?.trim() || item.command
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
@@ -47,13 +43,12 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
<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 when={item.pid}>
|
||||
{(pid) => <text fg={theme().textMuted}>PID: {pid()}</text>}
|
||||
</Show>
|
||||
<Show when={item.ports.length > 0}>
|
||||
<text fg={theme().textMuted}>PORTS: {item.ports.join(", ")}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,6 @@ const root = "/background-process"
|
||||
|
||||
export const BackgroundProcessPaths = {
|
||||
list: root,
|
||||
create: root,
|
||||
get: `${root}/:processID`,
|
||||
logs: `${root}/:processID/logs`,
|
||||
stop: `${root}/:processID/stop`,
|
||||
@@ -35,17 +34,6 @@ export const BackgroundProcessApi = HttpApi.make("background-process")
|
||||
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"),
|
||||
|
||||
@@ -12,12 +12,6 @@ export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, "
|
||||
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 }
|
||||
}) {
|
||||
@@ -59,7 +53,6 @@ export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, "
|
||||
|
||||
return handlers
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("get", get)
|
||||
.handle("logs", logs)
|
||||
.handle("stop", stop)
|
||||
|
||||
@@ -14,7 +14,6 @@ type Handler = (request: Request, context: Context.Context<unknown>) => Promise<
|
||||
|
||||
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))
|
||||
|
||||
@@ -24,23 +24,6 @@ export const BackgroundProcessRoutes = lazy(() =>
|
||||
}),
|
||||
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({
|
||||
|
||||
@@ -70,6 +70,15 @@ function invalid(action: Action, message: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function pattern(ready?: BackgroundProcess.Ready) {
|
||||
if (!ready?.pattern) return
|
||||
try {
|
||||
new RegExp(ready.pattern)
|
||||
} catch (err) {
|
||||
return `Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
|
||||
export const BackgroundProcessTool = Tool.define<typeof Params, Meta, never, "background_process">(
|
||||
"background_process",
|
||||
Effect.succeed({
|
||||
@@ -121,6 +130,8 @@ export const BackgroundProcessTool = Tool.define<typeof Params, Meta, never, "ba
|
||||
|
||||
const command = params.command
|
||||
if (!command?.trim()) return invalid(params.action, "Missing command")
|
||||
const err = pattern(params.ready)
|
||||
if (err) return invalid(params.action, err)
|
||||
const inst = yield* InstanceState.context
|
||||
const cwd = path.resolve(inst.directory, params.workdir ?? inst.directory)
|
||||
if (!containsPath(cwd, inst)) {
|
||||
|
||||
@@ -285,7 +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 ?? (() => []),
|
||||
processes: opts.state?.session?.processes ?? (() => []), // kilocode_change
|
||||
messages: opts.state?.session?.messages ?? (() => []),
|
||||
status: opts.state?.session?.status ?? (() => undefined),
|
||||
permission: opts.state?.session?.permission ?? (() => []),
|
||||
|
||||
@@ -89,4 +89,30 @@ describe("BackgroundProcess", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects invalid readiness patterns before launching", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = SessionID.descending()
|
||||
|
||||
const err = yield* Effect.promise(async () => {
|
||||
try {
|
||||
await BackgroundProcess.start({
|
||||
sessionID,
|
||||
command: "printf 'ready\n'",
|
||||
cwd: test.directory,
|
||||
ready: { pattern: "[", timeout: 1_000 },
|
||||
})
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect((err as Error).message).toContain("Invalid ready pattern")
|
||||
|
||||
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
|
||||
expect(list).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -81,7 +81,6 @@ describe("Kilo HttpApi bridge", () => {
|
||||
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",
|
||||
@@ -119,6 +118,8 @@ describe("Kilo HttpApi bridge", () => {
|
||||
|
||||
expect(kilo.filter((route) => !hono.has(route))).toEqual([])
|
||||
expect(kilo.filter((route) => !effect.has(route))).toEqual([])
|
||||
expect(hono.has("POST /background-process")).toBe(false)
|
||||
expect(effect.has("POST /background-process")).toBe(false)
|
||||
expect(effect.has("GET /indexing/status")).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
|
||||
|
||||
export type TuiSidebarBackgroundProcessItem = Pick<
|
||||
BackgroundProcessInfo,
|
||||
"id" | "pid" | "command" | "cwd" | "description" | "status" | "output"
|
||||
"id" | "pid" | "command" | "cwd" | "description" | "ports" | "status" | "output"
|
||||
>
|
||||
|
||||
export type TuiSidebarFileItem = {
|
||||
|
||||
@@ -13,8 +13,6 @@ import type {
|
||||
AuthRemoveResponses,
|
||||
AuthSetErrors,
|
||||
AuthSetResponses,
|
||||
BackgroundProcessCreateErrors,
|
||||
BackgroundProcessCreateResponses,
|
||||
BackgroundProcessGetErrors,
|
||||
BackgroundProcessGetResponses,
|
||||
BackgroundProcessListResponses,
|
||||
@@ -22,7 +20,6 @@ import type {
|
||||
BackgroundProcessLogsResponses,
|
||||
BackgroundProcessRestartErrors,
|
||||
BackgroundProcessRestartResponses,
|
||||
BackgroundProcessStartInput,
|
||||
BackgroundProcessStopErrors,
|
||||
BackgroundProcessStopResponses,
|
||||
BackgroundProcessStopSessionResponses,
|
||||
@@ -5006,47 +5003,6 @@ export class BackgroundProcess extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
|
||||
@@ -269,6 +269,7 @@ export type BackgroundProcessInfo = {
|
||||
command: string
|
||||
cwd: string
|
||||
description?: string
|
||||
ports: Array<number>
|
||||
status: "starting" | "running" | "ready" | "exited" | "failed" | "stopping" | "stopped"
|
||||
ready: boolean
|
||||
exitCode?: number
|
||||
@@ -1953,23 +1954,6 @@ 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
|
||||
@@ -7323,34 +7307,6 @@ export type BackgroundProcessListResponses = {
|
||||
|
||||
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: {
|
||||
|
||||
+8
-102
@@ -8995,67 +8995,6 @@
|
||||
"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}": {
|
||||
@@ -13246,6 +13185,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"ports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["starting", "running", "ready", "exited", "failed", "stopping", "stopped"]
|
||||
@@ -13283,7 +13229,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "command", "cwd", "status", "ready", "output", "time"],
|
||||
"required": ["id", "sessionID", "command", "cwd", "ports", "status", "ready", "output", "time"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnapshotFileDiff": {
|
||||
@@ -18132,46 +18078,6 @@
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user