mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #10513 from Kilo-Org/dynamic-department
Add background process support
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support tracked background processes in the CLI and VS Code so agents can start long-running dev servers and clean them up when sessions change or end. The CLI also includes process management UI, status, and logs.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:18d394d726ebee312e54db5d45f55cca3a1728d2b41f9f73bb9b610096bf53e0
|
||||
size 41004
|
||||
@@ -135,6 +135,7 @@ import { configFeatures } from "./features"
|
||||
import { createAutoApproveBridge } from "./kilo-provider/auto-approve"
|
||||
import type { KiloProviderOptions } from "./kilo-provider/options"
|
||||
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
|
||||
import { stopSessionProcesses } from "./kilo-provider/background-process"
|
||||
|
||||
type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
|
||||
type ContextMessage = { contextDirectory?: unknown }
|
||||
@@ -290,6 +291,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.opts.tabTitle?.(nativeTitle(session))
|
||||
}
|
||||
|
||||
private stopCurrentSessionProcesses(next?: string): void {
|
||||
const sid = this.contextSessionID ?? this.currentSession?.id
|
||||
if (!sid || sid === next) return
|
||||
const session = this.currentSession?.id === sid ? this.currentSession : undefined
|
||||
void stopSessionProcesses(this.client, sid, this.getSessionDirectory(sid, session))
|
||||
}
|
||||
|
||||
private sendRemoteStatus(): void {
|
||||
const s = this.remoteService?.getState()
|
||||
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
|
||||
@@ -492,6 +500,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
/** Register a session created externally and notify the webview. */
|
||||
public registerSession(session: Session): void {
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
this.trackedSessionIds.add(session.id)
|
||||
@@ -713,7 +722,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.handleCreateSession()
|
||||
break
|
||||
case "clearSession":
|
||||
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
|
||||
this.stopCurrentSessionProcesses()
|
||||
this.contextSessionID = undefined
|
||||
this.setCurrentSession(null)
|
||||
this.focusSession()
|
||||
break
|
||||
@@ -1357,6 +1367,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
{ directory: workspaceDir, platform: this.opts.platform },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
this.trackDirectory(session.id, workspaceDir)
|
||||
@@ -1382,7 +1393,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.client.session
|
||||
.get({ sessionID, directory: dir })
|
||||
.then((r) => {
|
||||
if (r.data && !signal?.aborted) {
|
||||
if (r.data && !signal?.aborted && this.contextSessionID === sessionID) {
|
||||
this.setCurrentSession(r.data)
|
||||
this.contextSessionID = r.data.id
|
||||
}
|
||||
@@ -1411,7 +1422,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
options: { mode?: MessageLoadMode; before?: string; limit?: number } = {},
|
||||
): Promise<void> {
|
||||
const mode = options.mode ?? "replace"
|
||||
if (mode !== "prepend") {
|
||||
if (mode === "replace" || mode === "focus") {
|
||||
this.stopCurrentSessionProcesses(sessionID)
|
||||
this.trackedSessionIds.add(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
@@ -1617,7 +1629,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const workspaceDir = this.getSessionDirectory(
|
||||
sessionID,
|
||||
this.currentSession?.id === sessionID ? this.currentSession : undefined,
|
||||
)
|
||||
await stopSessionProcesses(this.client, sessionID, workspaceDir)
|
||||
await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true })
|
||||
this.trackedSessionIds.delete(sessionID)
|
||||
this.streams.drop(sessionID)
|
||||
@@ -1626,6 +1642,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.lastReconciledAt.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.contextSessionID = undefined
|
||||
this.setCurrentSession(null)
|
||||
this.focusSession(undefined)
|
||||
}
|
||||
@@ -2490,6 +2507,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
{ directory: dir, platform: this.opts.platform },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
this.trackDirectory(session.id, dir)
|
||||
@@ -2865,6 +2883,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return self.currentSession
|
||||
},
|
||||
set currentSession(session) {
|
||||
self.stopCurrentSessionProcesses(session?.id)
|
||||
self.setCurrentSession(session)
|
||||
if (session) self.contextSessionID = session.id
|
||||
},
|
||||
@@ -3403,6 +3422,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
})
|
||||
}
|
||||
|
||||
private getSessionDirectory(sessionId: string, session?: Session): string {
|
||||
return this.sessionDirectories.get(sessionId) ?? session?.directory ?? this.getRootDirectory()
|
||||
}
|
||||
|
||||
private getContextDirectory(): string {
|
||||
return resolveContextDirectory({
|
||||
currentSessionID: this.currentSession?.id,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { recordPromotionHandoff } from "./promotion-handoff"
|
||||
import { restoreWorktrees } from "./state-recovery"
|
||||
import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff"
|
||||
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
||||
import { stopSessionProcesses } from "../kilo-provider/background-process"
|
||||
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
@@ -1175,7 +1176,16 @@ export class AgentManagerProvider implements Disposable {
|
||||
const state = this.getStateManager()
|
||||
if (!state) return null
|
||||
|
||||
const dirs = this.panel?.sessions.getSessionDirectories()
|
||||
const dir = state.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd()
|
||||
try {
|
||||
await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir)
|
||||
} catch (err) {
|
||||
this.log("onCloseSession: client not available:", err)
|
||||
}
|
||||
|
||||
state.removeSession(sessionId)
|
||||
this.panel?.sessions.clearSessionDirectory(sessionId)
|
||||
this.pushState()
|
||||
this.log(`Closed session ${sessionId}`)
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
|
||||
export async function stopSessionProcesses(
|
||||
client: KiloClient | null,
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
if (!client) return
|
||||
await client.backgroundProcess
|
||||
.stopSession({ sessionID, directory })
|
||||
.catch((err: unknown) => console.warn("[Kilo New] KiloProvider: Failed to stop background processes:", err))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
|
||||
const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider")
|
||||
|
||||
type Manager = {
|
||||
connectionService: { getClient: () => unknown }
|
||||
panel: {
|
||||
sessions: {
|
||||
getSessionDirectories: () => ReadonlyMap<string, string>
|
||||
clearSessionDirectory: (id: string) => void
|
||||
}
|
||||
}
|
||||
getStateManager: () => unknown
|
||||
getRoot: () => string
|
||||
pushState: () => void
|
||||
log: (...args: unknown[]) => void
|
||||
onCloseSession: (sessionId: string) => Promise<null>
|
||||
}
|
||||
|
||||
function createManager(options?: { dir?: string; panelDir?: string }) {
|
||||
const stopped: unknown[] = []
|
||||
const cleared: string[] = []
|
||||
const removed: string[] = []
|
||||
const client = {
|
||||
backgroundProcess: {
|
||||
stopSession: mock(async (params: unknown) => {
|
||||
stopped.push(params)
|
||||
return { data: {} }
|
||||
}),
|
||||
},
|
||||
}
|
||||
const state = {
|
||||
directoryFor: mock((sessionId: string) => (sessionId === "s1" ? options?.dir : undefined)),
|
||||
removeSession: mock((sessionId: string) => {
|
||||
removed.push(sessionId)
|
||||
}),
|
||||
}
|
||||
const manager = Object.create(AgentManagerProvider.prototype) as Manager
|
||||
manager.connectionService = { getClient: () => client }
|
||||
manager.panel = {
|
||||
sessions: {
|
||||
getSessionDirectories: () => new Map(options?.panelDir ? [["s1", options.panelDir]] : []),
|
||||
clearSessionDirectory: (id) => cleared.push(id),
|
||||
},
|
||||
}
|
||||
manager.getStateManager = () => state
|
||||
manager.getRoot = () => "/repo"
|
||||
manager.pushState = mock(() => undefined)
|
||||
manager.log = mock(() => undefined)
|
||||
|
||||
return { manager, stopped, cleared, removed }
|
||||
}
|
||||
|
||||
describe("AgentManagerProvider closeSession", () => {
|
||||
it("stops background processes in the worktree directory before closing", async () => {
|
||||
const { manager, stopped, cleared, removed } = createManager({ dir: "/repo/worktree" })
|
||||
|
||||
await manager.onCloseSession("s1")
|
||||
|
||||
expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }])
|
||||
expect(removed).toEqual(["s1"])
|
||||
expect(cleared).toEqual(["s1"])
|
||||
})
|
||||
|
||||
it("falls back to session provider directory mappings", async () => {
|
||||
const { manager, stopped } = createManager({ panelDir: "/repo/panel-worktree" })
|
||||
|
||||
await manager.onCloseSession("s1")
|
||||
|
||||
expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/panel-worktree" }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import { stopSessionProcesses } from "../../src/kilo-provider/background-process"
|
||||
|
||||
const warn = console.warn
|
||||
|
||||
afterEach(() => {
|
||||
console.warn = warn
|
||||
})
|
||||
|
||||
describe("stopSessionProcesses", () => {
|
||||
it("stops all background processes for a session in the provided directory", async () => {
|
||||
const calls: unknown[] = []
|
||||
const client = {
|
||||
backgroundProcess: {
|
||||
stopSession: mock(async (params: unknown) => {
|
||||
calls.push(params)
|
||||
return { data: {} }
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await stopSessionProcesses(client as never, "s1", "/repo/worktree")
|
||||
|
||||
expect(calls).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }])
|
||||
})
|
||||
|
||||
it("logs stop failures without throwing", async () => {
|
||||
const warnings: unknown[][] = []
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args)
|
||||
}
|
||||
const err = new Error("stop failed")
|
||||
const client = {
|
||||
backgroundProcess: {
|
||||
stopSession: mock(async () => {
|
||||
throw err
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await stopSessionProcesses(client as never, "s1", "/repo")
|
||||
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]?.[1]).toBe(err)
|
||||
})
|
||||
})
|
||||
@@ -41,13 +41,20 @@ function createClient(options?: {
|
||||
messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
|
||||
messagesData?: unknown[]
|
||||
deleteDeferred?: Deferred<unknown>
|
||||
sessionData?: unknown
|
||||
sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }>
|
||||
}) {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
const stopped: { sessionID: string; directory?: string }[] = []
|
||||
return {
|
||||
calls,
|
||||
stopped,
|
||||
session: {
|
||||
list: async () => ({ data: [] }),
|
||||
get: async () => ({ data: null }),
|
||||
get: async (params: { sessionID: string; directory?: string }) => {
|
||||
if (options?.sessionGet) return options.sessionGet(params)
|
||||
return { data: options?.sessionData ?? null }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async (params: { before?: string; limit?: number }) => {
|
||||
calls.push({ before: params.before, limit: params.limit })
|
||||
@@ -59,6 +66,12 @@ function createClient(options?: {
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
backgroundProcess: {
|
||||
stopSession: async (params: { sessionID: string; directory?: string }) => {
|
||||
stopped.push(params)
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
@@ -97,7 +110,11 @@ function createConnection(client: ReturnType<typeof createClient>) {
|
||||
type ProviderInternals = {
|
||||
connectionState: State
|
||||
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
|
||||
currentSession: { id: string; directory?: string } | null
|
||||
contextSessionID: string | undefined
|
||||
sessionDirectories: Map<string, string>
|
||||
trackedSessionIds: Set<string>
|
||||
stopCurrentSessionProcesses: (next?: string) => void
|
||||
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
|
||||
handleDeleteSession: (sid: string) => Promise<void>
|
||||
}
|
||||
@@ -117,6 +134,90 @@ function makeProvider(client: ReturnType<typeof createClient>) {
|
||||
}
|
||||
|
||||
describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
|
||||
it("stops background processes for the previous session when switching sessions", async () => {
|
||||
const client = createClient({
|
||||
sessionData: { id: "s2", directory: "/repo/worktree", time: { created: 1, updated: 1 } },
|
||||
})
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = { id: "s1", directory: "/repo/old" }
|
||||
|
||||
await internal.handleLoadMessages("s2")
|
||||
|
||||
expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }])
|
||||
})
|
||||
|
||||
it("does not stop background processes twice for focus-mode reconcile", async () => {
|
||||
const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = { id: "s1", directory: "/repo/old" }
|
||||
|
||||
await internal.handleLoadMessages("s2", { mode: "focus" })
|
||||
|
||||
expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }])
|
||||
})
|
||||
|
||||
it("ignores stale focus refreshes after switching sessions", async () => {
|
||||
const s1 = defer<{ data: unknown }>()
|
||||
const s2 = defer<{ data: unknown }>()
|
||||
const client = createClient({
|
||||
sessionGet: async (params) => {
|
||||
if (params.sessionID === "s1") return s1.promise
|
||||
if (params.sessionID === "s2") return s2.promise
|
||||
return { data: null }
|
||||
},
|
||||
})
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = { id: "s1", directory: "/repo/old" }
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
await internal.handleLoadMessages("s1", { mode: "focus" })
|
||||
const load = internal.handleLoadMessages("s2")
|
||||
s2.resolve({ data: { id: "s2", directory: "/repo/new", time: { created: 2, updated: 2 } } })
|
||||
await load
|
||||
await Promise.resolve()
|
||||
expect(internal.currentSession?.id).toBe("s2")
|
||||
|
||||
s1.resolve({ data: { id: "s1", directory: "/repo/old", time: { created: 1, updated: 1 } } })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(internal.currentSession?.id).toBe("s2")
|
||||
expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/old" }])
|
||||
})
|
||||
|
||||
it("stops each synchronously selected session during rapid switches", async () => {
|
||||
const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
|
||||
const client = createClient({ messagesDeferred: messages })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = { id: "s1", directory: "/repo/s1" }
|
||||
internal.contextSessionID = "s1"
|
||||
internal.sessionDirectories.set("s2", "/repo/s2")
|
||||
|
||||
const s2 = internal.handleLoadMessages("s2")
|
||||
const s3 = internal.handleLoadMessages("s3")
|
||||
|
||||
expect(client.stopped).toEqual([
|
||||
{ sessionID: "s1", directory: "/repo/s1" },
|
||||
{ sessionID: "s2", directory: "/repo/s2" },
|
||||
])
|
||||
|
||||
messages.resolve(mkResult([]))
|
||||
await Promise.all([s2, s3])
|
||||
})
|
||||
|
||||
it("stops the selected visible session when clearSession runs with stale currentSession", async () => {
|
||||
const client = createClient()
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = { id: "s1", directory: "/repo/s1" }
|
||||
internal.contextSessionID = "s2"
|
||||
internal.sessionDirectories.set("s2", "/repo/s2")
|
||||
|
||||
internal.stopCurrentSessionProcesses()
|
||||
internal.contextSessionID = undefined
|
||||
internal.currentSession = null
|
||||
|
||||
expect(client.stopped).toEqual([{ sessionID: "s2", directory: "/repo/s2" }])
|
||||
})
|
||||
|
||||
it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => {
|
||||
// Regression: switching to an already-loaded session sent mode: "focus"
|
||||
// which only refreshed session metadata and status — not messages. If
|
||||
@@ -184,6 +285,19 @@ describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
|
||||
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
|
||||
)
|
||||
expect(loaded).toEqual([])
|
||||
expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider.handleDeleteSession / background processes", () => {
|
||||
it("stops session background processes in the session directory before deletion", async () => {
|
||||
const client = createClient()
|
||||
const { internal } = makeProvider(client)
|
||||
internal.sessionDirectories.set("s1", "/repo/worktree")
|
||||
|
||||
await internal.handleDeleteSession("s1")
|
||||
|
||||
expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,14 +7,155 @@
|
||||
* upstream tool registrations have run (i.e. after importing message-part).
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { ToolRegistry } from "@kilocode/kilo-ui/message-part"
|
||||
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
|
||||
import { ToolRegistry, type ToolProps } from "@kilocode/kilo-ui/message-part"
|
||||
|
||||
/** Tools that should be open by default in the VS Code sidebar. */
|
||||
const DEFAULT_OPEN_TOOLS = ["bash"]
|
||||
const registered = new Set<string>()
|
||||
|
||||
const TITLE: Record<string, string> = {
|
||||
start: "Start background process",
|
||||
list: "List background processes",
|
||||
status: "Check background process",
|
||||
logs: "View background logs",
|
||||
stop: "Stop background process",
|
||||
restart: "Restart background process",
|
||||
}
|
||||
const STRUCTURED_ACTIONS = new Set(["start", "status", "stop", "restart"])
|
||||
const STRUCTURED_KEYS = new Set(["id", "status", "pid", "cwd", "command", "last_output"])
|
||||
const LABEL: Record<string, string> = {
|
||||
command: "Command",
|
||||
id: "Process id",
|
||||
last_output: "Last output",
|
||||
pid: "PID",
|
||||
status: "Status",
|
||||
cwd: "Cwd",
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
if (typeof value === "string" && value.trim()) return value.trim()
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function action(input: Record<string, unknown>) {
|
||||
const value = text(input.action)
|
||||
if (!value) return "status"
|
||||
return value
|
||||
}
|
||||
|
||||
function ready(input: Record<string, unknown>) {
|
||||
const value = input.ready
|
||||
if (!value || typeof value !== "object") return []
|
||||
const data = value as Record<string, unknown>
|
||||
return [text(data.port) ? ["Ports", text(data.port)!] : undefined].filter((item): item is [string, string] => !!item)
|
||||
}
|
||||
|
||||
function structured(raw: string | undefined, enabled: boolean) {
|
||||
if (!enabled) return { rows: [], output: output(raw) }
|
||||
|
||||
const lines = raw?.trimEnd().split("\n") ?? []
|
||||
const rows = lines.flatMap((line): [string, string][] => {
|
||||
const match = line.match(/^([a-z_]+):\s*(.*)$/)
|
||||
if (!match || !STRUCTURED_KEYS.has(match[1])) return []
|
||||
const text = match[2].trim()
|
||||
if (!text) return []
|
||||
return [[match[1], text]]
|
||||
})
|
||||
const rest = lines.filter((line) => {
|
||||
const match = line.match(/^([a-z_]+):\s*(.*)$/)
|
||||
return !match || !STRUCTURED_KEYS.has(match[1])
|
||||
})
|
||||
|
||||
return {
|
||||
rows: rows.map((row): [string, string] => [LABEL[row[0]] ?? row[0], row[1]]),
|
||||
output: output(rest.join("\n")),
|
||||
}
|
||||
}
|
||||
|
||||
function find(rows: [string, string][], label: string) {
|
||||
return rows.find((row) => row[0] === label)?.[1]
|
||||
}
|
||||
|
||||
function output(text?: string) {
|
||||
const value = text?.trimEnd()
|
||||
if (!value?.trim()) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
function expanded(status?: string, open?: boolean) {
|
||||
if (open !== undefined) return open
|
||||
return status === "pending" || status === "running" || status === "completed"
|
||||
}
|
||||
|
||||
function BackgroundProcessTool(props: ToolProps) {
|
||||
const act = createMemo(() => action(props.input))
|
||||
const title = createMemo(() => TITLE[act()] ?? "Background process")
|
||||
const data = createMemo(() => structured(props.output, props.status === "completed" && STRUCTURED_ACTIONS.has(act())))
|
||||
const id = createMemo(() => find(data().rows, "Process id") ?? text(props.metadata.processID) ?? text(props.input.id))
|
||||
const status = createMemo(() => find(data().rows, "Status") ?? text(props.metadata.status))
|
||||
const command = createMemo(() => find(data().rows, "Command") ?? text(props.input.command))
|
||||
const cwd = createMemo(() => find(data().rows, "Cwd") ?? text(props.input.cwd))
|
||||
const rows = createMemo(() =>
|
||||
[
|
||||
command() ? ["Command", command()!] : undefined,
|
||||
text(props.input.description) ? ["Description", text(props.input.description)!] : undefined,
|
||||
id() ? ["Process id", id()!] : undefined,
|
||||
status() ? ["Status", status()!] : undefined,
|
||||
cwd() ? ["Cwd", cwd()!] : undefined,
|
||||
!cwd() && text(props.input.workdir) ? ["Workdir", text(props.input.workdir)!] : undefined,
|
||||
...ready(props.input),
|
||||
...data().rows.filter((row) => !["Command", "Process id", "Status", "Cwd"].includes(row[0])),
|
||||
].filter((item): item is [string, string] => !!item),
|
||||
)
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="terminal"
|
||||
trigger={{
|
||||
title: title(),
|
||||
subtitle: command() ?? text(props.input.description) ?? id(),
|
||||
args: [],
|
||||
}}
|
||||
defaultOpen={expanded(props.status, props.defaultOpen)}
|
||||
allowPendingToggle
|
||||
>
|
||||
<Show when={rows().length > 0}>
|
||||
<div data-component="background-process-fields">
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<div data-slot="background-process-field">
|
||||
<span data-slot="background-process-label">{row[0]}</span>
|
||||
<span data-slot="background-process-value">{row[1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={data().output}>
|
||||
{(value) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<pre data-slot="background-process-output">{value()}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
}
|
||||
|
||||
export function registerVscodeToolOverrides() {
|
||||
if (!registered.has("background_process")) {
|
||||
ToolRegistry.register({
|
||||
name: "background_process",
|
||||
render: BackgroundProcessTool,
|
||||
})
|
||||
registered.add("background_process")
|
||||
}
|
||||
|
||||
for (const name of DEFAULT_OPEN_TOOLS) {
|
||||
if (registered.has(name)) continue
|
||||
const upstream = ToolRegistry.render(name)
|
||||
|
||||
@@ -147,6 +147,74 @@ const bashPending = {
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundStartPending: ToolPart = {
|
||||
id: "part-background-start-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: ASST_MSG_ID,
|
||||
type: "tool",
|
||||
callID: "call-background-start-001",
|
||||
tool: "background_process",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
action: "start",
|
||||
command: "bun run dev --host 127.0.0.1",
|
||||
description: "Dev server",
|
||||
workdir: "/project/web",
|
||||
ready: { port: 5173, pattern: "ready in", timeout: 30000 },
|
||||
},
|
||||
metadata: {},
|
||||
time: { start: now - 2500 },
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundStartCompleted: ToolPart = {
|
||||
id: "part-background-start-002",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: ASST_MSG_ID,
|
||||
type: "tool",
|
||||
callID: "call-background-start-002",
|
||||
tool: "background_process",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
action: "start",
|
||||
command: "bun run dev --host 127.0.0.1",
|
||||
description: "Dev server",
|
||||
workdir: "/project/web",
|
||||
ready: { port: 5173, pattern: "ready in", timeout: 30000 },
|
||||
},
|
||||
output: [
|
||||
"id: bgp_01hv8devserver",
|
||||
"status: ready",
|
||||
"pid: 42817",
|
||||
"cwd: /project/web",
|
||||
"command: bun run dev --host 127.0.0.1",
|
||||
"last_output: VITE v5.4.0 ready in 318 ms",
|
||||
].join("\n"),
|
||||
title: "Started background process",
|
||||
metadata: { processID: "bgp?", status: "ready" },
|
||||
time: { start: now - 2400, end: now - 1800 },
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundLogsCompleted: ToolPart = {
|
||||
id: "part-background-logs-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: ASST_MSG_ID,
|
||||
type: "tool",
|
||||
callID: "call-background-logs-001",
|
||||
tool: "background_process",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { action: "logs", id: "bgp_01hv8devserver" },
|
||||
output: ["VITE v5.4.0 ready in 318 ms", "Local: http://127.0.0.1:5173/"].join("\n"),
|
||||
title: "Logs: Dev server",
|
||||
metadata: { processID: "bgp_01hv8devserver", status: "ready" },
|
||||
time: { start: now - 1800, end: now - 1200 },
|
||||
},
|
||||
}
|
||||
|
||||
const textPart: TextPart = {
|
||||
id: "part-text-001",
|
||||
sessionID: SESSION_ID,
|
||||
@@ -544,6 +612,18 @@ export const ToolCards: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const BackgroundProcessToolCards: Story = {
|
||||
name: "Tool Cards — background process",
|
||||
render: () => {
|
||||
const data = dataWith([backgroundStartPending, backgroundStartCompleted, backgroundLogsCompleted])
|
||||
return (
|
||||
<StoryProviders data={data} sessionID={SESSION_ID} status="busy">
|
||||
<AssistantMessage message={baseAssistantMessage} />
|
||||
</StoryProviders>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Chat idle — prompt input placeholder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,3 +14,40 @@
|
||||
[data-component="tool-output"][data-scrollable]:has([data-component="task-tools"]) {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Background Process Tool
|
||||
============================================ */
|
||||
|
||||
[data-component="background-process-fields"] {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
[data-slot="background-process-field"] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: var(--kilo-font-size-12);
|
||||
}
|
||||
|
||||
[data-slot="background-process-label"] {
|
||||
color: var(--text-dimmed);
|
||||
}
|
||||
|
||||
[data-slot="background-process-value"] {
|
||||
color: var(--text-base);
|
||||
font-family: var(--font-mono);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="background-process-output"] {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -168,6 +176,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
// kilocode_change end
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
const deleted = new Set<string>() // kilocode_change
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
|
||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||
@@ -189,6 +198,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
// kilocode_change start
|
||||
deleted.clear()
|
||||
setStore("background_process", {})
|
||||
// kilocode_change end
|
||||
void bootstrap()
|
||||
break
|
||||
case "permission.replied": {
|
||||
@@ -364,10 +377,53 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||
break
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
case "background_process.updated": {
|
||||
const info = event.properties.info
|
||||
deleted.delete(info.id)
|
||||
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": {
|
||||
deleted.add(event.properties.processID)
|
||||
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": {
|
||||
@@ -515,6 +571,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const workspace = project.workspace.current()
|
||||
if (workspace !== syncedWorkspace) {
|
||||
fullSyncedSessions.clear()
|
||||
// kilocode_change start
|
||||
deleted.clear()
|
||||
setStore("background_process", {})
|
||||
// kilocode_change end
|
||||
syncedWorkspace = workspace
|
||||
}
|
||||
const projectPromise = project.sync()
|
||||
@@ -588,9 +648,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
|
||||
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
|
||||
// kilocode_change start
|
||||
sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
// kilocode_change end
|
||||
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), // kilocode_change
|
||||
// kilocode_change start
|
||||
sdk.client.network.list().then((x) => {
|
||||
@@ -601,6 +663,30 @@ 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)
|
||||
}
|
||||
for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id))
|
||||
setStore(
|
||||
"background_process",
|
||||
produce((draft) => {
|
||||
for (const [sessionID, list] of Object.entries(next)) {
|
||||
const items = new Map((draft[sessionID] ?? []).map((item) => [item.id, item]))
|
||||
for (const item of list) {
|
||||
if (deleted.has(item.id)) continue
|
||||
const prev = items.get(item.id)
|
||||
if (!prev || item.time.updated >= prev.time.updated) items.set(item.id, item)
|
||||
}
|
||||
const value = Array.from(items.values()).toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (value.length === 0) delete draft[sessionID]
|
||||
else draft[sessionID] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
// 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,44 @@ export function Session() {
|
||||
const sdk = useSDK()
|
||||
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) {
|
||||
const workspace = project.workspace.current()
|
||||
for (const id of processSessions(sessionID)) {
|
||||
void sdk.client.backgroundProcess.stopSession({ sessionID: id, workspace }).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
|
||||
const prev = processSessionID
|
||||
processSessionID = next
|
||||
if (processGroup(prev) === processGroup(next)) return
|
||||
stopProcesses(prev)
|
||||
})
|
||||
onCleanup(() => {
|
||||
stopProcesses(processSessionID)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
@@ -1707,6 +1750,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 +2166,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,
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
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"
|
||||
import * as Ports from "./ports"
|
||||
|
||||
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 PORT_MS = 2_000
|
||||
|
||||
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),
|
||||
ports: Schema.mutable(Schema.Array(PositiveInt)),
|
||||
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>
|
||||
poll?: ReturnType<typeof setTimeout>
|
||||
scan?: Promise<boolean>
|
||||
disposed?: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
ports: [...info.ports],
|
||||
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 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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function publish(active: Active) {
|
||||
if (active.disposed) return
|
||||
active.scan = (active.scan ?? refresh(active))
|
||||
.then(() => {
|
||||
active.scan = undefined
|
||||
if (active.disposed) return false
|
||||
emit(active)
|
||||
poll(active)
|
||||
return false
|
||||
})
|
||||
.catch((err) => {
|
||||
active.scan = undefined
|
||||
if (active.disposed) return false
|
||||
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 (active.disposed) return false
|
||||
if (changed) emit(active)
|
||||
poll(active)
|
||||
return changed
|
||||
})
|
||||
.catch((err) => {
|
||||
active.scan = undefined
|
||||
if (active.disposed) return false
|
||||
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
|
||||
publish(active)
|
||||
}, PUBLISH_MS)
|
||||
}
|
||||
|
||||
function ready(active: Active) {
|
||||
if (active.disposed) return
|
||||
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) {
|
||||
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)
|
||||
schedule(active)
|
||||
}
|
||||
|
||||
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
|
||||
if (code === null) delete active.info.exitCode
|
||||
else active.info.exitCode = code
|
||||
if (signal === null) delete active.info.signal
|
||||
else 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()
|
||||
active.info.time.ended = active.info.time.updated
|
||||
active.resolve?.(false)
|
||||
active.resolve = undefined
|
||||
publish(active)
|
||||
}
|
||||
|
||||
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" })
|
||||
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 code(err: unknown) {
|
||||
if (!err || typeof err !== "object" || !("code" in err)) return
|
||||
const value = (err as { code?: unknown }).code
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function group(pid: number) {
|
||||
try {
|
||||
process.kill(-pid, 0)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (code(err) === "ESRCH") return false
|
||||
log.debug("failed to probe process group", { err, pid })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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) && !group(pid)) 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
|
||||
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
|
||||
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 readyPattern = pattern(input.ready?.pattern)
|
||||
if (input.ready?.port && (await connected(input.ready.port))) {
|
||||
throw new Error(`Ready port is already in use: ${input.ready.port}`)
|
||||
}
|
||||
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,
|
||||
ports: [],
|
||||
status: input.ready ? "starting" : "running",
|
||||
ready: false,
|
||||
output: "",
|
||||
time: {
|
||||
started: now,
|
||||
updated: now,
|
||||
},
|
||||
},
|
||||
proc,
|
||||
start: { ...input, cwd },
|
||||
pattern: readyPattern,
|
||||
}
|
||||
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, { remove: true, 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 })))
|
||||
}
|
||||
}
|
||||
@@ -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(() => [])
|
||||
}
|
||||
@@ -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,348 @@
|
||||
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 { useProject } from "@tui/context/project"
|
||||
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+o")[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 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 project = useProject()
|
||||
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 })
|
||||
const workspace = project.workspace.current()
|
||||
try {
|
||||
const result =
|
||||
kind === "stop"
|
||||
? await sdk.client.backgroundProcess.stop({ processID: item.id, workspace })
|
||||
: await sdk.client.backgroundProcess.restart({ processID: item.id, workspace })
|
||||
|
||||
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 note = mode() === "all" ? session(sync, item.sessionID) : undefined
|
||||
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, 92),
|
||||
value: item.id,
|
||||
footer,
|
||||
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+o", 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} wrapMode="word">
|
||||
Name: {label(proc())}
|
||||
</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>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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 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?.trim() || 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}>{short(item.command)}</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>
|
||||
)}
|
||||
</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,108 @@
|
||||
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,
|
||||
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.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,62 @@
|
||||
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 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("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,12 @@ 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.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,130 @@
|
||||
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()),
|
||||
)
|
||||
.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,175 @@
|
||||
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: {},
|
||||
}
|
||||
}
|
||||
|
||||
function missing(id: BackgroundProcess.ID) {
|
||||
return {
|
||||
title: "Background process not found",
|
||||
output: `Background process not found: ${id}`,
|
||||
metadata: { processID: id },
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
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 found = yield* Effect.promise(() => BackgroundProcess.get(id))
|
||||
if (!found || found.sessionID !== ctx.sessionID) return missing(id)
|
||||
if (params.action === "logs") {
|
||||
const logs = yield* Effect.promise(() => BackgroundProcess.logs(id))
|
||||
if (!logs) return missing(id)
|
||||
return {
|
||||
title: `Logs: ${title(found)}`,
|
||||
output: logs.output || "(no output)",
|
||||
metadata: { processID: found.id, status: found.status },
|
||||
}
|
||||
}
|
||||
const info =
|
||||
params.action === "stop"
|
||||
? yield* Effect.promise(() => BackgroundProcess.stop(id))
|
||||
: params.action === "restart"
|
||||
? yield* Effect.promise(() => BackgroundProcess.restart(id))
|
||||
: found
|
||||
if (!info) return missing(id)
|
||||
return {
|
||||
title: `${params.action}: ${title(info)}`,
|
||||
output: format(info),
|
||||
metadata: { processID: info.id, status: info.status },
|
||||
}
|
||||
}
|
||||
|
||||
const command = params.command?.trim()
|
||||
if (!command) 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)) {
|
||||
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" || Flag.KILO_CLIENT === "vscode" ? [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)
|
||||
|
||||
@@ -534,6 +534,7 @@ function pathParameterSchema(route: string, name: string) {
|
||||
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
|
||||
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "processID" && route.includes(" /background-process/")) return { type: "string", pattern: "^bgp.*" } // kilocode_change
|
||||
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
|
||||
// /network/* reuses QuestionID (prefix "que"), not a separate brand. // kilocode_change
|
||||
|
||||
@@ -31,6 +31,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"
|
||||
import { z } from "zod"
|
||||
@@ -607,6 +608,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(() => {}),
|
||||
)
|
||||
|
||||
@@ -22,9 +22,10 @@ const _typeCheck: _AssertAgentImplementsACPAgent = true
|
||||
describe("acp.agent interface compliance", () => {
|
||||
// Extract method names from the ACPAgent interface type
|
||||
type ACPAgentMethods = keyof ACPAgent
|
||||
type ACPRuntimeMethod = ACPAgentMethods | "resumeSession" | "closeSession" // kilocode_change
|
||||
|
||||
// Methods that the SDK's router explicitly checks for at runtime
|
||||
const sdkCheckedMethods: ACPAgentMethods[] = [
|
||||
const sdkCheckedMethods: ACPRuntimeMethod[] = [ // kilocode_change
|
||||
// Required
|
||||
"initialize",
|
||||
"newSession",
|
||||
|
||||
@@ -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 ?? (() => []), // kilocode_change
|
||||
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,156 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { BackgroundProcess } from "@/kilocode/background-process"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
function quote(input: string) {
|
||||
const value = input.replaceAll("\\", "/")
|
||||
if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function script(dir: string, name: string, source: string) {
|
||||
const file = path.join(dir, name)
|
||||
await Bun.write(file, source)
|
||||
const bin = quote(process.execPath)
|
||||
const arg = quote(file)
|
||||
if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}`
|
||||
return `${bin} ${arg}`
|
||||
}
|
||||
|
||||
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 = yield* Effect.promise(() =>
|
||||
script(
|
||||
test.directory,
|
||||
"ready.mjs",
|
||||
`console.log("ready")
|
||||
setInterval(() => {}, 1_000)
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
if (process.platform !== "win32") {
|
||||
expect(stopped?.exitCode).toBeUndefined()
|
||||
expect(stopped?.signal).toBe("SIGTERM")
|
||||
}
|
||||
|
||||
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 = yield* Effect.promise(() =>
|
||||
script(
|
||||
test.directory,
|
||||
"tick.mjs",
|
||||
`console.log("ready")
|
||||
setTimeout(() => console.log("tick"), 200)
|
||||
setInterval(() => {}, 1_000)
|
||||
`,
|
||||
),
|
||||
)
|
||||
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))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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,12 @@ describe("Kilo HttpApi bridge", () => {
|
||||
const hono = new Set(openApiRouteKeys(await Server.openapiHono()))
|
||||
const effect = new Set(openApiRouteKeys(effectOpenApi()))
|
||||
const kilo = [
|
||||
`GET ${BackgroundProcessPaths.list}`,
|
||||
"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",
|
||||
@@ -111,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)
|
||||
})
|
||||
|
||||
|
||||
@@ -189,24 +189,35 @@ 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(
|
||||
KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, agent_manager_tool: true } }).map(
|
||||
(tool) => tool.id,
|
||||
),
|
||||
).toEqual(["codebase_search", "semantic_search", "recall", "agent_manager"])
|
||||
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual(["recall"])
|
||||
).toEqual(["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"])
|
||||
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
|
||||
"recall",
|
||||
"background_process",
|
||||
])
|
||||
|
||||
process.env["KILO_CLIENT"] = "desktop"
|
||||
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"])
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["KILO_CLIENT"]
|
||||
if (prev !== undefined) process.env["KILO_CLIENT"] = prev
|
||||
|
||||
@@ -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" | "ports" | "status" | "output"
|
||||
>
|
||||
|
||||
export type TuiSidebarFileItem = {
|
||||
file: string
|
||||
additions: number
|
||||
|
||||
@@ -13,6 +13,16 @@ import type {
|
||||
AuthRemoveResponses,
|
||||
AuthSetErrors,
|
||||
AuthSetResponses,
|
||||
BackgroundProcessGetErrors,
|
||||
BackgroundProcessGetResponses,
|
||||
BackgroundProcessListResponses,
|
||||
BackgroundProcessLogsErrors,
|
||||
BackgroundProcessLogsResponses,
|
||||
BackgroundProcessRestartErrors,
|
||||
BackgroundProcessRestartResponses,
|
||||
BackgroundProcessStopErrors,
|
||||
BackgroundProcessStopResponses,
|
||||
BackgroundProcessStopSessionResponses,
|
||||
CommandListResponses,
|
||||
CommitMessageGenerateErrors,
|
||||
CommitMessageGenerateResponses,
|
||||
@@ -5112,6 +5122,214 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -6690,6 +6908,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 }))
|
||||
|
||||
@@ -27,6 +27,8 @@ export type Event =
|
||||
| EventMessagePartDelta
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventBackgroundProcessUpdated
|
||||
| EventBackgroundProcessDeleted
|
||||
| EventSessionTurnOpen
|
||||
| EventSessionTurnClose
|
||||
| EventSessionDiff
|
||||
@@ -259,6 +261,26 @@ export type PermissionRequest = {
|
||||
}
|
||||
}
|
||||
|
||||
export type BackgroundProcessInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
pid?: number
|
||||
command: string
|
||||
cwd: string
|
||||
description?: string
|
||||
ports: Array<number>
|
||||
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
|
||||
@@ -872,6 +894,8 @@ export type GlobalEvent = {
|
||||
| EventMessagePartDelta
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventBackgroundProcessUpdated
|
||||
| EventBackgroundProcessDeleted
|
||||
| EventSessionTurnOpen
|
||||
| EventSessionTurnClose
|
||||
| EventSessionDiff
|
||||
@@ -1947,6 +1971,12 @@ export type Workspace = {
|
||||
projectID: string
|
||||
}
|
||||
|
||||
export type BackgroundProcessLogs = {
|
||||
id: string
|
||||
sessionID: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export type WorkspaceWarpError = {
|
||||
name: "WorkspaceWarpError"
|
||||
data: {
|
||||
@@ -2636,6 +2666,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"
|
||||
@@ -7346,6 +7393,168 @@ export type ExperimentalWorkspaceWarpResponses = {
|
||||
export type ExperimentalWorkspaceWarpResponse =
|
||||
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
|
||||
|
||||
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 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?: {
|
||||
/**
|
||||
|
||||
@@ -9202,6 +9202,360 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
"pattern": "^bgp.*"
|
||||
},
|
||||
"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",
|
||||
"pattern": "^bgp.*"
|
||||
},
|
||||
"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",
|
||||
"pattern": "^bgp.*"
|
||||
},
|
||||
"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",
|
||||
"pattern": "^bgp.*"
|
||||
},
|
||||
"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"],
|
||||
@@ -12445,6 +12799,12 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventPermissionReplied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventBackground_processUpdated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventBackground_processDeleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionTurnOpen"
|
||||
},
|
||||
@@ -13057,6 +13417,75 @@
|
||||
"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"
|
||||
},
|
||||
"ports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0
|
||||
}
|
||||
},
|
||||
"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", "ports", "status", "ready", "output", "time"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnapshotFileDiff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14878,6 +15307,12 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventPermissionReplied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventBackground_processUpdated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventBackground_processDeleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionTurnOpen"
|
||||
},
|
||||
@@ -17960,6 +18395,22 @@
|
||||
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BackgroundProcessLogs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkspaceWarpError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20205,6 +20656,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": {
|
||||
@@ -23093,6 +23595,10 @@
|
||||
"name": "workspace",
|
||||
"description": "Experimental HttpApi workspace routes."
|
||||
},
|
||||
{
|
||||
"name": "background-process",
|
||||
"description": "Kilo background process routes."
|
||||
},
|
||||
{
|
||||
"name": "commit-message",
|
||||
"description": "Kilo commit message routes."
|
||||
|
||||
Reference in New Issue
Block a user