From cd009c3d8f5cc1101b30d1967e48e565cbda6ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 21 May 2026 09:13:55 -0300 Subject: [PATCH 01/12] feat: background process --- .changeset/background-processes-cli.md | 5 + .../kilo-ui/src/components/message-part.css | 4 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 55 ++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 5 + .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../src/cli/cmd/tui/routes/session/index.tsx | 74 ++- .../cli/cmd/tui/routes/session/permission.tsx | 17 +- packages/opencode/src/cli/ui.ts | 4 +- .../src/kilocode/background-process/index.ts | 461 ++++++++++++++ .../opencode/src/kilocode/cli/cmd/tui/app.tsx | 12 + .../cmd/tui/component/dialog-process-list.tsx | 344 ++++++++++ packages/opencode/src/kilocode/cli/logo.ts | 19 +- .../plugins/sidebar-background-processes.tsx | 83 +++ .../httpapi/groups/background-process.ts | 120 ++++ .../httpapi/handlers/background-process.ts | 69 ++ .../src/kilocode/server/httpapi/instance.ts | 8 + .../src/kilocode/server/httpapi/server.ts | 2 + .../opencode/src/kilocode/server/instance.ts | 2 + .../server/routes/background-process.ts | 147 +++++ .../src/kilocode/tool/background-process.ts | 159 +++++ .../src/kilocode/tool/background-process.txt | 20 + .../opencode/src/kilocode/tool/registry.ts | 13 +- .../src/server/routes/instance/httpapi/api.ts | 2 + packages/opencode/src/session/session.ts | 2 + packages/opencode/test/fixture/tui-plugin.ts | 1 + .../kilocode/background-process-tool.test.ts | 26 + .../test/kilocode/background-process.test.ts | 92 +++ .../kilocode/server/httpapi-bridge.test.ts | 8 + .../kilocode/tool-registry-indexing.test.ts | 9 +- packages/opencode/test/server/auth.test.ts | 3 +- packages/plugin/src/tui.ts | 7 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 267 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 253 ++++++++ packages/sdk/openapi.json | 596 ++++++++++++++++++ .../ui/src/components/provider-icons/types.ts | 30 +- 35 files changed, 2876 insertions(+), 45 deletions(-) create mode 100644 .changeset/background-processes-cli.md create mode 100644 packages/opencode/src/kilocode/background-process/index.ts create mode 100644 packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx create mode 100644 packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx create mode 100644 packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts create mode 100644 packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts create mode 100644 packages/opencode/src/kilocode/server/routes/background-process.ts create mode 100644 packages/opencode/src/kilocode/tool/background-process.ts create mode 100644 packages/opencode/src/kilocode/tool/background-process.txt create mode 100644 packages/opencode/test/kilocode/background-process-tool.test.ts create mode 100644 packages/opencode/test/kilocode/background-process.test.ts diff --git a/.changeset/background-processes-cli.md b/.changeset/background-processes-cli.md new file mode 100644 index 00000000000..23b0f5087b4 --- /dev/null +++ b/.changeset/background-processes-cli.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support tracked background processes in the CLI so agents can start long-running dev servers, inspect their status and logs from `/process`, see them in the sidebar, and stop them automatically when switching sessions or exiting the TUI. diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 414110ec40c..e1b66d14fe9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -197,7 +197,9 @@ [data-slot="user-message-text"] [data-highlight="file"][data-clickable] { cursor: pointer; - transition: background-color 0.15s, box-shadow 0.15s; + transition: + background-color 0.15s, + box-shadow 0.15s; } [data-slot="user-message-text"] [data-highlight="file"][data-clickable]:hover { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9eef7acf58e..b22270458d0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -19,6 +19,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + BackgroundProcessInfo, // kilocode_change } from "@kilocode/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" @@ -77,6 +78,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ todo: { [sessionID: string]: Todo[] } + // kilocode_change start + background_process: { + [sessionID: string]: BackgroundProcessInfo[] + } + // kilocode_change end message: { [sessionID: string]: Message[] } @@ -119,6 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ session_status: {}, session_diff: {}, todo: {}, + background_process: {}, // kilocode_change message: {}, part: {}, lsp: [], @@ -149,6 +156,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ delete draft.session_diff[sessionID] delete draft.session_status[sessionID] delete draft.todo[sessionID] + delete draft.background_process[sessionID] // kilocode_change delete draft.permission[sessionID] delete draft.question[sessionID] delete draft.suggestion[sessionID] @@ -369,6 +377,45 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + // kilocode_change start + case "background_process.updated": { + const info = event.properties.info + const list = store.background_process[info.sessionID] + if (!list) { + setStore("background_process", info.sessionID, [info]) + break + } + const result = Binary.search(list, info.id, (item) => item.id) + if (result.found) { + setStore("background_process", info.sessionID, result.index, reconcile(info)) + break + } + setStore( + "background_process", + info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, info) + }), + ) + break + } + + case "background_process.deleted": { + const list = store.background_process[event.properties.sessionID] + if (!list) break + const result = Binary.search(list, event.properties.processID, (item) => item.id) + if (!result.found) break + setStore( + "background_process", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + // kilocode_change end + // kilocode_change start case "message.updated": { const info = strip(event.properties.info) @@ -601,6 +648,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } setStore("network", reconcile(next)) }), + sdk.client.backgroundProcess.list({ workspace }).then((x) => { + const next: Record = {} + for (const item of x.data ?? []) { + if (!next[item.sessionID]) next[item.sessionID] = [] + next[item.sessionID].push(item) + } + setStore("background_process", reconcile(next)) + }), // kilocode_change end sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data ?? {})) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index cdbbf40e75c..2e2ac7bb615 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -159,6 +159,11 @@ function stateApi(sync: ReturnType): 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] ?? [] }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index bae9f132048..b4e469e5517 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 796ce2c3efb..5103acb00a7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -3,6 +3,7 @@ import { createContext, createEffect, createMemo, + onCleanup, // kilocode_change createSignal, For, Match, @@ -25,6 +26,7 @@ import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, import { Prompt, type PromptRef } from "@tui/component/prompt" // kilocode_change start import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" // kilocode_change end import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" @@ -43,7 +45,10 @@ import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" -import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change +// kilocode_change start +import type { BackgroundProcessTool } from "@/kilocode/tool/background-process" +import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" +// kilocode_change end import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useEditorContext } from "@tui/context/editor" @@ -270,6 +275,25 @@ export function Session() { const sdk = useSDK() const editor = useEditorContext() + // kilocode_change start - background processes are scoped to the visible session + function stopProcesses(sessionID: string) { + void sdk.client.backgroundProcess.stopSession({ sessionID }).catch((err) => { + Log.Default.warn("failed to stop session background processes", { sessionID, err }) + }) + } + + let processSessionID = route.sessionID + createEffect(() => { + const next = route.sessionID + if (processSessionID === next) return + stopProcesses(processSessionID) + processSessionID = next + }) + onCleanup(() => { + stopProcesses(processSessionID) + }) + // kilocode_change end + createEffect(() => { const sessionID = route.sessionID void (async () => { @@ -1707,6 +1731,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess {/* kilocode_change start */} + + + @@ -2120,6 +2147,51 @@ function WebSearch(props: ToolProps) { } // kilocode_change start +function BackgroundProcess(props: ToolProps) { + 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 ( + + {title()} + in {dir()} + · $ {cmd()} + ({status()}) + + ) +} + function SemanticSearch(props: ToolProps) { const meta = createMemo(() => props.metadata as { results?: { length: number }[] }) const args = createMemo(() => props.input as { query?: string; path?: string }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 4f9ec7f14e5..22edf17bcbc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -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, diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 4a49be541ee..1d238027394 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -5,9 +5,7 @@ import { logo as glyphs } from "./logo" import { tui } from "../kilocode/cli/logo" // kilocode_change // kilocode_change start -const wordmark = [ - ...tui(), -] +const wordmark = [...tui()] // kilocode_change end export const CancelledError = NamedError.create("UICancelledError", z.void()) diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts new file mode 100644 index 00000000000..b1e31029b00 --- /dev/null +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -0,0 +1,461 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" +import { Instance, type InstanceContext } from "@/project/instance" +import { SessionID } from "@/session/schema" +import { Shell } from "@/shell/shell" +import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@/util/effect-zod" +import * as Log from "@opencode-ai/core/util/log" +import { spawn, type ChildProcess } from "child_process" +import { Context, Effect, Layer, Schema, Types } from "effect" +import net from "net" +import path from "path" +import z from "zod" + +export namespace BackgroundProcess { + const log = Log.create({ service: "background-process" }) + const MAX = 200 * 1024 + const KILL_MS = 3_000 + const READY_MS = 30_000 + const PUBLISH_MS = 500 + + const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe( + Schema.brand("BackgroundProcessID"), + ) + export type ID = typeof idSchema.Type + export const ID = idSchema.pipe( + withStatics((schema: typeof idSchema) => ({ + ascending: (id?: string) => { + if (id && !id.startsWith("bgp")) throw new Error(`Background process ID must start with bgp: ${id}`) + return schema.make(id ?? Identifier.create("bgp", "ascending")) + }, + zod: zod(schema), + })), + ) + + export const Status = Schema.Literals(["starting", "running", "ready", "exited", "failed", "stopping", "stopped"]) + export type Status = Schema.Schema.Type + + 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> + + export const Info = Schema.Struct({ + id: ID, + sessionID: SessionID, + pid: optionalOmitUndefined(PositiveInt), + command: Schema.String, + cwd: Schema.String, + description: optionalOmitUndefined(Schema.String), + status: Status, + ready: Schema.Boolean, + exitCode: optionalOmitUndefined(Schema.NullOr(NonNegativeInt)), + signal: optionalOmitUndefined(Schema.NullOr(Schema.String)), + output: Schema.String, + time: Schema.Struct({ + started: NonNegativeInt, + updated: NonNegativeInt, + ended: optionalOmitUndefined(NonNegativeInt), + }), + }) + .annotate({ identifier: "BackgroundProcessInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Info = Types.DeepMutable> + + 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> + + 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> + + 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 + } + + type State = { + ctx: InstanceContext + dir: string + processes: Map + } + + class StateService extends Context.Service Effect.Effect }>()( + "@kilocode/BackgroundProcess.State", + ) {} + + function clone(info: Info): Info { + return { + ...info, + time: { ...info.time }, + } + } + + function terminal(status: Status) { + return status === "exited" || status === "failed" || status === "stopped" + } + + function clamp(text: string) { + const buf = Buffer.from(text, "utf-8") + if (buf.length <= MAX) return text + let start = buf.length - MAX + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + return buf.subarray(start).toString("utf-8") + } + + function publish(active: Active) { + Instance.restore(active.ctx, () => { + void Bus.publish(Event.Updated, { info: clone(active.info) }).catch((err) => { + log.warn("failed to publish process update", { err, id: active.info.id }) + }) + }) + } + + function schedule(active: Active) { + if (active.notify) return + active.notify = setTimeout(() => { + active.notify = undefined + publish(active) + }, PUBLISH_MS) + } + + function ready(active: Active) { + if (active.info.ready) return + active.info.ready = true + active.info.status = "ready" + active.info.time.updated = Date.now() + active.resolve?.(true) + active.resolve = undefined + publish(active) + } + + function append(active: Active, chunk: string) { + active.info.output = clamp(active.info.output + chunk) + active.info.time.updated = Date.now() + if (active.pattern?.test(active.info.output)) ready(active) + schedule(active) + } + + function exited(active: Active, code: number | null, signal: NodeJS.Signals | null) { + if (terminal(active.info.status)) return + if (active.notify) clearTimeout(active.notify) + active.notify = undefined + active.info.exitCode = code + active.info.signal = signal + active.info.ready = active.info.ready && code === 0 + active.info.status = active.info.status === "stopping" ? "stopped" : code === 0 ? "exited" : "failed" + active.info.time.updated = Date.now() + active.info.time.ended = active.info.time.updated + active.resolve?.(false) + active.resolve = undefined + publish(active) + } + + function failed(active: Active, err: unknown) { + append(active, `\n${err instanceof Error ? err.message : String(err)}\n`) + exited(active, 1, null) + } + + function connected(port: number) { + return new Promise((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((resolve) => { + const timeout = setTimeout(() => { + if (active.info.status === "starting") { + active.info.status = "running" + active.info.time.updated = Date.now() + publish(active) + } + active.resolve = undefined + resolve(false) + }, input.timeout ?? READY_MS) + active.resolve = (ok) => { + clearTimeout(timeout) + resolve(ok) + } + const poll = async () => { + if (!input.port) return + while (!terminal(active.info.status) && !active.info.ready && active.resolve) { + if (await connected(input.port)) { + ready(active) + return + } + await Bun.sleep(250) + } + } + void poll().catch((err) => { + log.warn("port readiness check failed", { err, id: active.info.id, port: input.port }) + }) + }) + } + + function env() { + const result: NodeJS.ProcessEnv = { + ...process.env, + TERM: "dumb", + } + delete result.KILO_SERVER_PASSWORD + delete result.KILO_SERVER_USERNAME + return result + } + + function stopped(proc: ChildProcess) { + return proc.exitCode !== null || proc.signalCode !== null + } + + function waitExit(proc: ChildProcess, ms: number) { + if (stopped(proc)) return Promise.resolve() + return new Promise((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((resolve) => { + const child = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { + stdio: "ignore", + windowsHide: true, + }) + child.once("exit", () => resolve()) + child.once("error", () => resolve()) + }) + return + } + try { + process.kill(-pid, "SIGTERM") + } catch (err) { + log.warn("failed to terminate process group", { err, pid }) + active.proc.kill("SIGTERM") + } + await waitExit(active.proc, KILL_MS) + if (stopped(active.proc)) return + try { + process.kill(-pid, "SIGKILL") + } catch (err) { + log.warn("failed to kill process group", { err, pid }) + active.proc.kill("SIGKILL") + } + } + + async function terminate(state: State, active: Active, opts?: { remove?: boolean; silent?: boolean }) { + if (!terminal(active.info.status)) { + active.info.status = "stopping" + active.info.time.updated = Date.now() + if (!opts?.silent) publish(active) + await kill(active) + if (!terminal(active.info.status)) exited(active, active.proc.exitCode, active.proc.signalCode) + } + if (!opts?.remove) return + state.processes.delete(active.info.id) + if (active.notify) clearTimeout(active.notify) + active.resolve?.(false) + active.resolve = undefined + if (opts.silent) return + await Instance.restore(active.ctx, () => + Bus.publish(Event.Deleted, { sessionID: active.info.sessionID, processID: active.info.id }).catch((err) => { + log.warn("failed to publish process deletion", { err, id: active.info.id }) + }), + ) + } + + async function launch(state: State, input: StartInput, id = ID.ascending()) { + const sh = Shell.acceptable() + const cwd = path.resolve(state.dir, input.cwd ?? state.dir) + const pattern = input.ready?.pattern ? new RegExp(input.ready.pattern) : undefined + const args = Shell.args(sh, input.command, cwd) + const proc = spawn(sh, args, { + cwd, + env: env(), + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: true, + }) + const now = Date.now() + const active: Active = { + ctx: state.ctx, + info: { + id, + sessionID: input.sessionID, + pid: proc.pid, + command: input.command, + cwd, + description: input.description, + status: input.ready ? "starting" : "running", + ready: false, + output: "", + time: { + started: now, + updated: now, + }, + }, + proc, + start: { ...input, cwd }, + pattern, + } + state.processes.set(id, active) + proc.stdout?.on("data", (chunk) => append(active, chunk.toString("utf-8"))) + proc.stderr?.on("data", (chunk) => append(active, chunk.toString("utf-8"))) + proc.once("error", (err) => failed(active, err)) + proc.once("exit", (code, signal) => { + if (state.processes.get(id) !== active) return + exited(active, code, signal) + }) + publish(active) + if (input.ready) await wait(active, input.ready) + return clone(active.info) + } + + const stateLayer = Layer.effect( + StateService, + Effect.gen(function* () { + const ref = yield* InstanceState.make( + Effect.fn("BackgroundProcess.state")(function* (ctx) { + const state: State = { ctx, dir: ctx.directory, processes: new Map() } + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all( + Array.from(state.processes.values()).map((active) => terminate(state, active, { silent: true })), + ) + state.processes.clear() + }), + ) + return state + }), + ) + return StateService.of({ get: () => InstanceState.get(ref) }) + }), + ) + + const runtime = makeRuntime(StateService, stateLayer) + + function state() { + return runtime.runPromise((svc) => svc.get()) + } + + export async function start(input: StartInput) { + return launch(await state(), input) + } + + export async function list(input?: { sessionID?: SessionID }) { + const current = await state() + return Array.from(current.processes.values()) + .map((active) => clone(active.info)) + .filter((info) => !input?.sessionID || info.sessionID === input.sessionID) + .toSorted((a, b) => a.time.started - b.time.started || a.id.localeCompare(b.id)) + } + + export async function get(id: ID) { + const current = await state() + const active = current.processes.get(id) + return active ? clone(active.info) : undefined + } + + export async function logs(id: ID): Promise { + 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 }))) + } +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 92873540006..88c6dc81e87 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -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(() => ) + }, + }, { get title() { return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode" diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx new file mode 100644 index 00000000000..568d6094b76 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx @@ -0,0 +1,344 @@ +import type { BackgroundProcessInfo } from "@kilocode/sdk/v2" +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useKeybind } from "@tui/context/keybind" +import { useRoute } from "@tui/context/route" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { useTuiConfig } from "@tui/context/tui-config" +import { useToast } from "@tui/ui/toast" +import { getScrollAcceleration } from "@tui/util/scroll" +import { errorMessage } from "@/util/error" +import { Keybind } from "@/util/keybind" +import { Locale } from "@/util/locale" +import { createMemo, createSignal, onMount, Show } from "solid-js" +import stripAnsi from "strip-ansi" + +type Info = BackgroundProcessInfo +type Status = Info["status"] +type Scope = "session" | "all" +type Kind = "stop" | "restart" +type Theme = ReturnType["theme"] + +const stopKey = Keybind.parse("ctrl+x")[0] +const restartKey = Keybind.parse("ctrl+r")[0] +const allKey = Keybind.parse("ctrl+a")[0] + +function terminal(status: Status) { + return status === "exited" || status === "failed" || status === "stopped" +} + +function rank(status: Status) { + if (status === "starting" || status === "stopping") return 0 + if (status === "ready" || status === "running") return 1 + if (status === "failed") return 2 + return 3 +} + +function tone(status: Status, theme: Theme) { + if (status === "ready" || status === "running") return theme.success + if (status === "starting" || status === "stopping") return theme.warning + if (status === "failed") return theme.error + return theme.textMuted +} + +function label(item: Info) { + return item.description?.trim() || item.command +} + +function last(item: Info) { + return stripAnsi(item.output).trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "" +} + +function short(text: string, max = 64) { + return Locale.truncate(text, max) +} + +function useActions() { + const sdk = useSDK() + const toast = useToast() + const [busy, setBusy] = createSignal<{ id: string; kind: Kind }>() + + async function run(kind: Kind, item: Info) { + if (busy()) return + if (kind === "stop" && terminal(item.status)) return + + setBusy({ id: item.id, kind }) + try { + const result = + kind === "stop" + ? await sdk.client.backgroundProcess.stop({ processID: item.id }) + : await sdk.client.backgroundProcess.restart({ processID: item.id }) + + if (result.error) { + toast.show({ + variant: "error", + title: kind === "stop" ? "Failed to stop process" : "Failed to restart process", + message: errorMessage(result.error), + }) + } + } catch (err) { + toast.show({ + variant: "error", + title: kind === "stop" ? "Failed to stop process" : "Failed to restart process", + message: errorMessage(err), + }) + } finally { + setBusy(undefined) + } + } + + return { busy, run } +} + +function StatusMark(props: { status: Status }) { + const { theme } = useTheme() + return * +} + +function Hint(props: { title: string; keys: string; disabled?: boolean; onClick?: () => void }) { + const { theme } = useTheme() + return ( + !props.disabled && props.onClick?.()}> + {props.title}{" "} + {props.keys} + + ) +} + +function session(sync: ReturnType, 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) { + 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(sid() ? "session" : "all") + const mode = createMemo(() => (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[]>(() => { + const busy = actions.busy() + return list().map((item) => { + const title = label(item) + const tail = last(item) + const note = mode() === "all" ? session(sync, item.sessionID) : undefined + const footer = + busy?.id === item.id + ? `${busy.kind === "stop" ? "stopping" : "restarting"}...` + : `${item.status}${item.pid ? ` pid:${item.pid}` : ""}` + const command = note ? `(${short(note, 28)}) ${short(item.command, 40)}` : short(item.command, 58) + + return { + title: short(title, 61), + value: item.id, + description: tail ? `${command} > ${short(tail, 32)}` : command, + footer, + category: item.status, + gutter: () => , + } + }) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + { + dialog.replace(() => ( + dialog.replace(() => )} /> + )) + }} + keybind={[ + { + keybind: stopKey, + title: "stop", + onTrigger: (option) => { + const item = list().find((proc) => proc.id === option.value) + if (item) void actions.run("stop", item) + }, + }, + { + keybind: restartKey, + title: "restart", + onTrigger: (option) => { + const item = list().find((proc) => proc.id === option.value) + if (item) void actions.run("restart", item) + }, + }, + { + keybind: allKey, + title: mode() === "session" ? "all" : "current", + disabled: !sid(), + side: "right", + onTrigger: () => { + setScope((value) => (value === "session" ? "all" : "session")) + }, + }, + ]} + /> + ) +} + +function DialogProcessDetail(props: { id: string; back: () => void }) { + const dialog = useDialog() + const sync = useSync() + const keybind = useKeybind() + const actions = useActions() + const dimensions = useTerminalDimensions() + const config = useTuiConfig() + const { theme } = useTheme() + const item = createMemo(() => all(sync).find((proc) => proc.id === props.id)) + const output = createMemo(() => stripAnsi(item()?.output ?? "")) + const height = createMemo(() => Math.max(4, Math.floor(dimensions().height / 2) - 14)) + const busy = createMemo(() => actions.busy()?.id === props.id) + const stopped = createMemo(() => { + const proc = item() + return proc ? terminal(proc.status) : true + }) + const scroll = createMemo(() => getScrollAcceleration(config)) + let box: ScrollBoxRenderable | undefined + + onMount(() => { + dialog.setSize("large") + }) + + useKeyboard((evt) => { + if (evt.defaultPrevented) return + const proc = item() + if (keybind.match("backspace", evt)) { + evt.preventDefault() + evt.stopPropagation() + props.back() + return + } + if (keybind.match("ctrl+x", evt)) { + evt.preventDefault() + evt.stopPropagation() + if (proc) void actions.run("stop", proc) + return + } + if (keybind.match("ctrl+r", evt)) { + evt.preventDefault() + evt.stopPropagation() + if (proc) void actions.run("restart", proc) + return + } + if (evt.name === "pageup") { + evt.preventDefault() + evt.stopPropagation() + box?.scrollBy(-height()) + return + } + if (evt.name === "pagedown") { + evt.preventDefault() + evt.stopPropagation() + box?.scrollBy(height()) + } + }) + + return ( + + + + {item() ? short(label(item()!), 92) : "Background Process"} + + dialog.clear()}> + esc + + + Process is no longer tracked.}> + {(proc) => ( + <> + + + status{" "} + {proc().status} + {proc().pid ? ` pid ${proc().pid}` : ""} + {proc().exitCode !== undefined ? ` exit ${proc().exitCode}` : ""} + {proc().signal ? ` signal ${proc().signal}` : ""} + + started {Locale.datetime(proc().time.started)} + updated {Locale.datetime(proc().time.updated)} + + {(ended) => ended {Locale.datetime(ended())}} + + + cwd {proc().cwd} + + + command {proc().command} + + + + + Output Buffer + + (box = ref)} + height={height()} + scrollAcceleration={scroll()} + stickyScroll={true} + stickyStart="bottom" + verticalScrollbarOptions={{ visible: true }} + > + No output yet}> + {(text) => ( + + {text()} + + )} + + + + + + + void actions.run("stop", proc())} + /> + void actions.run("restart", proc())} + /> + + pageup/pagedown scroll + + + )} + + + ) +} diff --git a/packages/opencode/src/kilocode/cli/logo.ts b/packages/opencode/src/kilocode/cli/logo.ts index ca818c7189d..fe79cf3be37 100644 --- a/packages/opencode/src/kilocode/cli/logo.ts +++ b/packages/opencode/src/kilocode/cli/logo.ts @@ -14,11 +14,7 @@ const modern = { `████🬺🬏 ██ ██ ██ ██ ██ ██ ██ `, `██ ██ ██████ 🬁🬬████ 🬁🬬██ 🬁🬬████ 🬁🬬████ ██████ `, ], - exit: [ - ` ██ ██ ██🬺🬏 ██ ██ ██🬺🬏 `, - ` ████🬺🬏 ██ ██ ██ ██ `, - ` ██ ██ ██████ 🬁🬬████ 🬁🬬██ `, - ], + exit: [` ██ ██ ██🬺🬏 ██ ██ ██🬺🬏 `, ` ████🬺🬏 ██ ██ ██ ██ `, ` ██ ██ ██████ 🬁🬬████ 🬁🬬██ `], } const fallback = { @@ -33,11 +29,7 @@ const fallback = { `████ ██ ██ ██ ██ ██ ██ ██ `, `██ ██ ██████ ██████ ██ ████ ██████ ██████ `, ], - exit: [ - ` ██ ██ ████ ██ ██ ██ `, - ` ████ ██ ██ ██ ██ `, - ` ██ ██ ██████ ██████ ██ `, - ], + exit: [` ██ ██ ████ ██ ██ ██ `, ` ████ ██ ██ ██ ██ `, ` ██ ██ ██████ ██████ ██ `], } function flag(value: string | undefined) { @@ -77,10 +69,5 @@ export function session( platform = process.platform, ) { const logo = supports(env, platform) ? modern.exit : fallback.exit - return [ - ``, - `${logo[0]}${dim}${title}${normal}`, - `${logo[1]}${dim}kilo -s ${id}${normal}`, - logo[2], - ].join("\n") + return [``, `${logo[0]}${dim}${title}${normal}`, `${logo[1]}${dim}kilo -s ${id}${normal}`, logo[2]].join("\n") } diff --git a/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx b/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx new file mode 100644 index 00000000000..87fe1e1c41b --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx @@ -0,0 +1,83 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiSidebarBackgroundProcessItem } from "@kilocode/plugin/tui" +import { createMemo, createSignal, For, Show } from "solid-js" + +const id = "internal:kilo-sidebar-background-processes" + +function short(text: string, max = 34) { + if (text.length <= max) return text + return text.slice(0, max - 3) + "..." +} + +function last(text: string) { + return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "" +} + +function tone(item: TuiSidebarBackgroundProcessItem, api: TuiPluginApi) { + const theme = api.theme.current + if (item.status === "ready" || item.status === "running") return theme.success + if (item.status === "starting" || item.status === "stopping") return theme.warning + if (item.status === "failed") return theme.error + return theme.textMuted +} + +function label(item: TuiSidebarBackgroundProcessItem) { + return item.description || item.command +} + +function View(props: { api: TuiPluginApi; session_id: string }) { + const [open, setOpen] = createSignal(true) + const theme = () => props.api.theme.current + const list = createMemo(() => props.api.state.session.processes(props.session_id)) + + return ( + 0}> + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + Background Processes + + + + + {(item) => ( + + + ● {short(label(item))} + + + {item.status} + {item.pid ? ` pid:${item.pid}` : ""} + + {short(item.command)} + + {"> " + short(last(item.output))} + + + )} + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 250, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts new file mode 100644 index 00000000000..227220ab0f2 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -0,0 +1,120 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { SessionID } from "@/session/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { described } from "@/server/routes/instance/httpapi/groups/metadata" + +const root = "/background-process" + +export const BackgroundProcessPaths = { + list: root, + create: root, + get: `${root}/:processID`, + logs: `${root}/:processID/logs`, + stop: `${root}/:processID/stop`, + restart: `${root}/:processID/restart`, + stopSession: `${root}/session/:sessionID/stop`, +} as const + +export const Params = Schema.Struct({ processID: BackgroundProcess.ID }) +export const SessionParams = Schema.Struct({ sessionID: SessionID }) + +export const BackgroundProcessApi = HttpApi.make("background-process") + .add( + HttpApiGroup.make("background-process") + .add( + HttpApiEndpoint.get("list", BackgroundProcessPaths.list, { + success: described(Schema.Array(BackgroundProcess.Info), "List of background processes"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.list", + summary: "List background processes", + description: "List tracked background processes for the current instance.", + }), + ), + HttpApiEndpoint.post("create", BackgroundProcessPaths.create, { + payload: BackgroundProcess.StartInput, + success: described(BackgroundProcess.Info, "Created background process"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.create", + summary: "Create background process", + description: "Start a tracked background process.", + }), + ), + HttpApiEndpoint.get("get", BackgroundProcessPaths.get, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Background process info"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.get", + summary: "Get background process", + description: "Get status and retained output for one background process.", + }), + ), + HttpApiEndpoint.get("logs", BackgroundProcessPaths.logs, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Logs, "Background process logs"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.logs", + summary: "Get background process logs", + description: "Get the retained output tail for one background process.", + }), + ), + HttpApiEndpoint.post("stop", BackgroundProcessPaths.stop, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Stopped background process"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.stop", + summary: "Stop background process", + description: "Terminate a background process and its child process tree.", + }), + ), + HttpApiEndpoint.post("restart", BackgroundProcessPaths.restart, { + params: { processID: BackgroundProcess.ID }, + success: described(BackgroundProcess.Info, "Restarted background process"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.restart", + summary: "Restart background process", + description: "Stop and restart a background process with its original command.", + }), + ), + HttpApiEndpoint.post("stopSession", BackgroundProcessPaths.stopSession, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Stopped session background processes"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "backgroundProcess.stopSession", + summary: "Stop session background processes", + description: "Terminate and forget all background processes associated with one session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "background-process", + description: "Kilo background process routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "kilo HttpApi", + version: "0.0.1", + description: "Kilo HttpApi surface.", + }), + ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts new file mode 100644 index 00000000000..2782dcd2013 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts @@ -0,0 +1,69 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import { SessionID } from "@/session/schema" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" + +const missing = () => new HttpApiError.NotFound({}) + +export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, "background-process", (handlers) => + Effect.gen(function* () { + const list = Effect.fn("BackgroundProcessHttpApi.list")(function* () { + return yield* Effect.promise(() => BackgroundProcess.list()) + }) + + const create = Effect.fn("BackgroundProcessHttpApi.create")(function* (ctx: { + payload: BackgroundProcess.StartInput + }) { + return yield* Effect.promise(() => BackgroundProcess.start(ctx.payload)) + }) + + const get = Effect.fn("BackgroundProcessHttpApi.get")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.get(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const logs = Effect.fn("BackgroundProcessHttpApi.logs")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.logs(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const stop = Effect.fn("BackgroundProcessHttpApi.stop")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.stop(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const restart = Effect.fn("BackgroundProcessHttpApi.restart")(function* (ctx: { + params: { processID: BackgroundProcess.ID } + }) { + const info = yield* Effect.promise(() => BackgroundProcess.restart(ctx.params.processID)) + if (!info) return yield* missing() + return info + }) + + const stopSession = Effect.fn("BackgroundProcessHttpApi.stopSession")(function* (ctx: { + params: { sessionID: SessionID } + }) { + yield* Effect.promise(() => BackgroundProcess.stopSession(ctx.params.sessionID)) + return true + }) + + return handlers + .handle("list", list) + .handle("create", create) + .handle("get", get) + .handle("logs", logs) + .handle("stop", stop) + .handle("restart", restart) + .handle("stopSession", stopSession) + }), +) diff --git a/packages/opencode/src/kilocode/server/httpapi/instance.ts b/packages/opencode/src/kilocode/server/httpapi/instance.ts index 980b8f92ce1..b3697b00248 100644 --- a/packages/opencode/src/kilocode/server/httpapi/instance.ts +++ b/packages/opencode/src/kilocode/server/httpapi/instance.ts @@ -1,5 +1,6 @@ import type { Context } from "effect" import type { Hono } from "hono" +import { BackgroundProcessPaths } from "./groups/background-process" import { IndexingPaths } from "./groups/indexing" import { KiloGatewayPaths } from "./groups/kilo-gateway" import { KilocodePaths } from "./groups/kilocode" @@ -12,6 +13,13 @@ import { TelemetryPaths } from "./groups/telemetry" type Handler = (request: Request, context: Context.Context) => Promise export function register(app: Hono, handler: Handler, context: Context.Context) { + app.get(BackgroundProcessPaths.list, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.create, (c) => handler(c.req.raw, context)) + app.get(BackgroundProcessPaths.get, (c) => handler(c.req.raw, context)) + app.get(BackgroundProcessPaths.logs, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.stop, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.restart, (c) => handler(c.req.raw, context)) + app.post(BackgroundProcessPaths.stopSession, (c) => handler(c.req.raw, context)) app.post("/permission/allow-everything", (c) => handler(c.req.raw, context)) app.post("/enhance-prompt", (c) => handler(c.req.raw, context)) app.post("/commit-message", (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index ca9b07c47f2..f82f17fe986 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -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, diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index d309e057723..20a54636151 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -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 diff --git a/packages/opencode/src/kilocode/server/routes/background-process.ts b/packages/opencode/src/kilocode/server/routes/background-process.ts new file mode 100644 index 00000000000..445c2f749e7 --- /dev/null +++ b/packages/opencode/src/kilocode/server/routes/background-process.ts @@ -0,0 +1,147 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { errors } from "@/server/error" +import { NotFoundError } from "@/storage/storage" +import { SessionID } from "@/session/schema" +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { lazy } from "@/util/lazy" +import z from "zod" + +export const BackgroundProcessRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List background processes", + description: "List tracked background processes for the current instance.", + operationId: "backgroundProcess.list", + responses: { + 200: { + description: "List of background processes", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod.array()) } }, + }, + }, + }), + async (c) => c.json(await BackgroundProcess.list()), + ) + .post( + "/", + describeRoute({ + summary: "Create background process", + description: "Start a tracked background process.", + operationId: "backgroundProcess.create", + responses: { + 200: { + description: "Created background process", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(400), + }, + }), + validator("json", BackgroundProcess.StartInput.zod), + async (c) => c.json(await BackgroundProcess.start(c.req.valid("json") as BackgroundProcess.StartInput)), + ) + .get( + "/:processID", + describeRoute({ + summary: "Get background process", + description: "Get status and retained output for one background process.", + operationId: "backgroundProcess.get", + responses: { + 200: { + description: "Background process info", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.get(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .get( + "/:processID/logs", + describeRoute({ + summary: "Get background process logs", + description: "Get the retained output tail for one background process.", + operationId: "backgroundProcess.logs", + responses: { + 200: { + description: "Background process logs", + content: { "application/json": { schema: resolver(BackgroundProcess.Logs.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.logs(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/:processID/stop", + describeRoute({ + summary: "Stop background process", + description: "Terminate a background process and its child process tree.", + operationId: "backgroundProcess.stop", + responses: { + 200: { + description: "Stopped background process", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.stop(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/:processID/restart", + describeRoute({ + summary: "Restart background process", + description: "Stop and restart a background process with its original command.", + operationId: "backgroundProcess.restart", + responses: { + 200: { + description: "Restarted background process", + content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ processID: BackgroundProcess.ID.zod })), + async (c) => { + const info = await BackgroundProcess.restart(c.req.valid("param").processID) + if (!info) throw new NotFoundError({ message: "Background process not found" }) + return c.json(info) + }, + ) + .post( + "/session/:sessionID/stop", + describeRoute({ + summary: "Stop session background processes", + description: "Terminate and forget all background processes associated with one session.", + operationId: "backgroundProcess.stopSession", + responses: { + 200: { + description: "Stopped session background processes", + content: { "application/json": { schema: resolver(z.boolean()) } }, + }, + }, + }), + validator("param", z.object({ sessionID: SessionID.zod })), + async (c) => { + await BackgroundProcess.stopSession(c.req.valid("param").sessionID) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/kilocode/tool/background-process.ts b/packages/opencode/src/kilocode/tool/background-process.ts new file mode 100644 index 00000000000..a9b41b935c3 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/background-process.ts @@ -0,0 +1,159 @@ +import { BackgroundProcess } from "@/kilocode/background-process" +import { Tool } from "@/tool/tool" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { Effect, Schema } from "effect" +import DESCRIPTION from "./background-process.txt" +import path from "path" + +const Action = Schema.Literals(["start", "list", "status", "logs", "stop", "restart"]) +type Action = Schema.Schema.Type + +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 + +type Meta = { + processID?: BackgroundProcess.ID + status?: BackgroundProcess.Status + count?: number +} + +function title(info: BackgroundProcess.Info) { + return info.description ?? info.command +} + +function last(text: string) { + const lines = text.trim().split(/\r?\n/).filter(Boolean) + return lines.at(-1) ?? "" +} + +function format(info: BackgroundProcess.Info) { + return [ + `id: ${info.id}`, + `status: ${info.status}`, + info.pid ? `pid: ${info.pid}` : undefined, + `cwd: ${info.cwd}`, + `command: ${info.command}`, + last(info.output) ? `last_output: ${last(info.output)}` : undefined, + ] + .filter(Boolean) + .join("\n") +} + +function invalid(action: Action, message: string) { + return { + title: "Invalid background process input", + output: `${message} for action: ${action}`, + metadata: {}, + } +} + +export const BackgroundProcessTool = Tool.define( + "background_process", + Effect.succeed({ + description: DESCRIPTION, + parameters: Params, + execute: (params, ctx) => + Effect.gen(function* () { + if (params.action === "list") { + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID: ctx.sessionID })) + return { + title: "Background processes", + output: list.length + ? list.map(format).join("\n\n") + : "No background processes are running for this session.", + metadata: { count: list.length }, + } + } + + if (params.action !== "start") { + const id = params.id + if (!id) return invalid(params.action, "Missing id") + const info = + params.action === "stop" + ? yield* Effect.promise(() => BackgroundProcess.stop(id)) + : params.action === "restart" + ? yield* Effect.promise(() => BackgroundProcess.restart(id)) + : yield* Effect.promise(() => BackgroundProcess.get(id)) + if (!info) { + return { + title: "Background process not found", + output: `Background process not found: ${id}`, + metadata: { processID: id }, + } + } + if (params.action === "logs") { + const logs = yield* Effect.promise(() => BackgroundProcess.logs(id)) + return { + title: `Logs: ${title(info)}`, + output: logs?.output || "(no output)", + metadata: { processID: info.id, status: info.status }, + } + } + return { + title: `${params.action}: ${title(info)}`, + output: format(info), + metadata: { processID: info.id, status: info.status }, + } + } + + const command = params.command + if (!command?.trim()) return invalid(params.action, "Missing command") + const inst = yield* InstanceState.context + const cwd = path.resolve(inst.directory, params.workdir ?? inst.directory) + if (!containsPath(cwd, inst)) { + const pattern = + process.platform === "win32" ? AppFileSystem.normalizePathPattern(path.join(cwd, "*")) : path.join(cwd, "*") + yield* ctx.ask({ + permission: "external_directory", + patterns: [pattern], + always: [pattern], + metadata: { command, access: "unknown" }, + }) + } + yield* ctx.ask({ + permission: "bash", + patterns: [command], + always: [command.split(/\s+/, 1)[0] + " *"], + metadata: { command, description: params.description, action: "start", backgroundProcess: true }, + }) + + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID: ctx.sessionID, + command, + cwd, + description: params.description, + ready: params.ready, + }), + ) + return { + title: `Started: ${title(info)}`, + output: format(info), + metadata: { processID: info.id, status: info.status }, + } + }), + }), +) diff --git a/packages/opencode/src/kilocode/tool/background-process.txt b/packages/opencode/src/kilocode/tool/background-process.txt new file mode 100644 index 00000000000..e88e2aa90b3 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/background-process.txt @@ -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. diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 503214db543..69c927eef32 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -2,6 +2,7 @@ import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" import { AgentManagerTool } from "./agent-manager" +import { BackgroundProcessTool } from "./background-process" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -23,18 +24,23 @@ export namespace KiloToolRegistry { const codebase = yield* CodebaseSearchTool const recall = yield* RecallTool const manager = yield* AgentManagerTool - return { codebase, recall, manager } + const process = yield* BackgroundProcessTool + return { codebase, recall, manager, process } }) } /** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect — * it has no Service deps beyond what Tool.init itself needs. */ - export function build(tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info }, deps: Deps) { + export function build( + tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info }, + deps: Deps, + ) { return Effect.gen(function* () { const base = yield* Effect.all({ codebase: Tool.init(tools.codebase), recall: Tool.init(tools.recall), manager: Tool.init(tools.manager), + process: Tool.init(tools.process), }) const semantic = yield* semanticTool(deps) return { ...base, semantic } @@ -76,13 +82,14 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def }, + tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def }, cfg: { experimental?: { codebase_search?: boolean; agent_manager_tool?: boolean } }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), ...(tools.semantic ? [tools.semantic] : []), tools.recall, + ...(Flag.KILO_CLIENT === "cli" ? [tools.process] : []), // The extension is the only client that can consume the Agent Manager start event. ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.agent_manager_tool === true ? [tools.manager] : []), ] diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 7b7d6b640c2..1121970da6f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -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) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b499b157098..4dac34055e9 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" // kilocode_change start - legacy promise helpers + kilocode extensions import { makeRuntime } from "@/effect/run-service" +import { BackgroundProcess } from "@/kilocode/background-process" import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { fn } from "@/util/fn" // kilocode_change end @@ -605,6 +606,7 @@ export const layer: Layer.Layer 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(() => {}), ) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 4bc067fcb7d..1db181a665b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -285,6 +285,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { count: opts.state?.session?.count ?? (() => 0), diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), + processes: opts.state?.session?.processes ?? (() => []), messages: opts.state?.session?.messages ?? (() => []), status: opts.state?.session?.status ?? (() => undefined), permission: opts.state?.session?.permission ?? (() => []), diff --git a/packages/opencode/test/kilocode/background-process-tool.test.ts b/packages/opencode/test/kilocode/background-process-tool.test.ts new file mode 100644 index 00000000000..89b4cff3199 --- /dev/null +++ b/packages/opencode/test/kilocode/background-process-tool.test.ts @@ -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 } + + 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) + }) +}) diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts new file mode 100644 index 00000000000..4eef503f556 --- /dev/null +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -0,0 +1,92 @@ +import { describe, expect } from "bun:test" +import { Bus } from "@/bus" +import { BackgroundProcess } from "@/kilocode/background-process" +import { SessionID } from "@/session/schema" +import { Effect } from "effect" +import { TestInstance } from "../fixture/fixture" +import { it } from "../lib/effect" + +function update(sessionID: SessionID) { + const state: { off?: () => void; timer?: ReturnType } = {} + const promise = new Promise((resolve, reject) => { + state.timer = setTimeout(() => { + state.off?.() + reject(new Error("timed out waiting for process update")) + }, 5_000) + state.off = Bus.subscribe(BackgroundProcess.Event.Updated, (event) => { + const info = event.properties.info + if (info.sessionID !== sessionID) return + if (!info.output.includes("tick")) return + state.off?.() + if (state.timer) clearTimeout(state.timer) + resolve(info) + }) + }) + return { + promise, + dispose() { + state.off?.() + if (state.timer) clearTimeout(state.timer) + }, + } +} + +describe("BackgroundProcess", () => { + it.instance("starts, reports readiness, and stops a process", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const command = "printf 'ready\\n'; while true; do sleep 1; done" + + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + description: "test server", + ready: { pattern: "ready", timeout: 5_000 }, + }), + ) + + expect(info.status).toBe("ready") + expect(info.output).toContain("ready") + + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(list.map((item) => item.id)).toContain(info.id) + + const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id)) + expect(stopped?.status).toBe("stopped") + + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(next).toEqual([]) + }), + ) + + it.instance("publishes output updates from process callbacks", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const command = "printf 'ready\n'; sleep 0.2; printf 'tick\n'; while true; do sleep 1; done" + const wait = update(sessionID) + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + ready: { pattern: "ready", timeout: 5_000 }, + }), + ) + + try { + const event = yield* Effect.promise(() => wait.promise) + expect(event.id).toBe(info.id) + expect(event.output).toContain("tick") + } finally { + wait.dispose() + yield* Effect.promise(() => BackgroundProcess.stop(info.id)) + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + } + }), + ) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts index 763a9db39b7..1f8933316e8 100644 --- a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { OpenApi } from "effect/unstable/httpapi" +import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental" import { PublicApi } from "../../../src/server/routes/instance/httpapi/public" @@ -79,6 +80,13 @@ describe("Kilo HttpApi bridge", () => { const hono = new Set(openApiRouteKeys(await Server.openapiHono())) const effect = new Set(openApiRouteKeys(effectOpenApi())) const kilo = [ + `GET ${BackgroundProcessPaths.list}`, + `POST ${BackgroundProcessPaths.create}`, + "GET /background-process/{processID}", + "GET /background-process/{processID}/logs", + "POST /background-process/{processID}/stop", + "POST /background-process/{processID}/restart", + "POST /background-process/session/{sessionID}/stop", "POST /permission/allow-everything", "POST /enhance-prompt", "POST /commit-message", diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 1958b6e8925..4bbe67ba9de 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -189,16 +189,21 @@ describe("kilocode tool registry indexing", () => { semantic: def("semantic_search"), recall: def("recall"), manager: def("agent_manager"), + process: def("background_process"), } try { process.env["KILO_CLIENT"] = "cli" - expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"]) + expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual([ + "semantic_search", + "recall", + "background_process", + ]) expect( KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, agent_manager_tool: true } }).map( (tool) => tool.id, ), - ).toEqual(["codebase_search", "semantic_search", "recall"]) + ).toEqual(["codebase_search", "semantic_search", "recall", "background_process"]) process.env["KILO_CLIENT"] = "vscode" expect( diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts index fc693ee76a8..fe54b54677f 100644 --- a/packages/opencode/test/server/auth.test.ts +++ b/packages/opencode/test/server/auth.test.ts @@ -22,7 +22,8 @@ describe("ServerAuth", () => { expect(ServerAuth.headers()).toBeUndefined() }) - test("defaults to the kilo username", () => { // kilocode_change + test("defaults to the kilo username", () => { + // kilocode_change Flag.KILO_SERVER_PASSWORD = "secret" Flag.KILO_SERVER_USERNAME = undefined diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index aea2929e7be..23472e8b9ae 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -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 todo: (sessionID: string) => ReadonlyArray + processes: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray status: (sessionID: string) => SessionStatus | undefined permission: (sessionID: string) => ReadonlyArray @@ -312,6 +314,11 @@ export type TuiSidebarLspItem = Pick export type TuiSidebarTodoItem = Pick +export type TuiSidebarBackgroundProcessItem = Pick< + BackgroundProcessInfo, + "id" | "pid" | "command" | "cwd" | "description" | "status" | "output" +> + export type TuiSidebarFileItem = { file: string additions: number diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 61e87d8f5e1..38e5b035ce2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,6 +13,19 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + BackgroundProcessCreateErrors, + BackgroundProcessCreateResponses, + BackgroundProcessGetErrors, + BackgroundProcessGetResponses, + BackgroundProcessListResponses, + BackgroundProcessLogsErrors, + BackgroundProcessLogsResponses, + BackgroundProcessRestartErrors, + BackgroundProcessRestartResponses, + BackgroundProcessStartInput, + BackgroundProcessStopErrors, + BackgroundProcessStopResponses, + BackgroundProcessStopSessionResponses, CommandListResponses, CommitMessageGenerateErrors, CommitMessageGenerateResponses, @@ -4962,6 +4975,255 @@ export class Tui extends HeyApiClient { } } +export class BackgroundProcess extends HeyApiClient { + /** + * List background processes + * + * List tracked background processes for the current instance. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/background-process", + ...options, + ...params, + }) + } + + /** + * Create background process + * + * Start a tracked background process. + */ + public create( + parameters?: { + directory?: string + workspace?: string + backgroundProcessStartInput?: BackgroundProcessStartInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "backgroundProcessStartInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + BackgroundProcessCreateResponses, + BackgroundProcessCreateErrors, + ThrowOnError + >({ + url: "/background-process", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get background process + * + * Get status and retained output for one background process. + */ + public get( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + 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( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + 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( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + 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( + parameters: { + processID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + 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( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/background-process/session/{sessionID}/stop", + ...options, + ...params, + }) + } +} + export class CommitMessage extends HeyApiClient { /** * Generate commit message @@ -6540,6 +6802,11 @@ export class KiloClient extends HeyApiClient { return (this._tui ??= new Tui({ client: this.client })) } + private _backgroundProcess?: BackgroundProcess + get backgroundProcess(): BackgroundProcess { + return (this._backgroundProcess ??= new BackgroundProcess({ client: this.client })) + } + private _commitMessage?: CommitMessage get commitMessage(): CommitMessage { return (this._commitMessage ??= new CommitMessage({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b0c8abb5540..d695bfe8ff0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -27,6 +27,8 @@ export type Event = | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventBackgroundProcessUpdated + | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose | EventSessionDiff @@ -260,6 +262,25 @@ export type PermissionRequest = { } } +export type BackgroundProcessInfo = { + id: string + sessionID: string + pid?: number + command: string + cwd: string + description?: string + status: "starting" | "running" | "ready" | "exited" | "failed" | "stopping" | "stopped" + ready: boolean + exitCode?: number + signal?: string + output: string + time: { + started: number + updated: number + ended?: number + } +} + export type SnapshotFileDiff = { file: string patch: string @@ -873,6 +894,8 @@ export type GlobalEvent = { | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventBackgroundProcessUpdated + | EventBackgroundProcessDeleted | EventSessionTurnOpen | EventSessionTurnClose | EventSessionDiff @@ -1930,6 +1953,29 @@ export type Workspace = { projectID: string } +export type BackgroundProcessReady = { + pattern?: string + port?: number + timeout?: number +} + +export type BackgroundProcessStartInput = { + sessionID: string + /** + * Command to run in the configured shell + */ + command: string + cwd?: string + description?: string + ready?: BackgroundProcessReady +} + +export type BackgroundProcessLogs = { + id: string + sessionID: string + output: string +} + export type EffectHttpApiErrorUnauthorized = { _tag: "Unauthorized" } @@ -2616,6 +2662,23 @@ export type EventPermissionReplied = { } } +export type EventBackgroundProcessUpdated = { + id: string + type: "background_process.updated" + properties: { + info: BackgroundProcessInfo + } +} + +export type EventBackgroundProcessDeleted = { + id: string + type: "background_process.deleted" + properties: { + sessionID: string + processID: string + } +} + export type EventSessionTurnOpen = { id: string type: "session.turn.open" @@ -7241,6 +7304,196 @@ export type ExperimentalWorkspaceSessionRestoreResponses = { export type ExperimentalWorkspaceSessionRestoreResponse = ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type BackgroundProcessListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/background-process" +} + +export type BackgroundProcessListResponses = { + /** + * List of background processes + */ + 200: Array +} + +export type BackgroundProcessListResponse = BackgroundProcessListResponses[keyof BackgroundProcessListResponses] + +export type BackgroundProcessCreateData = { + body?: BackgroundProcessStartInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/background-process" +} + +export type BackgroundProcessCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type BackgroundProcessCreateError = BackgroundProcessCreateErrors[keyof BackgroundProcessCreateErrors] + +export type BackgroundProcessCreateResponses = { + /** + * Created background process + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessCreateResponse = BackgroundProcessCreateResponses[keyof BackgroundProcessCreateResponses] + +export type BackgroundProcessGetData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}" +} + +export type BackgroundProcessGetErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessGetError = BackgroundProcessGetErrors[keyof BackgroundProcessGetErrors] + +export type BackgroundProcessGetResponses = { + /** + * Background process info + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessGetResponse = BackgroundProcessGetResponses[keyof BackgroundProcessGetResponses] + +export type BackgroundProcessLogsData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/logs" +} + +export type BackgroundProcessLogsErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessLogsError = BackgroundProcessLogsErrors[keyof BackgroundProcessLogsErrors] + +export type BackgroundProcessLogsResponses = { + /** + * Background process logs + */ + 200: BackgroundProcessLogs +} + +export type BackgroundProcessLogsResponse = BackgroundProcessLogsResponses[keyof BackgroundProcessLogsResponses] + +export type BackgroundProcessStopData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/stop" +} + +export type BackgroundProcessStopErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessStopError = BackgroundProcessStopErrors[keyof BackgroundProcessStopErrors] + +export type BackgroundProcessStopResponses = { + /** + * Stopped background process + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessStopResponse = BackgroundProcessStopResponses[keyof BackgroundProcessStopResponses] + +export type BackgroundProcessRestartData = { + body?: never + path: { + processID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/{processID}/restart" +} + +export type BackgroundProcessRestartErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type BackgroundProcessRestartError = BackgroundProcessRestartErrors[keyof BackgroundProcessRestartErrors] + +export type BackgroundProcessRestartResponses = { + /** + * Restarted background process + */ + 200: BackgroundProcessInfo +} + +export type BackgroundProcessRestartResponse = + BackgroundProcessRestartResponses[keyof BackgroundProcessRestartResponses] + +export type BackgroundProcessStopSessionData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/background-process/session/{sessionID}/stop" +} + +export type BackgroundProcessStopSessionResponses = { + /** + * Stopped session background processes + */ + 200: boolean +} + +export type BackgroundProcessStopSessionResponse = + BackgroundProcessStopSessionResponses[keyof BackgroundProcessStopSessionResponses] + export type CommitMessageGenerateData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e1d98a72b22..0358b6132d6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8949,6 +8949,417 @@ ] } }, + "/background-process": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of background processes", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + }, + "description": "List of background processes" + } + } + } + } + }, + "description": "List tracked background processes for the current instance.", + "summary": "List background processes", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Created background process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Start a tracked background process.", + "summary": "Create background process", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessStartInput" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.create({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Background process info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Get status and retained output for one background process.", + "summary": "Get background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.get({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/logs": { + "get": { + "tags": ["background-process"], + "operationId": "backgroundProcess.logs", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Background process logs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessLogs" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Get the retained output tail for one background process.", + "summary": "Get background process logs", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.logs({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/stop": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.stop", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Stopped background process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Terminate a background process and its child process tree.", + "summary": "Stop background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stop({\n ...\n})" + } + ] + } + }, + "/background-process/{processID}/restart": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.restart", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "processID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Restarted background process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Stop and restart a background process with its original command.", + "summary": "Restart background process", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.restart({\n ...\n})" + } + ] + } + }, + "/background-process/session/{sessionID}/stop": { + "post": { + "tags": ["background-process"], + "operationId": "backgroundProcess.stopSession", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Stopped session background processes", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Stopped session background processes" + } + } + } + } + }, + "description": "Terminate and forget all background processes associated with one session.", + "summary": "Stop session background processes", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.stopSession({\n ...\n})" + } + ] + } + }, "/commit-message": { "post": { "tags": ["commit-message"], @@ -12192,6 +12603,12 @@ { "$ref": "#/components/schemas/EventPermissionReplied" }, + { + "$ref": "#/components/schemas/EventBackground_processUpdated" + }, + { + "$ref": "#/components/schemas/EventBackground_processDeleted" + }, { "$ref": "#/components/schemas/EventSessionTurnOpen" }, @@ -12807,6 +13224,68 @@ "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], "additionalProperties": false }, + "BackgroundProcessInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "pid": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["starting", "running", "ready", "exited", "failed", "stopping", "stopped"] + }, + "ready": { + "type": "boolean" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, + "signal": { + "type": "string" + }, + "output": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "started": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["started", "updated"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "command", "cwd", "status", "ready", "output", "time"], + "additionalProperties": false + }, "SnapshotFileDiff": { "type": "object", "properties": { @@ -14628,6 +15107,12 @@ { "$ref": "#/components/schemas/EventPermissionReplied" }, + { + "$ref": "#/components/schemas/EventBackground_processUpdated" + }, + { + "$ref": "#/components/schemas/EventBackground_processDeleted" + }, { "$ref": "#/components/schemas/EventSessionTurnOpen" }, @@ -17647,6 +18132,62 @@ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], "additionalProperties": false }, + "BackgroundProcessReady": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + }, + "port": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "timeout": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "BackgroundProcessStartInput": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "command": { + "type": "string", + "description": "Command to run in the configured shell" + }, + "cwd": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ready": { + "$ref": "#/components/schemas/BackgroundProcessReady" + } + }, + "required": ["sessionID", "command"], + "additionalProperties": false + }, + "BackgroundProcessLogs": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "sessionID", "output"], + "additionalProperties": false + }, "effect_HttpApiError_Unauthorized": { "type": "object", "properties": { @@ -19884,6 +20425,57 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventBackground_processUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["background_process.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/BackgroundProcessInfo" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventBackground_processDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["background_process.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "processID": { + "type": "string" + } + }, + "required": ["sessionID", "processID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionTurnOpen": { "type": "object", "properties": { @@ -22845,6 +23437,10 @@ "name": "workspace", "description": "Experimental HttpApi workspace routes." }, + { + "name": "background-process", + "description": "Kilo background process routes." + }, { "name": "commit-message", "description": "Kilo commit message routes." diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index bb8a45e660d..2f22f2e1bb6 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -7,12 +7,12 @@ export const iconNames = [ "zai", "zai-coding-plan", "xiaomi", - "xiaomi-token-plan-sgp", // kilocode_change - "xiaomi-token-plan-cn", // kilocode_change - "xiaomi-token-plan-ams", // kilocode_change + "xiaomi-token-plan-sgp", + "xiaomi-token-plan-cn", + "xiaomi-token-plan-ams", "xai", "wandb", - "wafer.ai", // kilocode_change + "wafer.ai", "vultr", "vivgrid", "vercel", @@ -20,8 +20,8 @@ export const iconNames = [ "v0", "upstage", "togetherai", - "the-grid-ai", // kilocode_change - "tencent-tokenhub", // kilocode_change + "the-grid-ai", + "tencent-tokenhub", "tencent-coding-plan", "synthetic", "submodel", @@ -32,7 +32,7 @@ export const iconNames = [ "scaleway", "sap-ai-core", "requesty", - "regolo-ai", // kilocode_change + "regolo-ai", "qiniu-ai", "qihang-ai", "privatemode-ai", @@ -48,7 +48,7 @@ export const iconNames = [ "nvidia", "novita-ai", "nova", - "neuralwatt", // kilocode_change + "neuralwatt", "nebius", "nano-gpt", "morph", @@ -56,7 +56,7 @@ export const iconNames = [ "moonshotai-cn", "modelscope", "moark", - "mixlayer", // kilocode_change + "mixlayer", "mistral", "minimax", "minimax-coding-plan", @@ -65,10 +65,10 @@ export const iconNames = [ "meganova", "lucidquery", "lmstudio", - "llmgateway", // kilocode_change + "llmgateway", "llama", "kuae-cloud-coding-plan", - "kiro", // kilocode_change + "kiro", "kimi-for-coding", "kilo", "jiekou", @@ -77,7 +77,7 @@ export const iconNames = [ "inception", "iflowcn", "huggingface", - "hpc-ai", // kilocode_change + "hpc-ai", "helicone", "groq", "google", @@ -86,7 +86,7 @@ export const iconNames = [ "gitlab", "github-models", "github-copilot", - "frogbot", // kilocode_change + "frogbot", "friendli", "firmware", "fireworks-ai", @@ -94,7 +94,7 @@ export const iconNames = [ "evroc", "drun", "dinference", - "digitalocean", // kilocode_change + "digitalocean", "deepseek", "deepinfra", "cortecs", @@ -117,7 +117,7 @@ export const iconNames = [ "alibaba-coding-plan-cn", "alibaba-cn", "aihubmix", - "abliteration-ai", // kilocode_change + "abliteration-ai", "abacus", "302ai", ] as const From 06a6bf715d4390daf28e82bd3953c4c5deb2bb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 21 May 2026 13:58:30 -0300 Subject: [PATCH 02/12] refactor: improve implementation --- .changeset/background-process-ports.md | 5 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 26 +++- .../src/kilocode/background-process/index.ts | 98 ++++++++++++- .../src/kilocode/background-process/ports.ts | 138 ++++++++++++++++++ .../cmd/tui/component/dialog-process-list.tsx | 55 +++---- .../plugins/sidebar-background-processes.tsx | 17 +-- .../httpapi/groups/background-process.ts | 12 -- .../httpapi/handlers/background-process.ts | 7 - .../src/kilocode/server/httpapi/instance.ts | 1 - .../server/routes/background-process.ts | 17 --- .../src/kilocode/tool/background-process.ts | 11 ++ packages/opencode/test/fixture/tui-plugin.ts | 2 +- .../test/kilocode/background-process.test.ts | 26 ++++ .../kilocode/server/httpapi-bridge.test.ts | 3 +- packages/plugin/src/tui.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 44 ------ packages/sdk/js/src/v2/gen/types.gen.ts | 46 +----- packages/sdk/openapi.json | 110 +------------- 19 files changed, 344 insertions(+), 277 deletions(-) create mode 100644 .changeset/background-process-ports.md create mode 100644 packages/opencode/src/kilocode/background-process/ports.ts diff --git a/.changeset/background-process-ports.md b/.changeset/background-process-ports.md new file mode 100644 index 00000000000..dfaaddcc676 --- /dev/null +++ b/.changeset/background-process-ports.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show detected ports for tracked background processes in the TUI sidebar and process detail dialog. diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index b22270458d0..a5c08d899c7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -654,6 +654,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (!next[item.sessionID]) next[item.sessionID] = [] next[item.sessionID].push(item) } + for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id)) setStore("background_process", reconcile(next)) }), // kilocode_change end diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5103acb00a7..bd09dd36cbd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -276,18 +276,36 @@ export function Session() { const editor = useEditorContext() // kilocode_change start - background processes are scoped to the visible session + function processGroup(sessionID: string) { + const info = sync.session.get(sessionID) + return info?.parentID ?? info?.id ?? sessionID + } + + function processSessions(sessionID: string) { + const group = processGroup(sessionID) + const ids = new Set([sessionID, group]) + for (const item of sync.data.session) { + if (item.id === group || item.parentID === group) ids.add(item.id) + } + return Array.from(ids) + } + function stopProcesses(sessionID: string) { - void sdk.client.backgroundProcess.stopSession({ sessionID }).catch((err) => { - Log.Default.warn("failed to stop session background processes", { sessionID, err }) - }) + for (const id of processSessions(sessionID)) { + void sdk.client.backgroundProcess.stopSession({ sessionID: id }).catch((err) => { + Log.Default.warn("failed to stop session background processes", { sessionID: id, err }) + }) + } } let processSessionID = route.sessionID createEffect(() => { const next = route.sessionID if (processSessionID === next) return - stopProcesses(processSessionID) + const prev = processSessionID processSessionID = next + if (processGroup(prev) === processGroup(next)) return + stopProcesses(prev) }) onCleanup(() => { stopProcesses(processSessionID) diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index b1e31029b00..77ef3cfc342 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -14,6 +14,7 @@ import { Context, Effect, Layer, Schema, Types } from "effect" import net from "net" import path from "path" import z from "zod" +import * as Ports from "./ports" export namespace BackgroundProcess { const log = Log.create({ service: "background-process" }) @@ -21,6 +22,7 @@ export namespace BackgroundProcess { const KILL_MS = 3_000 const READY_MS = 30_000 const PUBLISH_MS = 500 + const PORT_MS = 2_000 const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe( Schema.brand("BackgroundProcessID"), @@ -61,6 +63,7 @@ export namespace BackgroundProcess { command: Schema.String, cwd: Schema.String, description: optionalOmitUndefined(Schema.String), + ports: Schema.mutable(Schema.Array(PositiveInt)), status: Status, ready: Schema.Boolean, exitCode: optionalOmitUndefined(Schema.NullOr(NonNegativeInt)), @@ -122,6 +125,9 @@ export namespace BackgroundProcess { pattern?: RegExp resolve?: (ready: boolean) => void notify?: ReturnType + poll?: ReturnType + scan?: Promise + disposed?: boolean } type State = { @@ -137,6 +143,7 @@ export namespace BackgroundProcess { function clone(info: Info): Info { return { ...info, + ports: [...info.ports], time: { ...info.time }, } } @@ -153,7 +160,26 @@ export namespace BackgroundProcess { return buf.subarray(start).toString("utf-8") } - function publish(active: Active) { + function same(a: number[], b: number[]) { + return a.length === b.length && a.every((port, index) => port === b[index]) + } + + async function refresh(active: Active) { + const pid = active.proc.pid + if (!pid || terminal(active.info.status)) { + const changed = active.info.ports.length > 0 + active.info.ports = [] + return changed + } + const fallback = active.info.ready && active.start.ready?.port ? [active.start.ready.port] : [] + const next = Array.from(new Set([...(await Ports.list(pid)), ...fallback])).toSorted((a, b) => a - b) + if (same(active.info.ports, next)) return false + active.info.ports = next + active.info.time.updated = Date.now() + return true + } + + function emit(active: Active) { Instance.restore(active.ctx, () => { void Bus.publish(Event.Updated, { info: clone(active.info) }).catch((err) => { log.warn("failed to publish process update", { err, id: active.info.id }) @@ -161,7 +187,50 @@ export namespace BackgroundProcess { }) } + function publish(active: Active) { + if (active.disposed) return + active.scan = (active.scan ?? refresh(active)) + .then(() => { + active.scan = undefined + emit(active) + poll(active) + return false + }) + .catch((err) => { + active.scan = undefined + log.debug("failed to refresh process ports", { err, id: active.info.id }) + emit(active) + poll(active) + return false + }) + } + + function poll(active: Active) { + if (active.disposed) return + if (terminal(active.info.status)) return + if (active.poll) return + active.poll = setTimeout(() => { + active.poll = undefined + if (active.disposed) return + if (terminal(active.info.status)) return + active.scan = (active.scan ?? refresh(active)) + .then((changed) => { + active.scan = undefined + if (changed) emit(active) + poll(active) + return changed + }) + .catch((err) => { + active.scan = undefined + log.debug("failed to refresh process ports", { err, id: active.info.id }) + poll(active) + return false + }) + }, PORT_MS) + } + function schedule(active: Active) { + if (active.disposed) return if (active.notify) return active.notify = setTimeout(() => { active.notify = undefined @@ -170,6 +239,7 @@ export namespace BackgroundProcess { } function ready(active: Active) { + if (active.disposed) return if (active.info.ready) return active.info.ready = true active.info.status = "ready" @@ -180,6 +250,7 @@ export namespace BackgroundProcess { } function append(active: Active, chunk: string) { + if (active.disposed) return active.info.output = clamp(active.info.output + chunk) active.info.time.updated = Date.now() if (active.pattern?.test(active.info.output)) ready(active) @@ -187,11 +258,15 @@ export namespace BackgroundProcess { } function exited(active: Active, code: number | null, signal: NodeJS.Signals | null) { + if (active.disposed) return if (terminal(active.info.status)) return if (active.notify) clearTimeout(active.notify) + if (active.poll) clearTimeout(active.poll) active.notify = undefined + active.poll = undefined active.info.exitCode = code active.info.signal = signal + active.info.ports = [] active.info.ready = active.info.ready && code === 0 active.info.status = active.info.status === "stopping" ? "stopped" : code === 0 ? "exited" : "failed" active.info.time.updated = Date.now() @@ -202,10 +277,20 @@ export namespace BackgroundProcess { } function failed(active: Active, err: unknown) { + if (active.disposed) return append(active, `\n${err instanceof Error ? err.message : String(err)}\n`) exited(active, 1, null) } + function pattern(input?: string) { + if (!input) return + try { + return new RegExp(input) + } catch (err) { + throw new Error(`Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}`) + } + } + function connected(port: number) { return new Promise((resolve) => { const socket = net.createConnection({ port, host: "127.0.0.1" }) @@ -325,8 +410,10 @@ export namespace BackgroundProcess { if (!terminal(active.info.status)) exited(active, active.proc.exitCode, active.proc.signalCode) } if (!opts?.remove) return + active.disposed = true state.processes.delete(active.info.id) if (active.notify) clearTimeout(active.notify) + if (active.poll) clearTimeout(active.poll) active.resolve?.(false) active.resolve = undefined if (opts.silent) return @@ -340,7 +427,7 @@ export namespace BackgroundProcess { async function launch(state: State, input: StartInput, id = ID.ascending()) { const sh = Shell.acceptable() const cwd = path.resolve(state.dir, input.cwd ?? state.dir) - const pattern = input.ready?.pattern ? new RegExp(input.ready.pattern) : undefined + const readyPattern = pattern(input.ready?.pattern) const args = Shell.args(sh, input.command, cwd) const proc = spawn(sh, args, { cwd, @@ -359,6 +446,7 @@ export namespace BackgroundProcess { command: input.command, cwd, description: input.description, + ports: [], status: input.ready ? "starting" : "running", ready: false, output: "", @@ -369,7 +457,7 @@ export namespace BackgroundProcess { }, proc, start: { ...input, cwd }, - pattern, + pattern: readyPattern, } state.processes.set(id, active) proc.stdout?.on("data", (chunk) => append(active, chunk.toString("utf-8"))) @@ -393,7 +481,9 @@ export namespace BackgroundProcess { yield* Effect.addFinalizer(() => Effect.promise(async () => { await Promise.all( - Array.from(state.processes.values()).map((active) => terminate(state, active, { silent: true })), + Array.from(state.processes.values()).map((active) => + terminate(state, active, { remove: true, silent: true }), + ), ) state.processes.clear() }), diff --git a/packages/opencode/src/kilocode/background-process/ports.ts b/packages/opencode/src/kilocode/background-process/ports.ts new file mode 100644 index 00000000000..7afa14e11e1 --- /dev/null +++ b/packages/opencode/src/kilocode/background-process/ports.ts @@ -0,0 +1,138 @@ +import { Process } from "@/util/process" +import fs from "fs/promises" +import path from "path" + +function sorted(items: Iterable) { + 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() + 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) { + 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) { + 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() + 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(() => []) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx index 568d6094b76..4984e0ca7be 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx @@ -23,7 +23,7 @@ type Scope = "session" | "all" type Kind = "stop" | "restart" type Theme = ReturnType["theme"] -const stopKey = Keybind.parse("ctrl+x")[0] +const stopKey = Keybind.parse("ctrl+o")[0] const restartKey = Keybind.parse("ctrl+r")[0] const allKey = Keybind.parse("ctrl+a")[0] @@ -49,14 +49,14 @@ function label(item: Info) { return item.description?.trim() || item.command } -function last(item: Info) { - return stripAnsi(item.output).trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "" -} - function short(text: string, max = 64) { return Locale.truncate(text, max) } +function ports(item: Info) { + return item.ports.length > 0 ? item.ports.join(", ") : "none" +} + function useActions() { const sdk = useSDK() const toast = useToast() @@ -142,21 +142,14 @@ export function DialogProcessList() { const options = createMemo[]>(() => { const busy = actions.busy() return list().map((item) => { - const title = label(item) - const tail = last(item) const note = mode() === "all" ? session(sync, item.sessionID) : undefined - const footer = - busy?.id === item.id - ? `${busy.kind === "stop" ? "stopping" : "restarting"}...` - : `${item.status}${item.pid ? ` pid:${item.pid}` : ""}` - const command = note ? `(${short(note, 28)}) ${short(item.command, 40)}` : short(item.command, 58) + const footer = busy?.id === item.id ? `${busy.kind === "stop" ? "stopping" : "restarting"}...` : item.pid?.toString() + const title = `${note ? `(${note}) ` : ""}${label(item)} - ${item.command}` return { - title: short(title, 61), + title: short(title, 92), value: item.id, - description: tail ? `${command} > ${short(tail, 32)}` : command, footer, - category: item.status, gutter: () => , } }) @@ -239,7 +232,7 @@ function DialogProcessDetail(props: { id: string; back: () => void }) { props.back() return } - if (keybind.match("ctrl+x", evt)) { + if (keybind.match("ctrl+o", evt)) { evt.preventDefault() evt.stopPropagation() if (proc) void actions.run("stop", proc) @@ -278,23 +271,31 @@ function DialogProcessDetail(props: { id: string; back: () => void }) { {(proc) => ( <> - - status{" "} - {proc().status} - {proc().pid ? ` pid ${proc().pid}` : ""} - {proc().exitCode !== undefined ? ` exit ${proc().exitCode}` : ""} - {proc().signal ? ` signal ${proc().signal}` : ""} + + Name: {label(proc())} - started {Locale.datetime(proc().time.started)} - updated {Locale.datetime(proc().time.updated)} + + Status:{" "} + {proc().status} + + PID: {proc().pid ?? "none"} + Ports: {ports(proc())} + + Exit: {proc().exitCode} + + + {(signal) => Signal: {signal()}} + + Started: {Locale.datetime(proc().time.started)} + Updated: {Locale.datetime(proc().time.updated)} - {(ended) => ended {Locale.datetime(ended())}} + {(ended) => Ended: {Locale.datetime(ended())}} - cwd {proc().cwd} + CWD: {proc().cwd} - command {proc().command} + Command: {proc().command} diff --git a/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx b/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx index 87fe1e1c41b..ddd8a3e9ff4 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-background-processes.tsx @@ -8,10 +8,6 @@ function short(text: string, max = 34) { return text.slice(0, max - 3) + "..." } -function last(text: string) { - return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "" -} - function tone(item: TuiSidebarBackgroundProcessItem, api: TuiPluginApi) { const theme = api.theme.current if (item.status === "ready" || item.status === "running") return theme.success @@ -21,7 +17,7 @@ function tone(item: TuiSidebarBackgroundProcessItem, api: TuiPluginApi) { } function label(item: TuiSidebarBackgroundProcessItem) { - return item.description || item.command + return item.description?.trim() || item.command } function View(props: { api: TuiPluginApi; session_id: string }) { @@ -47,13 +43,12 @@ function View(props: { api: TuiPluginApi; session_id: string }) { ● {short(label(item))} - - {item.status} - {item.pid ? ` pid:${item.pid}` : ""} - {short(item.command)} - - {"> " + short(last(item.output))} + + {(pid) => PID: {pid()}} + + 0}> + PORTS: {item.ports.join(", ")} )} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts index 227220ab0f2..28e2be8a52e 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -11,7 +11,6 @@ const root = "/background-process" export const BackgroundProcessPaths = { list: root, - create: root, get: `${root}/:processID`, logs: `${root}/:processID/logs`, stop: `${root}/:processID/stop`, @@ -35,17 +34,6 @@ export const BackgroundProcessApi = HttpApi.make("background-process") description: "List tracked background processes for the current instance.", }), ), - HttpApiEndpoint.post("create", BackgroundProcessPaths.create, { - payload: BackgroundProcess.StartInput, - success: described(BackgroundProcess.Info, "Created background process"), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "backgroundProcess.create", - summary: "Create background process", - description: "Start a tracked background process.", - }), - ), HttpApiEndpoint.get("get", BackgroundProcessPaths.get, { params: { processID: BackgroundProcess.ID }, success: described(BackgroundProcess.Info, "Background process info"), diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts index 2782dcd2013..5c4ab90e2f1 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/background-process.ts @@ -12,12 +12,6 @@ export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, " return yield* Effect.promise(() => BackgroundProcess.list()) }) - const create = Effect.fn("BackgroundProcessHttpApi.create")(function* (ctx: { - payload: BackgroundProcess.StartInput - }) { - return yield* Effect.promise(() => BackgroundProcess.start(ctx.payload)) - }) - const get = Effect.fn("BackgroundProcessHttpApi.get")(function* (ctx: { params: { processID: BackgroundProcess.ID } }) { @@ -59,7 +53,6 @@ export const backgroundProcessHandlers = HttpApiBuilder.group(InstanceHttpApi, " return handlers .handle("list", list) - .handle("create", create) .handle("get", get) .handle("logs", logs) .handle("stop", stop) diff --git a/packages/opencode/src/kilocode/server/httpapi/instance.ts b/packages/opencode/src/kilocode/server/httpapi/instance.ts index b3697b00248..2e823ed1e4e 100644 --- a/packages/opencode/src/kilocode/server/httpapi/instance.ts +++ b/packages/opencode/src/kilocode/server/httpapi/instance.ts @@ -14,7 +14,6 @@ type Handler = (request: Request, context: Context.Context) => Promise< export function register(app: Hono, handler: Handler, context: Context.Context) { app.get(BackgroundProcessPaths.list, (c) => handler(c.req.raw, context)) - app.post(BackgroundProcessPaths.create, (c) => handler(c.req.raw, context)) app.get(BackgroundProcessPaths.get, (c) => handler(c.req.raw, context)) app.get(BackgroundProcessPaths.logs, (c) => handler(c.req.raw, context)) app.post(BackgroundProcessPaths.stop, (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/src/kilocode/server/routes/background-process.ts b/packages/opencode/src/kilocode/server/routes/background-process.ts index 445c2f749e7..081bc7f1cae 100644 --- a/packages/opencode/src/kilocode/server/routes/background-process.ts +++ b/packages/opencode/src/kilocode/server/routes/background-process.ts @@ -24,23 +24,6 @@ export const BackgroundProcessRoutes = lazy(() => }), async (c) => c.json(await BackgroundProcess.list()), ) - .post( - "/", - describeRoute({ - summary: "Create background process", - description: "Start a tracked background process.", - operationId: "backgroundProcess.create", - responses: { - 200: { - description: "Created background process", - content: { "application/json": { schema: resolver(BackgroundProcess.Info.zod) } }, - }, - ...errors(400), - }, - }), - validator("json", BackgroundProcess.StartInput.zod), - async (c) => c.json(await BackgroundProcess.start(c.req.valid("json") as BackgroundProcess.StartInput)), - ) .get( "/:processID", describeRoute({ diff --git a/packages/opencode/src/kilocode/tool/background-process.ts b/packages/opencode/src/kilocode/tool/background-process.ts index a9b41b935c3..16c9428252b 100644 --- a/packages/opencode/src/kilocode/tool/background-process.ts +++ b/packages/opencode/src/kilocode/tool/background-process.ts @@ -70,6 +70,15 @@ function invalid(action: Action, message: string) { } } +function pattern(ready?: BackgroundProcess.Ready) { + if (!ready?.pattern) return + try { + new RegExp(ready.pattern) + } catch (err) { + return `Invalid ready pattern: ${err instanceof Error ? err.message : String(err)}` + } +} + export const BackgroundProcessTool = Tool.define( "background_process", Effect.succeed({ @@ -121,6 +130,8 @@ export const BackgroundProcessTool = Tool.define 0), diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), - processes: opts.state?.session?.processes ?? (() => []), + processes: opts.state?.session?.processes ?? (() => []), // kilocode_change messages: opts.state?.session?.messages ?? (() => []), status: opts.state?.session?.status ?? (() => undefined), permission: opts.state?.session?.permission ?? (() => []), diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index 4eef503f556..4dc156945c4 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -89,4 +89,30 @@ describe("BackgroundProcess", () => { } }), ) + + it.instance("rejects invalid readiness patterns before launching", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + + const err = yield* Effect.promise(async () => { + try { + await BackgroundProcess.start({ + sessionID, + command: "printf 'ready\n'", + cwd: test.directory, + ready: { pattern: "[", timeout: 1_000 }, + }) + } catch (err) { + return err + } + }) + + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Invalid ready pattern") + + const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) + expect(list).toEqual([]) + }), + ) }) diff --git a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts index 1f8933316e8..9dfa4b8e2ab 100644 --- a/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-bridge.test.ts @@ -81,7 +81,6 @@ describe("Kilo HttpApi bridge", () => { const effect = new Set(openApiRouteKeys(effectOpenApi())) const kilo = [ `GET ${BackgroundProcessPaths.list}`, - `POST ${BackgroundProcessPaths.create}`, "GET /background-process/{processID}", "GET /background-process/{processID}/logs", "POST /background-process/{processID}/stop", @@ -119,6 +118,8 @@ describe("Kilo HttpApi bridge", () => { expect(kilo.filter((route) => !hono.has(route))).toEqual([]) expect(kilo.filter((route) => !effect.has(route))).toEqual([]) + expect(hono.has("POST /background-process")).toBe(false) + expect(effect.has("POST /background-process")).toBe(false) expect(effect.has("GET /indexing/status")).toBe(true) }) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 23472e8b9ae..13cc9040556 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -316,7 +316,7 @@ export type TuiSidebarTodoItem = Pick export type TuiSidebarBackgroundProcessItem = Pick< BackgroundProcessInfo, - "id" | "pid" | "command" | "cwd" | "description" | "status" | "output" + "id" | "pid" | "command" | "cwd" | "description" | "ports" | "status" | "output" > export type TuiSidebarFileItem = { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 38e5b035ce2..94076650f46 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,8 +13,6 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, - BackgroundProcessCreateErrors, - BackgroundProcessCreateResponses, BackgroundProcessGetErrors, BackgroundProcessGetResponses, BackgroundProcessListResponses, @@ -22,7 +20,6 @@ import type { BackgroundProcessLogsResponses, BackgroundProcessRestartErrors, BackgroundProcessRestartResponses, - BackgroundProcessStartInput, BackgroundProcessStopErrors, BackgroundProcessStopResponses, BackgroundProcessStopSessionResponses, @@ -5006,47 +5003,6 @@ export class BackgroundProcess extends HeyApiClient { }) } - /** - * Create background process - * - * Start a tracked background process. - */ - public create( - parameters?: { - directory?: string - workspace?: string - backgroundProcessStartInput?: BackgroundProcessStartInput - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "backgroundProcessStartInput", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - BackgroundProcessCreateResponses, - BackgroundProcessCreateErrors, - ThrowOnError - >({ - url: "/background-process", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Get background process * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d695bfe8ff0..f54bbec62b9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -269,6 +269,7 @@ export type BackgroundProcessInfo = { command: string cwd: string description?: string + ports: Array status: "starting" | "running" | "ready" | "exited" | "failed" | "stopping" | "stopped" ready: boolean exitCode?: number @@ -1953,23 +1954,6 @@ export type Workspace = { projectID: string } -export type BackgroundProcessReady = { - pattern?: string - port?: number - timeout?: number -} - -export type BackgroundProcessStartInput = { - sessionID: string - /** - * Command to run in the configured shell - */ - command: string - cwd?: string - description?: string - ready?: BackgroundProcessReady -} - export type BackgroundProcessLogs = { id: string sessionID: string @@ -7323,34 +7307,6 @@ export type BackgroundProcessListResponses = { export type BackgroundProcessListResponse = BackgroundProcessListResponses[keyof BackgroundProcessListResponses] -export type BackgroundProcessCreateData = { - body?: BackgroundProcessStartInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/background-process" -} - -export type BackgroundProcessCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type BackgroundProcessCreateError = BackgroundProcessCreateErrors[keyof BackgroundProcessCreateErrors] - -export type BackgroundProcessCreateResponses = { - /** - * Created background process - */ - 200: BackgroundProcessInfo -} - -export type BackgroundProcessCreateResponse = BackgroundProcessCreateResponses[keyof BackgroundProcessCreateResponses] - export type BackgroundProcessGetData = { body?: never path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0358b6132d6..d92cf37edbd 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8995,67 +8995,6 @@ "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.list({\n ...\n})" } ] - }, - "post": { - "tags": ["background-process"], - "operationId": "backgroundProcess.create", - "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Created background process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BackgroundProcessInfo" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "description": "Start a tracked background process.", - "summary": "Create background process", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BackgroundProcessStartInput" - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.backgroundProcess.create({\n ...\n})" - } - ] } }, "/background-process/{processID}": { @@ -13246,6 +13185,13 @@ "description": { "type": "string" }, + "ports": { + "type": "array", + "items": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, "status": { "type": "string", "enum": ["starting", "running", "ready", "exited", "failed", "stopping", "stopped"] @@ -13283,7 +13229,7 @@ "additionalProperties": false } }, - "required": ["id", "sessionID", "command", "cwd", "status", "ready", "output", "time"], + "required": ["id", "sessionID", "command", "cwd", "ports", "status", "ready", "output", "time"], "additionalProperties": false }, "SnapshotFileDiff": { @@ -18132,46 +18078,6 @@ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], "additionalProperties": false }, - "BackgroundProcessReady": { - "type": "object", - "properties": { - "pattern": { - "type": "string" - }, - "port": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "timeout": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - "BackgroundProcessStartInput": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "command": { - "type": "string", - "description": "Command to run in the configured shell" - }, - "cwd": { - "type": "string" - }, - "description": { - "type": "string" - }, - "ready": { - "$ref": "#/components/schemas/BackgroundProcessReady" - } - }, - "required": ["sessionID", "command"], - "additionalProperties": false - }, "BackgroundProcessLogs": { "type": "object", "properties": { From 1f24a39ae9e0d17fa4293acde5ddd7f2ae68de70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 21 May 2026 14:04:39 -0300 Subject: [PATCH 03/12] refactor: address the comments --- .../opencode/src/kilocode/background-process/index.ts | 10 ++++++++-- .../opencode/test/kilocode/background-process.test.ts | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 77ef3cfc342..5e275e4fbfa 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -192,12 +192,14 @@ export namespace BackgroundProcess { 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) @@ -216,12 +218,14 @@ export namespace BackgroundProcess { 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 @@ -264,8 +268,10 @@ export namespace BackgroundProcess { if (active.poll) clearTimeout(active.poll) active.notify = undefined active.poll = undefined - active.info.exitCode = code - active.info.signal = signal + 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" diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index 4dc156945c4..e4231d03c8d 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -56,6 +56,8 @@ describe("BackgroundProcess", () => { const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id)) expect(stopped?.status).toBe("stopped") + expect(stopped?.exitCode).toBeUndefined() + expect(stopped?.signal).toBe("SIGTERM") yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID })) From f2053dc8bec10357b4a51b76af55f7908095bd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 21 May 2026 14:38:18 -0300 Subject: [PATCH 04/12] refactor: address the comments --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 32 ++++++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- .../src/kilocode/background-process/index.ts | 22 +++++++++- .../cmd/tui/component/dialog-process-list.tsx | 7 +++- .../src/kilocode/tool/background-process.ts | 41 +++++++++++-------- 5 files changed, 82 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a5c08d899c7..fceb88585a9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -176,6 +176,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // kilocode_change end const fullSyncedSessions = new Set() + const deleted = new Set() // kilocode_change let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { @@ -197,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": { @@ -372,14 +377,17 @@ 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]) @@ -401,6 +409,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } 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) @@ -562,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() @@ -635,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) => { @@ -655,7 +670,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ next[item.sessionID].push(item) } for (const list of Object.values(next)) list.sort((a, b) => a.id.localeCompare(b.id)) - setStore("background_process", reconcile(next)) + 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) => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index bd09dd36cbd..21b0ca4bcb8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -291,8 +291,9 @@ export function Session() { } function stopProcesses(sessionID: string) { + const workspace = project.workspace.current() for (const id of processSessions(sessionID)) { - void sdk.client.backgroundProcess.stopSession({ sessionID: id }).catch((err) => { + void sdk.client.backgroundProcess.stopSession({ sessionID: id, workspace }).catch((err) => { Log.Default.warn("failed to stop session background processes", { sessionID: id, err }) }) } diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 5e275e4fbfa..b347d67e92c 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -362,6 +362,23 @@ export namespace BackgroundProcess { 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((resolve) => { @@ -398,7 +415,7 @@ export namespace BackgroundProcess { active.proc.kill("SIGTERM") } await waitExit(active.proc, KILL_MS) - if (stopped(active.proc)) return + if (stopped(active.proc) && !group(pid)) return try { process.kill(-pid, "SIGKILL") } catch (err) { @@ -434,6 +451,9 @@ export namespace BackgroundProcess { 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, diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx index 4984e0ca7be..2b6b34a1574 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-process-list.tsx @@ -4,6 +4,7 @@ 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" @@ -58,6 +59,7 @@ function ports(item: Info) { } function useActions() { + const project = useProject() const sdk = useSDK() const toast = useToast() const [busy, setBusy] = createSignal<{ id: string; kind: Kind }>() @@ -67,11 +69,12 @@ function useActions() { 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 }) - : await sdk.client.backgroundProcess.restart({ processID: item.id }) + ? await sdk.client.backgroundProcess.stop({ processID: item.id, workspace }) + : await sdk.client.backgroundProcess.restart({ processID: item.id, workspace }) if (result.error) { toast.show({ diff --git a/packages/opencode/src/kilocode/tool/background-process.ts b/packages/opencode/src/kilocode/tool/background-process.ts index 16c9428252b..05ca5154757 100644 --- a/packages/opencode/src/kilocode/tool/background-process.ts +++ b/packages/opencode/src/kilocode/tool/background-process.ts @@ -70,6 +70,14 @@ function invalid(action: Action, message: string) { } } +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 { @@ -100,27 +108,24 @@ export const BackgroundProcessTool = Tool.define 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)) - : yield* Effect.promise(() => BackgroundProcess.get(id)) - if (!info) { - return { - title: "Background process not found", - output: `Background process not found: ${id}`, - metadata: { processID: id }, - } - } - if (params.action === "logs") { - const logs = yield* Effect.promise(() => BackgroundProcess.logs(id)) - return { - title: `Logs: ${title(info)}`, - output: logs?.output || "(no output)", - metadata: { processID: info.id, status: info.status }, - } - } + : found + if (!info) return missing(id) return { title: `${params.action}: ${title(info)}`, output: format(info), @@ -128,8 +133,8 @@ export const BackgroundProcessTool = Tool.define Date: Fri, 22 May 2026 09:50:50 -0300 Subject: [PATCH 05/12] feat: background process on vscode --- .changeset/background-processes-cli.md | 2 +- packages/kilo-vscode/src/KiloProvider.ts | 31 +++- .../src/agent-manager/AgentManagerProvider.ts | 10 ++ .../src/kilo-provider/background-process.ts | 8 + .../unit/agent-manager-close-session.test.ts | 72 +++++++++ .../tests/unit/background-process.test.ts | 46 ++++++ .../unit/kilo-provider-load-messages.test.ts | 116 +++++++++++++- .../components/chat/VscodeToolOverrides.tsx | 147 +++++++++++++++++- .../src/stories/composite.stories.tsx | 83 ++++++++++ .../webview-ui/src/styles/tool-overrides.css | 37 +++++ .../opencode/src/kilocode/tool/registry.ts | 2 +- .../kilocode/tool-registry-indexing.test.ts | 10 +- 12 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider/background-process.ts create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts create mode 100644 packages/kilo-vscode/tests/unit/background-process.test.ts diff --git a/.changeset/background-processes-cli.md b/.changeset/background-processes-cli.md index 23b0f5087b4..6aed067705b 100644 --- a/.changeset/background-processes-cli.md +++ b/.changeset/background-processes-cli.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Support tracked background processes in the CLI so agents can start long-running dev servers, inspect their status and logs from `/process`, see them in the sidebar, and stop them automatically when switching sessions or exiting the TUI. +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. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b4fe6a4ecb2..efe27308c6e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { 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 @@ -1615,7 +1627,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) @@ -1624,6 +1640,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) } @@ -2488,6 +2505,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) @@ -2863,6 +2881,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 }, @@ -3401,6 +3420,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, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 8f7cad3014a..d14dcbaf8ec 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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 diff --git a/packages/kilo-vscode/src/kilo-provider/background-process.ts b/packages/kilo-vscode/src/kilo-provider/background-process.ts new file mode 100644 index 00000000000..98379b19ea5 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/background-process.ts @@ -0,0 +1,8 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export async function stopSessionProcesses(client: KiloClient | null, sessionID: string, directory: string): Promise { + if (!client) return + await client.backgroundProcess + .stopSession({ sessionID, directory }) + .catch((err: unknown) => console.warn("[Kilo New] KiloProvider: Failed to stop background processes:", err)) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts new file mode 100644 index 00000000000..61292540013 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts @@ -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 + clearSessionDirectory: (id: string) => void + } + } + getStateManager: () => unknown + getRoot: () => string + pushState: () => void + log: (...args: unknown[]) => void + onCloseSession: (sessionId: string) => Promise +} + +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" }]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/background-process.test.ts b/packages/kilo-vscode/tests/unit/background-process.test.ts new file mode 100644 index 00000000000..0990f0417c0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/background-process.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 04a10c4f5bb..ff1b0fe5f95 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -41,13 +41,20 @@ function createClient(options?: { messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }> messagesData?: unknown[] deleteDeferred?: Deferred + 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) { type ProviderInternals = { connectionState: State webview: { postMessage: (message: unknown) => Promise } | null + currentSession: { id: string; directory?: string } | null + contextSessionID: string | undefined + sessionDirectories: Map trackedSessionIds: Set + stopCurrentSessionProcesses: (next?: string) => void handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -117,6 +134,90 @@ function makeProvider(client: ReturnType) { } 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 @@ -183,6 +284,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" }]) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx index 7c262d26e3d..1d270c957df 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx @@ -7,14 +7,159 @@ * 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() +const TITLE: Record = { + 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 = { + 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) { + const value = text(input.action) + if (!value) return "status" + return value +} + +function ready(input: Record) { + const value = input.ready + if (!value || typeof value !== "object") return [] + const data = value as Record + 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 ( + + 0}> +
+ + {(row) => ( +
+ {row[0]} + {row[1]} +
+ )} +
+
+
+ + {(value) => ( +
+
{value()}
+
+ )} +
+
+ ) +} + 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) diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 30739bd3315..c4d7636ccb9 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -147,6 +147,77 @@ 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 +615,18 @@ export const ToolCards: Story = { }, } +export const BackgroundProcessToolCards: Story = { + name: "Tool Cards — background process", + render: () => { + const data = dataWith([backgroundStartPending, backgroundStartCompleted, backgroundLogsCompleted]) + return ( + + + + ) + }, +} + // --------------------------------------------------------------------------- // 5. Chat idle — prompt input placeholder // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css index 853b00cdc1a..577a3a9be4e 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css +++ b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css @@ -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: 12px; +} + +[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; +} diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 69c927eef32..497dabcc90f 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -89,7 +89,7 @@ export namespace KiloToolRegistry { ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), ...(tools.semantic ? [tools.semantic] : []), tools.recall, - ...(Flag.KILO_CLIENT === "cli" ? [tools.process] : []), + ...(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] : []), ] diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 4bbe67ba9de..598b46adb6a 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -210,8 +210,14 @@ describe("kilocode tool registry indexing", () => { 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 From 33221b1711f6fdc8fd798f0a25c4900fa7c6144c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 13:02:41 +0000 Subject: [PATCH 06/12] chore: update kilo-vscode visual regression baselines --- .../background-process-tool-cards-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png new file mode 100644 index 00000000000..b557ae59935 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18d394d726ebee312e54db5d45f55cca3a1728d2b41f9f73bb9b610096bf53e0 +size 41004 From 9c003c4685a6866bb75920b58ebc1ab17dd9bd45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 10:07:23 -0300 Subject: [PATCH 07/12] fix: fix vscode font size --- packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css index 577a3a9be4e..2eefff2c787 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css +++ b/packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css @@ -31,7 +31,7 @@ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr); gap: 8px; align-items: baseline; - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="background-process-label"] { From 2062335803d20c0877165c27fa23ad9a580478b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 10:40:06 -0300 Subject: [PATCH 08/12] fix: fix test --- .../src/server/routes/instance/httpapi/public.ts | 1 + packages/sdk/openapi.json | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index fed4aca2017..70d4e2b0df6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6bbec18baf7..89bd016ba98 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9275,7 +9275,8 @@ "name": "processID", "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^bgp.*" }, "required": true } @@ -9337,7 +9338,8 @@ "name": "processID", "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^bgp.*" }, "required": true } @@ -9399,7 +9401,8 @@ "name": "processID", "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^bgp.*" }, "required": true } @@ -9461,7 +9464,8 @@ "name": "processID", "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^bgp.*" }, "required": true } From e0ee13290fc150c8de6ac1d34f36167430e463ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 10:44:54 -0300 Subject: [PATCH 09/12] fix: merge --- packages/sdk/js/src/v2/gen/types.gen.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e09eb494691..a6d9eee91ff 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1971,18 +1971,17 @@ export type Workspace = { projectID: string } -<<<<<<< dynamic-department export type BackgroundProcessLogs = { id: string sessionID: string output: string -======= +} + export type WorkspaceWarpError = { name: "WorkspaceWarpError" data: { message: string } ->>>>>>> main } export type EffectHttpApiErrorUnauthorized = { From 5696a1937e293c0a972080422c29349e5a800c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 10:52:14 -0300 Subject: [PATCH 10/12] fix: test --- .../build-tasks/.gradle/file-system.probe | Bin 8 -> 8 bytes packages/opencode/test/acp/agent-interface.test.ts | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/file-system.probe b/packages/kilo-jetbrains/build-tasks/.gradle/file-system.probe index 25eb3b63db9364597f6b5963e770bdb5eb9c3a1d..8ef9165978017e1e7d5499cd3f26dd01dfed0eb2 100644 GIT binary patch literal 8 PcmZQzV4UawZpASG2|xo& literal 8 PcmZQzV4UYV_u5tf2yg=p diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts index 7c4633d7d82..68414fbee58 100644 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ b/packages/opencode/test/acp/agent-interface.test.ts @@ -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", From 697fa4e80f5b7a49c2f51b0907dfef56b1a9dc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 11:05:40 -0300 Subject: [PATCH 11/12] fix: format --- .../executionHistory/executionHistory.bin | Bin 365994 -> 390843 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/outputFiles.bin | Bin 19073 -> 19505 bytes .../src/kilo-provider/background-process.ts | 6 +++++- .../components/chat/VscodeToolOverrides.tsx | 8 ++------ .../src/stories/composite.stories.tsx | 5 +---- 7 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.bin b/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.bin index 6955fe3fdf1083ef6a32b6e0c094fc5fac0c1014..4029e652d0da0d33409679af40b21acef4028a36 100644 GIT binary patch delta 8931 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 delta 495 zcmdn}R(#cJu?ZrQtPEhVES7fyL~sl-w31wyO&H%z7qnx| z6JTWQ%gZmyP0Y!xN-W9D&)Y6w#VWzZ_+;|Ne&5Lk$HX@WY_w#Wo|nT~J9$pB_T)`l z5+?_zC;;iN@={4U$3h&wIegUIckrf>$nEZ_6W2d6?mouE!U%NYpXnD>8SNRxrVFYu z8pxR#8>Sd0T3DE-S{Np!nj5AWnOT?`8>A$g85txdPH%VxRJeZ~tM>E-K-HU_(v%$7 z_#=R!7%|z;Lq=)(B4XUtI8~`%&0OgPAWDj6|j89yT5B`e#pVy)442k6|8PDV+d{E*v?d7-@e9| T5r~<9m>Gy!wy*JJ{pSt<^-;eW diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.lock b/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.lock index 0be136ab1bb6c1dae3a34c9d4e2631f6e57a9cee..d6b9a37badb30b7e3bb9c6884c22335c88f0e79b 100644 GIT binary patch literal 17 TcmZQ(vGNSwR4DM10Rln*BL@Rs literal 17 TcmZQ(vGNSwR4DM10RnshBLD+e diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/packages/kilo-jetbrains/build-tasks/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 4824deddd8cfb2ea20b3225ddb358c66b2064506..4b8c6caa37c282bcbc388a21fbb572d46c32a467 100644 GIT binary patch literal 17 TcmZRUa?RnEx7S$A00GAU7@z{l literal 17 TcmZRUa?RnEx7S$A00Cpu)kHkI%Ym?&T=Vxd^F2qNjjJGoTCLSXKj zH>PImRQ5r2OrE?{LP20P!_P?zBc9HK2zqBv-Ya3DvFpK(_^)zNhoOSCFtc|vT+g*S zWv>Gjzdrdb(1ariHdQJ6Cl*1CeLI;|GDG0b=S_A;b{(yOs&t>6DruqdQET<`^!hwo zsNhSOvETRlyzqSVQxR&y?a5Om0|ZzfHQC31r4%%RGM9b-zB}#%M5T{B z%-B%YGFyRPsnEFaS~|H^D#Ib^&%$d9kKc`i+GPq;oStME=6iQT15|t=Og!re+lBgn ziA_-P-;VzgwSS34+o~n{+`a06Fe3H PvDst+FCieU0;J;sJIm(t delta 91 zcmdlugRyZc;|3E6Muo|~5+0M+N*GN3Dj_jhRnlN`tE9x_t&$0ob)^g@_eyz8ehZ|1 tr6ne>l?JK-(yB5NlXGPfCX31%OpcXRVqzBDsQ5#CqXCELW=9VPCIFWS9k>7h diff --git a/packages/kilo-vscode/src/kilo-provider/background-process.ts b/packages/kilo-vscode/src/kilo-provider/background-process.ts index 98379b19ea5..1e59a3a8baf 100644 --- a/packages/kilo-vscode/src/kilo-provider/background-process.ts +++ b/packages/kilo-vscode/src/kilo-provider/background-process.ts @@ -1,6 +1,10 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -export async function stopSessionProcesses(client: KiloClient | null, sessionID: string, directory: string): Promise { +export async function stopSessionProcesses( + client: KiloClient | null, + sessionID: string, + directory: string, +): Promise { if (!client) return await client.backgroundProcess .stopSession({ sessionID, directory }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx index 1d270c957df..c04c6cf870e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx @@ -51,9 +51,7 @@ function ready(input: Record) { const value = input.ready if (!value || typeof value !== "object") return [] const data = value as Record - return [ - text(data.port) ? ["Ports", text(data.port)!] : undefined, - ].filter((item): item is [string, string] => !!item) + return [text(data.port) ? ["Ports", text(data.port)!] : undefined].filter((item): item is [string, string] => !!item) } function structured(raw: string | undefined, enabled: boolean) { @@ -110,9 +108,7 @@ function BackgroundProcessTool(props: ToolProps) { 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]), - ), + ...data().rows.filter((row) => !["Command", "Process id", "Status", "Cwd"].includes(row[0])), ].filter((item): item is [string, string] => !!item), ) diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index c4d7636ccb9..079067ade3e 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -208,10 +208,7 @@ const backgroundLogsCompleted: ToolPart = { 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"), + 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 }, From b96bf3da31e4fd5935e09a7255b35256ec3d6203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 22 May 2026 13:19:07 -0300 Subject: [PATCH 12/12] fix: tests / gradle --- .../executionHistory/executionHistory.bin | Bin 390843 -> 0 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 0 bytes .../buildOutputCleanup.lock | Bin 17 -> 0 bytes .../buildOutputCleanup/cache.properties | 2 - .../buildOutputCleanup/outputFiles.bin | Bin 19505 -> 0 bytes .../build-tasks/.gradle/file-system.probe | Bin 8 -> 0 bytes .../test/kilocode/background-process.test.ts | 44 ++++++++++++++++-- 7 files changed, 40 insertions(+), 6 deletions(-) delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.bin delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.lock delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/buildOutputCleanup/cache.properties delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/buildOutputCleanup/outputFiles.bin delete mode 100644 packages/kilo-jetbrains/build-tasks/.gradle/file-system.probe diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.bin b/packages/kilo-jetbrains/build-tasks/.gradle/9.4.1/executionHistory/executionHistory.bin deleted file mode 100644 index 4029e652d0da0d33409679af40b21acef4028a36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 390843 zcmeF42V4`^AHb6!iaTmm9JLPIhFmU}jipjlqzxVsS_j~*MYr*6B3cp(aI z;55K#fYSh{0Zs#)1~?6H8sId*X@JuJrvXj_oCY`za2nt=z-fTf0H*;?1Dpo(ga*0- zDGJe&_|_9pzZCDs<3UBhF9l{+dh}V7nD(>fYr(^V%>Kc`qN2ywyaK;kKId&d%zVCm zUddixt1peWe9kZWlKH$=*DlGwqtAb}eD0mFlKEWP?6B@k|A(9De3t&id>(M6U2xLw z$KB}9g9Dh)J9eC{Ej;e-F3aa$t?DzMGk=56x6x_1KRFF>8sId*X@JuJrvXj_oCY`z za2nt=z-fTf0H*;?1Dpmp4R9LZG{9+q(*UOdP6M0t$lqYp8%U#BLz;NPqJI?b^-!mZX)tZ^kb)1d zE>F5Tb>uy14KMJGu7EzZ+@Cip{~nWvoW z9k#H~oq&YG=L-ngpYXQXnOckL!eT(_{3Bwctfua0)Z?Vd)QL3dV~pT8w-)~Ec?}u1 z{dB2a`72&duY12viS8SVB^vd5b7upI`$_z9)I@eBbta8j6HD^r?Y<#Mler^m4)+>1 z+x}CHu_I}W(wI!3Mwoc>?7xaJXf-&fpr+1ds-A|$`7EQVbQB{_a)CXE_hSU@;xG?8YnQC+(Pij?+`Xmq$Xh9Fx;8O(8@7Ml1O zg@#=g8U`)VTIg9FT6SqBY7-PFrkkpWu5zXfU4VyO=>mL}sf>SJ7qCTb&UIn=&b=;R z|4h0NjL%ReFXMNZpO(9=)V?L#HBzrkjnq|h=I62-$>bA+SMME)|NW_|{+-dU%aki< zp39$1RS}KO;t@f`;$6#8l^GddAdUjAk2jX7g}lj*)zFoKGW)yLF8v*FfquQEh~KP` z{WpuBjr>w*?hWwKCH0&FcfH4#uPr(U^}T#jNqI%@lE5o=E5YNH8B6`lF-8yyy-BYa zAj4zTIx^H?ZN04rv}>8 zFj#K}9?{Yhh}lDIf6GH&!bIVSHY0u*(6Mexbf}+U`C|S|=TD~QrNRV+lXzr+Rzpo0 zn1bwnTXkQzMLR=Nf{s@Udbl8(KRxqPm%Q?jMFKVbnYkL#%wQ>tpIXzunJz9^-Bkx1 ze>Wp)0MihYpPxnt>_`%RCcV+zJgx!h`y(&P=P;k5dyCL0Fv8Q4UcwR-m`I8*}{XWl1Dh{&BO|rZJ(=a~m{IqZE zy<$ry%?|4yHe}gu{;v)t%OZVR-YvLYd2Ko{GNs-#@UvEE>Q?XO)(3Ug`|bLHztXYP zZW6!FOGWMoCQ+!7Y_G>rt#t(B)XjSfQ|=EacicOAnqRe~#YgY(mt`+yp6aTdh|Jg% z`x#$Va7U$ukE6@HnKAWh75;3O51;;4_H$fJ5i|bJ&Z~d3QLzirSc{E(zN=-(Z{`NF zC%8iwld&4_WT9^@V-;4>UFC4}TU91zQWgQ0hb&Vv;f76z<8GxUNt0`QIeEOngbT;N zwP<$pMIrjr!(P)S;aj>NSh#Ed3|*na-%2>g3X^GhG1CHT#taORdTs9Z=}p&I@1fNnb7$P%G1A zW{?EqIlu;!NEM2NN+MSNH7LvC-PI+b*PJ4Z7VWM3 zv0$kO4+je`Du2XZA_kjSI+GfV=1gLxTmmb=4}wWluuLXbz_1J^P@GiCNQF#|WISsT zN3V~K6Ct5cD6GmXOT&D}9>3c#Vt1DsyVpJ)k$&fkhq9)KxzeezfJB8Sx}wXr93t2uMcR*yH)k|!SQK(YwkF3NZ`0ro0V=1%YKo8}Dx6TrdQbNv1?`iAqgKl_(*HVU<*YC?KZ_GLzs$2em`*QmQJl zQ_{{S8^-iqF}&)}KQ7tnsg@Anl4LRktX9B4LrS$2lM*BXlQ5}P$S|c-Z_q!=b>*(J z>)grSAsxtpdg5I_E*YD#SU_^!?NdD!CQQYj{Za7+flC@h!Clu`-s zl_-S35(ObupbkROqdJk1RBORm8W9{S1szMK93i{`(F{4RZ$I%;|z6lt%A zT9QfRI0~E)E>TM53QQtbpr}-Z%5eoQl`AC*XIe6u!6q{1N=b%pA-w#df(CoWG@Vwe zgDgGoNp#Xf9U*EIk`ihaDM!@6d&4RSSIcoZMq*MaimNaO9a)bMfFo6AR14ey*rO0( zB#MK-T#TD0L`LK7W`#e9UgTW~TD|own94hx;>v1lppYY|nvf~gq*AH|hE%Bt6$C*z z_y?>YRIoGgto5BRX+>mzu<30%Xyi)3n*-a2?5))G0f~)&)@%IPAWxkrgs2D%K@s}e)j&!IO`h6yWf?7VR#?|3?TvAXbHi=I7Cbvd6Jzash2m_$Oe8rMff>2;kg2l_gb zWbp>+8}=`$vw^h3PO1gS>B<`8vQ1Wde)0n}cg0UWt=m#P3*`f=9=>Ggk;2o_U^ z1i;^5CPN)Su^p%Mr$w^vE`yGaez}N@!bpNZ!PyWo>@W3)#Dp4EprnF?RbYYvgBFWo zYDfWr&I*_vSX-r3VHl(!+~fq_4sxRAabCh32}Nf}P@j)JgUnaFZ1!@iS3Yy zy|z`MCAZaosc6vj$2IvI-|o^za5HDcw+=_d5`VeB1QJVdFrdO!Fc=V{Due`cKL{dV zDGr0dJ4v8wDfp2|q%b2mGn%23(-p+0Zm;R#i97mUm^^erzdMPz;8o6wOrRuanwjgl zR4Rwn7$H}p2%#neU+t29eFDmC zuNho=gaGVR!K_%JR$`b6jE@zdO4+9bzM1hUhiW_ynOg4V#1ZI- zi0dh#2Eo77I|^&1FByS6soG)CMOl26b63~pW#1_5y<^(#F+~H;n-bc6ZPL8d{C|^Y z6sjk_y1wgN^$y3+Jq>IyE22zZ_lv>}`wJd5U64F@)hH%^3emXEzt1ZQx4e0K&9vfG z;~mn-(%sKTYyY)AQ5d}*ZF>Hrd9Uci?rqn0)!UZGJfhIJx_0V=@%Y)Y!Z)*rti>jKxW{3_=wO1*()22v{GL z$Ph$@D3xGg3j;$(f0Llsz-hGd{CCZw>6kV6nmN~!-*xe8OMGDf8y zyu!ZdC=q+Te4Qd6YxnqkDyDnt7{QgCEjpN{Xu@>Vsn)E^ER)Kq*#eG$#apmSDgnbl zQcl2-oFHKsSCL?lOJH(2#MGH=FGYbS$CNTe#V{(L$gJLLf z1sDc{s)tHZB`U#HGMNgPNS0ZCIve{U)ElnZTWDtMi8tGj z>u9}%nWi59q0UoyFY?`|dUa<89o}A6v2RlX`Tx>&3d0F=yOPS9(nTw$7F$_GfAGKC zaSHv`YL6&KMk>_Y2>laGV59CN_zBZ zotL`X{@{(V!V>a5VQmHdRicE+h|^c>totIF?$1SuX0H)S67QD*?z2EDSv;zwyipsBc|TJ>Kn+Puf!_2=E}_`XEIy zp%2~Le@BrA-sF(1mXmnaDG=bYSk^Gw(N|AxGTDw(^5>bt!Nzi=DDzj;NlTt^!cX<@ zKbh6=K-2ddK8;}>` z3uY12@iwiiOR_xS!Aesbtyhm0pIU!bHn`LL*^B<5q~BZ6*=g4S0 zOHZp>+!xfMk#o{YM6YPo?0kj#=X$?ua~^z@)@16rxhq;89;Y2Q zd(BK?Y`)zTRKnt-1E?dt)UI~0Baz5$C-XE%YoN7bVKpsAF5YRo8D|35*3l@QaQ5ln6se{r%Q`GEOY!tC=WN-m^B^04EeRY!&tel~TL&I8e~!Db zJy9oLMrY;2Uz&AJmh`(?Cmd_Ir{waMr&m3E+obzGCVe&v6pLzOfY(ILJmD&7TIuGA zu>k`=+#5M*-`fLpE(-qgETst5(B(*a>T~FFIsW`uSbeKP;N{;V-lfM5vQ_ zq>qLN$ubvL zR(Pz`VdOS)7l(R!Bj*1?b!Wk%k=XVuD30e_D3By^!-%LJ?) zvXFOaS)H_1icurlTMGru^5o~I-l|z|WF@`EB~&WZun2Di7_-e8K5?> z7;r$DwoS1bU+P_3Kf@nB^vA$sA+=@K84V{pd*0qQHHKGPQt9x7#AMSJuRBGnHM+*6 zT;x6_U5qzZeQVBF$a?fZ(*1ixYTwYQfo+^7c4`|YiE~NqZ1b}D!vh;CJ?Xge_4e1Z z`i5=3YLT=Nf2Ol{QKsqG_I%yqJ)nsDsygN4y`>s!(J0e6PvYD(jDSgWB6YL~AWKN9 zb#cPZ_xbN&r^f6w%x{E4E1!+o|-EJ(lkxqv^og0fOaFPaa zpo}_H>(p30c=O6agQryk+UP;J!5pe)J-ybWBpwH#k?WOr)y3=)^41#(w^S7 z3|3|<16uZ{tx8uds|!y)|0lW1KYd&boe40OyF4t$TDu2slu)x`YNN^*e@tv~_#uruV}*McCUZgx8kX#{E7Ma^W40eGFH9Q&#FSwq~_`I{O$>TyK1fF zri|q_rE_C3VvhqiU(I^D9py`{kiISb-eu9Xo^Ka*N-h4FF-^SfA$vo_b&ZPKKW|0# z>1VFom@@TZE!*R>oIFlTaU*eZ$eaBAetmi9C;>P9ZYQ0@hd(JJg+pccAna)!(r065Y|rBlV;R>7d3;JcJV^TkbsrNw?43x zb6r0EET^xd)Xwgut>pu1PjZpQxd|8{Cz@g~bDV+EeAv`Oy-O*3cdk)%^uz_C1)Fa% znxAR^oV~)+B}ETZ`Fd<_W$nY+d*#2+xyhuM<2(f|&Z9A3b_BNCH2mb9MZK!U##EqF z`S7PYB(kXYhtq#lQ&bva7T>LWX56PW*JzP^z(B@Ma(_FmF2v3)`P?Y2RfR(pe>nGP zQ39R!5dLKQ1e7l4`uCaJ;m!6@E9Y;#UgK4KPpjxam(v{|b*@52W{@wnYLlTvbVTiY z-silgE8kUJ^JH4o;ya8=5*?qkSL3o|u(v3_za=?1!Ce)G7}cJMM;eE$!K4RlmA>*G`FR|EMweXgpT!?(Isw zF7Qq5y}rt833+3*dVOS!!Ni}4>%qkbz}=!9FaOUpRO9<}dAq?Yf>&NkL+J^i7k@H! zJf0fuXbAej%C!d9s@A%CuQER;hgC1P#Q3MpPL5C7+llR8Y-#4YN<$Zw?O5n^z)q}W zPCW|qa3bqrJ3CFf6MJ8<_(9z5SwCJo^++V8leFPabxh*Sg;fSp~DwXK*A@VwWXQs zDh(|Zd$EB|2NcRm=2W4~!-=ei?G$?K`$GxWe%nMu#D~V@yI#rXrKK#VLB)4WLunKr zhCMD2Q*_-#V*u~E|LPUiGEf?2KkQtij64&pRhm|3yH%r8Dh7AE-VQC*s`uhv?>{o? zoMHEvy)Ml|9$u<=^~8h6eKBdX)|H=I6M)t_+i40~m`}bv7kGVY+~2>Ic-Z+1#TU@2 zeE3uB(^%Aay7GmR!zK(vKQ^2{dColHL*_{bdF*s}WrL<)o2CfdsKTbP7dq5Yf3OrF z(U(8TE^A7GX;luC#e27y)#c6a!`nvG-)R*bC~%s?lg_ng6$AJPtt8<|RJU_m+U)mP ze^H|-0X|A-ed4&3~_&Wd+J#x49VN4H0; zlRah!k^-N*#ND#@=UnaN{h8|$I_$Vxir|%LKhW8(;WO6B*S~sff!?)sraLEF?|BtW z%jWR>Bg$Y9cI*vOH@m3-J>_uLI_KqdaVEhjTadja>&b>Lo9FS5m4wkcds{e*edNcv z-jEppogEgC4H?OKB{45`z}(S$A%BbDdM`)S<;ZRmGeX;GDrfzyi}x{rFM7CUa!o}% zmZQxyd(t>q5Uh1tkL=_C>M%7&ZOQZ$uK9(ze(WaGF^$}eo$_}`gE^~-zkmsl`u!@-uzH+v_q43>}Py zK7G?w2jZi20pU|RSmUg?iA%%JjXXzDNB&Gj3V7wREAYzM`o_2#>$xXtK<|~CP2Fp6 z&fNIRU@QOvn8wB)7g?}!G-g2}0nh@#M1c?hlTnc}IUu6QRDgq`!f}9GP~xye1+ast z8j?fIHO~Gb=h*?&0+pSGwUEmtu+YrxN&rYqfYYPG)Cxc#1aur3Aw$(F7+kppOi2vD zqyPv8a7UP{itG|F+1Z!i2CMI7p`kp*(%8{lV4!h8YylTW6$lDAiHHhP%28N>VE|gF zqHvRd?uaN;x!bvx#$lV(u7~!WOEu z3mVo5gjHb{G1f0#aPZLD)U~%4N464fPB!wFWGCHORu`Gu(%YhN*ag=Bfdti>uy$sq zBtP6Ox$eXIep_no+mzN$S?4)_y<Y;;a1AHnKB~P1Q!fP9${} zld)0Xx29P=>hD=J_O}PKj~|a%$hJ||dF1TEh_kNSvDP@1h$+sejXojK>$<(TH!|$e z+&5#6dk_kSjepGTx(1qk3lhZgWkmn6$iY)4d8K@9Mawr%+z~C|&vh)fdwnh^l20Mwb+LIwamgPYa!Fc z(qS&o1^OL{CoUQ8eEy(8k^fN#v@g|197dvbl@OE^nabzZA# zpV#&u)|kJjaoK6Pxe%6V%{!NyUGBRQVY`r+KPzn!uN*L=@zhg>LWgs?M7i%sgr9%8 zzHI2p)3sWzFW+l+(oe~ges_Jzp}KeWR6etF!pD@d)J8}-Z!QFlCB70Df`$u0<1|{_~OBxDYfhLyu>KoTJ07 z<#^OKE(FatThqv6on_Xu(!UUb#+wU4!-b$>uthE|oB4Yhf+n{@_c%LfPsw)R(eKLw zH>*n3hnF8{UH0KB{!Hgj=2~c+Q5(+9x;x_5y0NXJ*2T>xn*4V|;|PAZTdC*Cz@>Kw zzKni&@!9wp&)%)RCp|Nh3XQYf|J{n6u{$rWlE*w)(^-F#3yotm`T1#dpaUZbKa<{Q zZXQR0`bZrC26TQB`jQA28mCI_FNU*EsCwiqG!B26lg8}ih7eE4z9DqRlIL^~V#rAj zjPltxbm&CqeggG*MUOXue?-l`T=x3T_g8-W?*0E?M4lPs;+7){T-|fsTyI8+=jV$r zx3qRHB2V+kiPsucy8WTReWd!hQI&SkdTI0zMdWd96&jdsavjdco~_G{lSGG~0ml8e zM&xm6l`Pegbwr-nTNQVNX+Fc|<5Qmgv@Q5TRuOp$mlyX-JE3W^Vcg~BW1eqKS;Mp( z=W%ZA!dJDQc&fH`d6~nj<&wsJ2Z%&l%4|jCiLbfkYvX=jOT`XIywcfs|6Dq?+lV~u zUeB*Q!~ks}1_n3EvcU9g$~ZrNN7y>n|3REspQ=edD=% ztYrgK<4*27k0&xIvlWp?dA0Mc$yY{K*(cG(#@zMWM5lHe zk%w&?T2v4%$*H7vw(;^kGb2#_U~QFR_2U2ZO^Kgnk+iAnh&=a`YgfH@c+%WW=}(9L z5asJ@EgEGSXYXQK3_Dm-_9F6p|3_lo4PRmk9=udzZ-lbYlBJ9u-G;QWYb0z#*fQ`W zA`jahODs}kCn68qEcENMACc#lyb=<&3MUft)s9XKDp8Mq^B%7(Bl57VWGy$aQtn0M z8I?A$>7k+rkCmCfZGWG%!2_1rjO8Y-jD4#~dLz)hjQ#QatB?1W&Y3V&Xv@Hph&*L9 zEuq`|-v#%j!9PuJwC^sRNp>RgXriy&n_qZL)Cb|`&AvzX3};eiHzLmw({Y3M+wy7c zyYa64px^nPPQS}L%ZNNXo4>00^UDeRm*%~Kz!586S<6is%Z=t-wJc{5d0H(iySyTL zv}60Qu_+~|?U`hhE8#^_!duUtI z>_y~ZchPim_YrwqIUEl6i!-9VMpT{mOFniWw#-BLGzp+zz-iyeyv{&apyZep_R+dTZhOQ6mN|zyl z|IdZw`9qL?NwIdCW?jhaLdRakb)>rxFnVW)zOmC|3%?o#4H55>#w<1zE?yxp&Xy*N zkUZ}fV-ZK8so!;aT4#4^P}`wQN9i;q54)-|53}X6tE090o-5Gr^p+VLw!YZGue7}} zt<7CdL-MeDKx-L5<@7it4|1yU$};^It@!xymm%Xu7c+t>f8{CTC)`W-=_7BXns}f? z>MoZdc@A7TRB!l>?V+kZho(pUW++0>0G)*7c~<3M6n}j8{wEflDzmcBrht`9ZD)tR zv9lA~!K`4?WD$~w?OEt_E<*CK?L-R(Mx%})bL`_#h0LrNI&<=)?(=8QUSi~Tsz~P_ z>?|Y?+lN?-K$*!ge^=d|%aA-rn%3%eDgN5vg%jKCfAW35SetU#p>OOIdf@1-o~uTk z@j^9MMM>RcGFzG~Lh>B1IPGn0v74K_{BrifvSk+v(&=1;r^`IQR)QFqBB zQ-z1}Rnxp;$Hv|YtKdL^o`vKQtv|YNLwj@UyXkF;{8}*P z1aKR#>jqZ2a;i!1nM&z~K>ZYP=8!z6%yH|c{cvbT;hV>foh`8E^BNkYU?Tm)e!T z;^p+Z_v@7CzOh)MQLi_5Hjp^A5{{b4&ZN$yF>7K;e!Sf`1b_~71PEcTVYBT&)fhXH z8Sz-=*?$#d&}wi1p)_?i8^K>;aX!oJ6o48u{+9S&HZGp+xiM{m0@`r*+L6tXI5{V*6GiEtC zXpgR(zE8v6udP@<%St0vx7C#&LrO@0P;G;|GIR)-|k1tkyVVN}@wWwLGH%7J8p_rB=1T!t48mylJ9~y(3V~k`8-l1$1u^rrj z$}>}HHw9ef{C&U&TC^2tM8l|7Y>{{>ZT4UTRY6VueozVCR;t+U^&X&)G5|Mj>}bJN zTN4Y}ieA9Z+aEPiOwKksLyWnZG0aqq$9}ye7hf;Q^m@M6I;Ukdp;6s5=I~A=PJ+(U z=#T1jdb6bodo5zsiPos8O%&BVYRt^z>@vZ*SSHv>nN~S16KXPP!gQ3~?TfRKRh-T- z1_RhtH_ zEp6NgIx8SI-)AGLBaC_t(H@Nh@F&~o%-sukYb;CkRK$hOI@Dka*PDT>utsyCH@xk? zE6YZaW(8c4Dddm@!cnypCzUuNN8}O}sg@}vDv69#Kx#1es@^Qs>auvwj12_(T_rBtmV)VNA1f#rmn zlqmu79|(ye;1`%BkcGK~ghHXP%84`$^BsHqZo`P(U25!J`*cM59RWMeR94bRafnbN zq(p`*a9k-8s4-Xulg^|;b!L%EiE7keAJC9@tLp26N#(FqO~^??fy)rJ5|JvMN@OxyhB5&7l0yd62E5XzRDkEi>QVElQD*&k|MUj|PB1KfF3MJSz$znJHG)uE4igYd3)Gv-) zF^|O}lsGAsD%HT1k}wHu2cuF<1!^};!nj%~ai%XytQfOK>sqX!vo(#Kb!CD=YYWQM zF0kaP;2;aCo0YZ{Dk+A_l@gqU2?Y=Y#xNGQo_z7~9CMaaoYE-8j6r5LG_!!o%{CM6LSfk_AgM-&J~z;eWyTo&G<%Zep# zl#>5MqR-B-r7H8_#pT|6+y*ff1glhPRH`IN8Kjg*P(p>^5;+M;5d}^-ZG$?E-nH@e zr>rj7b5gx&nDPA7>U)RQ@z{7uwNx!r5i*HX4Jj3vlA>0rAV{V}B*0H7A!kZ5lc1Ld z^~&U0t}3!q(#|Iv#`Ik=yz0+CF4^h1reH`V!xW@ijgy2*rc|rsatS6=z;X$WN+g6c zx%x-BspfTdof~@bdYkAsLf^Mv^6|@g-1;O6NFi6FasnqI3{{aN2C6qMLDaAe0Y1#p zcGS=pbk!g%4WJ7cNs%-ZRygfsps=}{rY;aa{-J70Z2b7H%>?^AY<)5qRiY|6fuS-) zsgTQ2QjSYuObJw{#Bs#YSJ9&j>Hv-wbnqfLR0_JA%spSh`<2bl(-zHt5BXjCE_Kv& z!CnuALSPl7f}{}8kCMbugj4~SAR#3PELQ?w;ixOCP)tM_7cv>Th4Au+3L5Mg({x&? z4zhFsn~I;6J5WnVgFo z62-w^u7(Q}BBSwkv%()lFY>Mgt=@W8aN0wqp(?2gbTb42flxbYaO(<@L9kjbks&yN zVvth-t-2#jS`ld*M7k95=0Hb~mdD-#QUl+hmP&CLf@NTIqlV>jDX=f1hA>D1&dUUA0#0eSJqS`B?n7jRUY@A3_A@JXD=jDg~B=L1ztv>(>&XNd*DP)G~~eDAd4( zWFc3!SXy4fBS{hYBe!d7XqOV>p_o1sJQouuQKb@+t0kBcRVb8F6j6cx0CbWP42%xh zJrY}eW-ze_gKrU-B$HZ`ODh)|Aq&t>3@dxt7+h*dd<}!(cMr8Gmq92YQA<&|97g48 zOd&(%Fd-okNJ8KsAeVzS=^E%depRSM;?&2~>GvuaPdz(+Slt!NQl=mg9!p7puv&6R zhCwinLrO4EQ{#vf1~In~FbGkwdI;xIGIV8;#FdnnNxxf7zi)jZ;Zo_vOJ5F{AFQQWZ#ZzYO9j-v|h7 z*|!(vE;ZImcq5_c3<>J<(PxnPiudi3#|77NwjdmoOa%K&tzqJYs!$e`EU&X;y@Wn> zdrc2d+|l>K}t-ATj+uX476a7=ANib~~})Jh`hPwnY|Qx5=~V4}Boz;RWMsm(F9 z)i9}+%j7o3wVBXrungxS?7d^!?J-3I&YKe2eQnab)cmJ8+fM1jL>yCF0)eB4M&XA2 z1&^98NFKau6q7%NXk6#t=M_1oHpkR*ncVTgHuxZ{BeV{nl!aC`aUbVchv9pldVldFOI?c*HTa6-t=|lR^p=2$6sz z;P8|RT<3tnSz8H2qL^)ZZBrDr{9;?e6MlI%v0bfu*BU1HG(0i$)UJ{*a$}ZFox`J- ztU0Fke`if2k9C&0!qm?Hk21CEp1Ac>(7TnAPJ=%C;J*w8x96M3XtV@%bUZd|D$9A}evaH*gpd?`%X|7_{jxRpq;oD&)b9t$ zJ^Q9zOxb+NbS{i9?42j=Wo(-o|fZfg=z8yn5mAulJ;T3-YHsxG&4y zS)OqB+p7DzE!r8H5_G&;(8C4M^wYo9;h*3KYWg$HnE*|NY9@YaP5)-PxL|cxaEnaW z&4?Pnb=$v0{k~?A>MfvEJ9!O#d6y^b{YJNaAbeu%;T2^J@RrqLI?<99eEup%GFCfw zO3MBcmlAQg(?EUwF_Fn12R$*<*&HpwpXVYM=KT@vSEN=LL>w8qJxIT>gA}3@E>~s{ z8u+vAK^XL{fKYle!V?Y|;gz=IR$|9K%R^9#`n|_ekDOA!lU;fmtDUOO$XB|}!zyL3 zSE|!Z6&QM(&g+2Tuk*s`!c6y!7fvU#@WQ(_321s^=x>r$%hT~q^>!8Jc;T-t_Wj$u z@UIuC21}8t!JaUnF_=VQB)Dr2E=Uq0aNAr&FYuZG&j`!lj{0j!9Z%S^*aX?ufrreW z<1TDZ)XA68SpiaO);U?y?`oZJtl^%L%Uhma_3&+z?)#YZtFumT$EaeHscF6Z9bWhs zi%NX>3$tWf^Mq_(c!pK7Oq`)ko{-H8r$xvSUN}>lrCAH))GAE+)mf)^W0hBy+6t_a zWiAY~F6pVyq05!W?0e8^prPyMcU9AAtAJk7Q(m~=^8TX|=jIQLZ%}b`!?7pQ(wLUx zoWgC#W82=d5PT-z{NX<5K4_&uzptHe$=X=5#S8cKmI|Y$-q0$F2z=W-+cKU`?UonL zM!hmgoJ(q_?|uPS9zFb__~WBW-NRcTn5( zmp0p2x*|8c@WeuwyHyKrId9tOmUXW0yVd*&kVzhoKKCWmmZ#6^FU)d zwOd{|+crdLHi|9s3`E;Ie^`bH6`|+f{ukXVC)lBM> zbV~)UC%e3GwjtgyhVXc- z{O|H*$qQ#&$y#n;rN6=pXT(l&gRkZZ_nF6|Ln^k}{(OCzJ0D6r^$^6eYV`VD?y5rv||9mgvaH(>55I@y`^Xr=H81?n9#x-2M_&5V)ML_bYnGo8(@8S(!durqZyI^^ zNIz5@*kr7KTr~5XogjAg{}9yccIBgs{K9S>>^8K7{%5O7zi08nPp2|WYhKA?8bc;QUJCYoX}bDV+Ee7>0N<9b#ecRE~j^3E#$qH4D8H`~1MmyE*a z*yo(#a%hk7Xv~)#fvq+TKY3?SuWGR|73fqx{HYF!EaT4~PXAF&QE7--e7EwMai7*) zV;*&s$gTqSkiRcCY{!w4-uk3R-Aatq(Pc=<1~1$yJ5VN|ZD**(RjX$tnQV%oxL+As z-0)pM!%|nyKTP=;eEG$_vlVUim(At2tGKrVksVK3{d#$8-$7Hxe=d~tn$cqxyl{NP z{j=l872I-mcGSH3#nzOk(*bO9)M^B=AwIH+N>ojBdH%?9EBv z72P(us7qdWYLodLYflWHBkB3mn~BNS%=8@02`{`?FJFFxP0tj zHkaGZPHYGB3o`}Cf)~#AEOa^-yl}Rin1|VN+1aU(v4631-MtSCZZDf~Z^Xj3bXj^i zYAk$+sZqjnqHj5!Nlc<lq~##p2Q7x%?tNy-|_9UHM?)b>`2|l zFMlb(nqa%yzm}m zffeu9#}sXSC^a?avf}J1iyNSL;RiSWIdMB2->~i4Ugf=hsy2lJ)Ap_hc;S6Ef}d5? z&m0{o)5AKG(L5j=4%cAek*2T+6KXb_ql6*5#5*VV>%J}@c5(Fc`U7;Iy)u2o+wPRf zNfR@z(C~JaV^Y@ZVQ%QI{G80J`YaUFFl5V4Bt?yiB`t99Wb0v1S}ZYm%mFn?&o1rR zml<5l61S`oA*c*oqn88D~(=>oNljk2oRXiaNn z3A=gQV%FRI0cNWkYSe*q6{cpUmPVuAXcElH^pHWr0EO4Ms|6XdF^u7M!Cl(70f^yH zcpf`b0M0U^%)12(*k7vwxI4gl3Y!A$7Rm25RYdXKL==lP3ZPPrCa-Ct?$jX^>%GKA zpfFxjz(*#d6p}SE0Zuq>_L@h10QN66>Nt@dREPO`mZ&>m<$^;cI>3juz+f9$hWK97 zMcn~~RixG!MOt$sHL5j{Ood|fZ9y$}w26~haX>h$O?b1}1q9!9LBSTg*=J=|It7-d zA06E`qyykJ&u8bw0Q~>9l`@&0-lU;8%U%=g_HbsfCD7^H5|M&TAL|X~HoC%@l`oKVVuG7!a*n_I%W`? z1n04P&UqA^S0)x5#|vl0dg6HDuH!s$yzq?CepWG_I9@o2t7e%OWf|Xz%i9<&aE=#FnlTQz<}q-Mvpn7$PrilK;>TjV>y@ZDN32%HW&hA?I*1)6< z950-kzIr@;&0`%c#|!6p;T$jA*`S=6$vBDYqKPtSolK;a3Irk%F!Ygw`4I%EWokLB z#4uQ{z*U$O#!w|m%KloI4PkOztnv8r%lZ8}D+5mWD9^$q0DasBk0e~=d*px3E2VHW=Tp!C~!Dj#0BrTokL+l|Pv zJ^XpjIKH+7--K!|Ik9$f!l9`25c0wp>d(CO_VV=Y(DE5)0oDA{b&6_!qd>j7{I%GMvNb3273y-oa8z@S zYOWrHnG7VU z_X6WueA%79{F>l8qqbb61zJ^>pCwd%e&dn*ABX*N|NZ6o=L4TTd;QY#>UlHy-;S#O zfr4i zpH{to@vi=8!_l-}0{F|bWV18$L^i*h;fQC4s_xnARlBHT)StWz2ZjkH@#vAo$TyW%{hvqgSe0ZZZIw~FjU^!a$0V!%LZG4({+qYl0 zMuM`tFRP8;e=qq)mjg^sG0aWY3^HkgxTITRXwG~BR~15&RrYb z`uXSC$=CB)i$|2}y7(3{e=eYfr({++7TFokUp;1)}6XRHAi{fwJtygIgPeAJpVb51Y$ zm9h3pH*j$_ma~az>)@@@vk8$Uyu?3F9N(Qo9GHG8e)ri|sL z;I3L0Lsg$>*#RGFWY>ER5$@`SI8kDc@- za>MJ0D>r_rIW@VAE!liG$!JQdWOrNS#qyy=T8t8lug(~~ZMDUwKKvP(Q#y^CLchw# znSgexKmBOx_uY$B8s~TE{etP=mHv%>3&M}j^cJddZ0fu8aSL9T?{|OJb1%(~q9` z?yc}c3w;H1btPSfJO3cN-mmDPe0$RNy=2o4BIn zh}Tme4SpV6r^Ge7Eb##0?Fh%GTxkxn>#uZ3ZH@08Ke9ERw5aE^v1YJ171}@^|6_(>J)Pc`)Rw5)^^cjpLEGq|hZA=F)=KT7%f5LZI0_$exktSQ z71l8>n$3u}vlH9FtYy+kNGn^KEZFsx)(neQ`Kh2WKX*A_clDY>bUGL8dUhAcJj|BM&N*+2;x+rQ zkC)sIi|_vQz?-#nE!yDaj9o7|dPop8XGpO8Xw>3r@M)d343tLy4!d5Pbh+yJ`9*sy z@4m0g=24aYSWnkA*X(+1*3|o#c0c%7$G26zc1x(+c!IU!O|wQ8DBOP0rE$!YuGsbaez{h$MzIoSYLa(+FU_kENk9L( zNhWrE|4pS1T{TR6mK0ICZ|^uYWEC7J&@*=Z!-qv)2x^k5EkT+!+Q~#u(fjVjWu1@B3RW$}5Tz8{fS1EExlAS{ z5fy<+2m(hG2u8qiL<~Y)=}gq%B7D6(ofqT%sg#6d}P~DsUr4g1~YmhRR8Yrf22CncQIr!7biyA-w#df(CoW zG@VwegDhQe(nFcl5>g?Tk}`0!OQMvauuQ3fWrS2FQQ|O)st5;}=$VQL1i=Df%c!V@ zdYuR(Q4omEB^Z7}WHjDxR``SHMc$R5)mzUBPJ1XIU__`SBt{SrqEM-E6h~wbtd>h; z2u`3FEPJ)9J@YaWRx+(HHjorFj~tzR4YBSWtgzy z#?Bi@@{XsY7pn_=Jw+wT6mm!c;ebhmlS&+sBXWs~RLhhSl|)7=Ahk2+43?2xIAe}o z&#~(fL8ODGkjT8ZO` zqi?bf=@}Rw>>SI^o}%sZaI%r}A=|QTn=odYFCO%;l0j+} zK&nUqaT0=M7_3yoa=8>N2Ow$)gCsD{>S?GgSdra~RuzhbN-_;Mh1FWE_w^mUp6D4N1SF5 zT(aCYzXNGEAMGtPJnhKd1D^zWjEUt0Ao(x#jzW%IuhBVoYB}r~h3bi~uJ1Zmy~FWy zPXimwiYSxU{i1Ng{(?tM7bFi}HHyifLNu=PDrftlf50mWx4e0K&9vfG;~mn-(%sKT zYv1Qg5jb|e*|MceZD{yImOux_)wNR}jK|NG6~39>d{ikP;k_0;d!FiYJ~e(t@}V(_ zgob0+o5aqe#Ctq|^8hSwbz)$iu9m7WoKz{Pxw;$!Gk93VvFkCp9AdnG#-UV^EpQBd z!qpCYYd_NE*!5AUPOlZS&XIXsoh?%daMwh{vFpRAsuu+g$-w?r$Lb}lH(ax~(9G5o zZ?;)!Jaqi+Nn#5R?E2kd4Z>J>@HP~=yBeDfHMmY@*0 zrjFW}FpUmC$RDt7cHnw&ye3kk)lcH(BRI3?aS*{T~gDKm< z^j^Z<{G?wS^Ujwm)bW>LSA7<*=CAf3Is3HAKUV@UVfl;*@vt6Go3%lznD9M3E>yp z*TuERN_P@2E;eQUiGlw=%=3(WW|7Nc~`S5Nx0k6+rmAwG z!HG7Fi$@;2{`kp)-lruMw|$<^OuV$54b$?Ustq<_t1PoJ(p)8~hMnKM3wN zZEf(fw#KUDMt?ft`tq)+_@`g1K8=QU&3#k9(%SvcD~zy~4IuOWA{D;{^62}v;a%5$ zXf|MOVehab*`nfyUOw0Tl%dq^*Kyn0t}A?8xz=VInn{kD%|;CtW3~cw?QG*cv{1?_ zFTugeUmMkv%!!+COPMVye)Fq$`usj{W8j1fo1XR9{Yx1-wOcAa+cr#+EUEZE?vV!t z?;f_GMEk)F!ldXiI$>AWRQ!A2S9r#MR=H!errClOjb8P#7L78EGp#T!=C4ulA?^0F zN9RwPd35ZKMz1eSOXRiEpKT>;xq+2(kBUFC{-eDoR{WuEy8ghPEt}8vvkd~=V&(0O zVTXs1)<$vsb2UrtL*ES&*>`_$gqJlk;PNLXA zR4j;MN5zy4L?D4AAYgCU^#MUtQ0$1*-?x7M+uk5hU&32pg-;Fx!H>2Y3 zU9{V)fBf@P9yU|2#?$&X%eSz3RQy9t;~VcQ<5jImX1j@PK3Gr67b_eU|6AQQU0eN{ zpZ2Vy&Bp@?{e_v|Hr~LRDhw5WTHcEf^}a>PL%E__ z)1B)d#aeMOfr_8$H>5|S>s>pwJGiEGz@k^@3he#wQ1NFl&L&oI*WP;8<1OFWV`?s@ z1y}{+!02(^{Zi}iGwQ7#8<}`g9sQ(E$MX5bO{3yh-8y5;wgGm*sXdY#$o)Lq8mvh~ z#plIEEqJiuTkVl&-YkC6ex|db98M8j$DRB%{?bAAU5}@{dtRe{U0#coUfYICm)8*T)~&UwZF#pT~x}?P(@}=5b~uu7mVy z+0%-Kicee!8BApk6`vT^4KgN_#hIFu_x|u9+Ek`zmQ3YLzrF*D%kL|gLB%Jog$m66 zTTt;^>>B^!`kb-;;hV}SmZdjZuAeFF!QzY*xa=Xr`rIXUnYMFJX zV{4M=QnY#aZ$MGpmpBby((6Nd^txr+ZZ>@zH$;!<5Bl(YxF|Un23Fh#trr|fX(L0h zrJofe&Fc8$_v(GSW>2oW;bf;RA^BD`gNi?)MaR~6_l=mlo-4jus#MD!`7%r3+6(q# zm{RX;T4(#$*u-gKJM_iTvwh2AasV8hAf{s(F+y9{$6umexIS7-z_1CL@uJ6|38jZ* zS8d{h)qjc<6~FhcfX`(Tv>{zKzg*7fcJi?P8-P9IVQoyU1K(Xi&jD?~dCt~hmCsy% zz2?i{mR3inbdJ9Qel@0VzjCdY&JBBz#2tUX(Ve{}QSpE6swk?&JbUtE|Fs+C6V46X zru$0Ze;8BViZT6;sG4N)$j}uRK0NE>^=#O7!-A5CK5r(#n#- z-@8t#_Nv8^eTxETEZV-3C*X7QlS1qFGbEf$Ym^9$fV@%{?DKb?2 zDfd3p+j{Oy6V8v^6mPR=wf-AWi^3){WzmUxi_Q*GsRF}7^t44W<&<`p_p*+)7&i6F z+E1a$`i-w?RD3T+V70`KM-JXy+2KaL7SGP_z&i9Y^Qic|+`F@F=W2s}BI|eg&?CMd zCPjvdKZNeoryIZPLp#Ln^s`=ldl)wTrcm*3KC%joJ{|MugP%u>>vp4=Sj~sMjZpF9 zLo3bfe&+Jnw6q_KeN%jW3%2;hL&d*P^~Jd6Pe-12?>4WeRAyrKg4@ zW~d^kZx1p?#qa9A^psQ2d(TF-Zs&L8!uOT)bXG%A@qOA}et*VpbR0j}EiY$x<*z6z zzF##I6@Mi7k)S_`f&K_z5iSjiQp!g0gGY{XaY07%d7=JVF=JBLptqCWAJ02|YEczs z2g$jQ)<(awpyC@t757k~ztcbZbjW}7>7x{fXaHi9w_1fxw+cjK2C&+Kif=*1C&hHu zW0PA@@qG}jxo6ZCRD266eq0(k3cr*7U49p}*dIkl*Wvib5Mb6>RjqcL8-ZFKVY}gJ zKb9k=$7pA`KK<%;e4}Xyca{-^yA`o{b_q7mHfo+m-%R?gAd!2ZLL1N*ks^w4M2&}$ zUrB(G?BN0INKJ$yraO6VOm9yY4mxo ztY&H-Wlb-?7S=UelwEzj*igwXA}&C>7P6%XNA}xL7A>gw&V?f_(iw+Otgkj?ZtGZy zIy=Vk;DnaNcAikqmGeb1KAS6N30)*Yfdv)cf{IUsT(qF#TTt=A^8}4^iNGAvjc^XF z|3;ugI^$&0$TETHK~00!$Y!y^hR-g(no>k86tVJ)b423* z3GfhrZjJrbJaMJE`qH8MN8DZ<<6~W140^C;+%M*Ve{diJ>DOr{=$#m@rA|Nnv~|krsOLl7~C#gcIVkBmejMr42`5d474 z84f|5iHW0tzI297E8qD6i}sGVJa5ACQTG-}t^a%Y&vZuVYL1=`Evh*Tcdwb=taptm z*8f=qXbUR7LRkRJiv$CW#03Edqbwdrj)2oqIm?A5m z2ANl|n&hXoeroOye(`EG?+`7F2u;(ElwVY4CSpwi|$^dxs!OfHmzC zf=Kg0+WR;*C==n(y{k`cS^#egK}5x0r|U;WLB&t#9EQHi z2QNwciT(S6_u)=@5`)rqHShoWG^OKv8qKzr*t9VUIG9BmC5`w2QNIrK%8;^6$|Ybi95Uu{o+E8FTcH}{m?9qfyuYtxcVRbWv3 z%jp5iGSl}swsz~Y?EBFsjj^WlmeOeRz$bP_WMLtOqO|?9-MTK|tS`51_3F3FGUKtL zhiU_Cq)ASG^?F~PyU(Utt!o)AW3K)=#C@O33zEjC`9AqxH&eAZbHtkedx-n-gOUb1 zusU^#uO5G3=D5y^TwPbRAnxyMJG4>z#+$`^)r-Dd@A*08j?ONjY8gYNSeBm#<$j{2T!y=}|AI+#<7~;NbWzY5v#_ed* zU~4nmV1D#~hXx;!r z_gOb9h18WjE_-58j?;vIHnBxQ+#fOYjdS0njc0n*aDCn7o6Px2zHi8|PhzNJ554cgEOMcl9XN;0s&RaKUFlvSnfGwwuVZHY}I?)zrN4f=BMNcD^(Tg#Wd zw&0%r(*V?D2_WuIp3wMAr?!sG)U9)TG=Ar`*9;yo3t5jFBt%DCHB?X>#C@Vauu6)8 zxKDHorau62$2yQal!>mWm|7JR3q4L+vBU|70s3ib%V}qQAC#6}9zA*M!m=l@mM;Mi zcQX~B$ihS~>AxFzspJs%_h-!;>fLL>!0R10$JNZ8+vKLf)@C8=an3sYs`}xbcN#|@ zkNmi_tIHLK{0fSLxIeDbgH3H0c4!hC-t|>$`KotVB}GBp-{P>pa!2(%E#h1I+q6on zb{Ztjs*iKlou&@+Dmu()@V@6dPWnl|KukWhYN3exIlNa%u|i%}@WZ>+#vE$aK>yvS zvwkz;{wwE%SMP(n&Mo7gTb>{Bb;AvVi~i$?`zt;r*QvGJz03@^>dj9y9laa-t~L}S z?vLo^b7V#OMeenLJ4+X>DRt?(!MZr0dt7%vli+SWcgKXbvun3Gzd_t!YkqOli2IdV zJ*D-zx^_|Jst26*JRM&b6S1KY5%-DY5r%R&MQ|PW?CHE0al!m=s&0-}pFRydh?UOh zLqOc0*Xkdart&!z#+-`SZp)CY(d!Gk>Ay$Zk3Zi)#p<+pY>g$$n%)>#V^V&gHvwXg zGb3>w^j9P9e{^|!ZO(-ynaoX`XUo>S+=G4RrYe69asSFPyPW#fgz0ZRXYM$lsl71A z&}siei2I4Ps~%6N9p9(@ux<{Hqq@w(zF`xEi2Gx`)=M+q|2RHl)6dmRx8}5bD-x0I zaUJ(>Mcm)};-I)g^>KN<^H#3%7$SO#eaoYgM%;h)F3F~IySi2I8K3H|y{aEY)V9u{(G)HN0MEmLXKLhd&|JYut| z)UO>A_&XClTYB8Qmv2!Rc^zkLZgba6?SRRdfZQj}ftX@o7e&wuHxWiYz9`OWhW407L| z+bH|KqRyFy{5kHGFDC}*zZ=Yk{|a*d*1E?dU#KFlA1K=)d=Tp=_W@=a(~$eK=2xa& ze7R=A+|MVE-7ie8uP*kIX~0kik!$QqrD z)hiARSUkx6eHTuapRvVu$&|UBqvogVyaVFKJaMk3$(VNIE6ZIus}6;?Hiq2aGce@6 zeDteLlj^%Smc#{p+NLuz3b{Y(aODeG;yvp9r>ehWo(p%?LGG&nf!tRq zZ8nm8mK_~wMHQvqmx z7lpcSm{N(T&6b=GN`e?^f!x=kI4X3;`XS!l?KWlYx$Kepvd2=#lh$7ed5?u*NU75ko(QhjdVuSHe1|Xt#TW`J31@xV6(kPtdE(`Z8w6D zm+-he0V3ifLJ>y@UKnz?a+v^;xk&juxsb;dl7dNUw7S&-c!F;N6sP!V;TL;JL^9U;z&0_aV+*2{LL_q7RY@71YSTd5fN;LS)%2>OB1f# z^bGyLsP*w@DO&Af2L-kZ%S9j;3uID+B@v4dL?RQiq->s?!{>n*21DPD1pG8$ss;$p z82$j)kK^LU_5%nx5Lg$T!SY|SJ!ZM{(}s;tgvZV5->KM`&c7c!k%F+#3>vv~aXBKlh6pOebHt1C{7SBZ_2NN5vD^T6kwqUigY6g#bOh5SRnT;koy8T)Dg+S>s1#(|ygfV4o zB@5(!;YRxXdo-ghT5(aHDNxA*xo>{U$RO?*|F#XI(SEVGGfjzX7mWzo{ziT_BYL)C zD@g%6n-MwNU&zg7M9o%Q9VuXDvq0`El`5_7kW9m5b4j@CERg&EGpID3aVx&k0=C%Z ztIZhgwbFO1ow2@Qe!-7r)&TsrqT;w&Aon>1i~&ZNg?E0hwJnhQ1t9DIVvvhLGFu?` zb%=|mMyxZA_tKdSZ00Ot1H^hj1<4k1=CT%i{11oCq*1wrLU*I>Rge zz^I|gG{KqpR&}$_f0=6inErbyp2We-Q3`DUQ;kRwkf@EONa&H%O`+)WSW-DxB#|N_ zp^J>g2TTuua#$o2uvi>~i~gTjAd-kg1w>HAXac_o5zE4F)@fPsYm33(&xZ}VJ>B}+ z@BMTO z0GYN&HDNRE%5^c7*ELcd{r`93v_S5Y+@2S^Bj?XMfSF6*Qzc>^7h!V+Jdul#&z5jm zE+RxCmGUIu8cE6%09HyKn}vuaa1cZn$tb&5{!x+za=$R%j^BCS%NV*h_U`mb?*D(B zY5ZlAWr;xU+dLyYWBOh2?QUTJAsa=;)p!Sm`74x8fm%9azkl0+xtDf6^Ie~sbXv^Y zK#L8)_(5UR{LlCkaZbuU2np?Act&P0(nUb24?fG2h$!a+{d?*2RF_Co-!4t}(3y4{XFiiYVRXt}~99 zA>S2ho$1%xJ-dF_zW-VKkPagniytbhCS$MoScftv<9d$ zx5~C|McX7vwQDwq<|3~>xm3Ii(Ez$uT{r8j00)Cwl{(TY_cu8~_hQsphY^Zs^vg%* zV6j3;OE8*^bfv5qix^Wpr!%cbPzlW3x+7o_va%#fL`)TVPC+x*g_diylEhcHkAT;!YGJQ1X z?2O~7HA6T%*E?fEt5a#TG{c;P8`wH%dvH480AMu2g0ux~|BO5_@@n7Fsy5RDlfRC6 zrp2l`1@Jsg)M99VWY5&wb;rm~PDuAst?2cysRB;v`q96l3fQt{`O#yWZg0A~DzGbK zN3t5La%G*OQ3W)-Uv2Z3Eu(feKeRoshp^>KOgguG;Yk1AGQJ&}@u2eRxr_ZH{Ku#4 z$0R@0q^-m`pdhjm7zRds)GQp_=D^AsyB;k*b7tfUO!{ptS`w+W!TjI0*$fEXJn;2{ zss2xve3*U)6Pp%GLNCy#;?eUOL4#7=nQOPr*&E8nivFkrM(mjTZEm^r8ol3pMB;(6 z*~b_DIeLMGqQ*6!E3V))cO?A|I>oj1A>VWAgT z*ty}$1APy?eiZ2%;db@FQp_&>DWVr3dgXP#R|>_^$AbYb&fMj4Thk$>*J>?S`s3%N zpI44=c)$hD-CT@noPrRg6=O){+5Fw3k7>V0UQS=66r^BP0l4kLYtqT@>lT07^+2_? z9+_#6KXw>&$RNMIaCsbxAkizBU4T~#t)*~!0oz)G&UoE=-QsGmxDT~D%pX%2y+E6B z(XXPz55M^G-By~!mXYDIvqiL7rnhP~CRRM%O zsR+*AA^lox?7HV>T-D~0Z_5q|Y=gCRvxRATfyE0uePWFpy3C_yrPz-*YR}eN1waob zk~L;~-^yc!4N#C`fYt~W5y7OE0`hl%9!H_8uy}-bB zJ*%{BJvg+*oGEb)gmt|!X|wbKqmHy3JECpJSCw5W2-9|NTVW6(*A(ZQoq=kh27|m8Yqe^HButBF(5_?3 zHP3ZH#Ddsvg1QQ@{$>jM-3n0iu`m-wnJ}a`XZl6Wtfz z$(0SVjq_T^NFLiif4VV?&yRD~z0Jd#Z*m%)yyLxeOW8v;uIE=!9C`t}fdj6@tP7ng zUP_a!-}ZD1R!LFl1vbaDQ)b?LEkNj{&-ZH8@`ypwtok@-^;o{@LD=0_8@pUN{vkoj z_rv7B)GU-?YfIvW{c9E7y7Y zmh5T&ae9GEZ_YLEA1Q5mdephN3yw$kVBgh_VtRqoL&~16IcRC!jlcZ5yEz{6cx9L` zaH#&c?%p@?`uA&tryi`5ot9dQcUO^L+%&zwoj$WGs~3l;W;8i-ffkjx2oo`;7a%fa z7|P)k!FAlvU;EiDn7ARc`|;3mYva4PV5Lv{6X*rHHR)Be$(ciq&Tq`!a9B0LTdyzZ zrvIK^piGsCiaIB6OtEJtb{yaHaBF?t_O!z31&He)eWAi^-))S3(B!not&J>e3%uAGwfxWaPuT*3DCw2i(9~%d#~?FF;%i6_`DVR7F!W z?kD|>J4X{H(ME<~*05qQF6E3!oGobeE+!+!al(h#eDnQ>=mns(x08oA>kcI+fS3?ZEQ}2|;u8HG)+@mh z-Q$*XX7IF(h_2lD`A6u3#)Dr)lumuHvU2_4vPpZ!{^-=Wo=JLvo6f7+wmbH`Tx3xH za+-^_Be8up3B7>J=hLE-7YCV zwv$H|Ob&qI6U0bEilH!3FI+6Cb*EBa&%X26Jufx+`h4}h&6r36DZKzOi0Hoo^%Ja( zspv5o+e*>q!QlMhSh~d+w!F#8%pO&T^q-x>8mF(>-U!A&-#S)|$93m4k4?3HSGV@r zrE7gXKjzC6LN73RdFzBjTb4HFg!&CDmGaXAlQTgtkp8~qpeV21y{_GC(7C4b%g2Vo zX6OZ`&~|Tj^BcOtV_$8@3f>8AFp*zn$mj*4+j!X`3h z(TRGC&JI$k0>eUdyCo~e%U0g^Y#777T&~F)er3lv!>-;my}-g2-T{ZJ)VNrsrg+ix z@;^W7d-O8%^a5iUlVyt+hE6+`R=?%hFOv0!?f*YGWw2gMTb#P>zQE>1^3ehBQ!7+| zU?^#dUf|T@F&8|i+YO0hl^(Zq@Tyi=`Mh8wdVvK!C%vI3OrIB@GPZBmQE7YiiUR`{ zk6z$}$F=zQi1RaBL_62C-=E$J#ElV}9;4js<~3gb{OvhYY_0aoOtxlV5xd6r=0MF~2R_kRuBXTjgZ!*)`Dne^c0GZDK^WRD1p;oKZ z8fzn*d4Ll2ufgjr6a)GP8U~65kZ_0b2H31K*V9mc15f`26az83D`2Ls>-&MjGOZDz z!0+W5u-ROgT|8i7f*G9^8nGk@krjuaz?vSX-)rG7wdwIe3W>%U_r*>CcQjq0l&TRF zvm9hZ6kvyYD0Qux0|uwXqG+bxVrd{Cf&l3f;P%>TVSW!70MbKbx_kRxfM3BTqpg+RO*0k+3s>D~vioh)Cs#RK(^9C>CY`zKejx zW=X|zt`rf1^nV^`6g+novPEn@;=+PKX!UT-rV&9cYz_c)U#!%chTFHWIpCq>!HNb! z-{+aOmxaxt0C?ZR=78{7LN;406M+aRKv-O!i&O?P0$?^`f{ahoed_Ru_0@*VZ5=C7 zXU8}moY1n^`0PSCSI!s7_-wA6C3KMp1zawmoM7>VJhs$DDr7-XTd$E82}T!@h{m*D zjrixeKIdG+Mx;(^Jhsp3jNW-|07f6SUjZ-*@*t2f&Ex>Qa5A`X3!8(5 z%^?8fy&GZomIyp2-3Xu4y4cv4TmbLE=K$&@4tTxH2D}(3=!cvyLD(*Gxm-$!VniYg zO#pcLAJQ$mVd^JW!XBmpNRXTq*&0zcML6*Wo zfeRPmbIGiUo{%vE6F+vRA~7ogI$$&wd7GJp4zzDIxQ<#tuv?CQg-_WE*}P>gc%aU}@C2JA;7zC_|8 z;jjgem2?A=X*h>Ov77*o48Vc)W3x!(u6@$Y(u0q0d*u4|!<(kY#<%)Q!ZkPb#X(#L@r=)1uVIYhscCn7C1SPxNwCo9H~SIFthl4$Wp*Ifa+N- zQ;3y1I{^m*dTnFIGRC(K9GeSqTheTzxQUP3KCo_7+_R;BcerJAwpHBb|J~6RgLty_ zXAib4R5zv%wJcOO1xGZ69cWpoZk+Y!A%z}hF^J5hf0~%oh?lgu6jQ&MmXtv}$0{yw zM2Q?@F^H#F#kSJ#J;X9P!zwPhR7nt$8u5|-g}gFZZ6`sS-FvGde^oE)!$P zoS`v@M`*>JCx7z^8iRO%R$K-k3eL|gEJgp**%^b#WLw<5v`7TbX}K(!7+k)xINe&)V8 zssAXui-r5@4}*vQ?Hn(3hD|Hq`2maej<`H;!tzn~7D=uDdn_*&?kls@D0l`d3-=X* z1G;YhWAzl?v&Ox~T;azxx^#W>-^>g6{})d5-yRn&+*e?ju${>stVHP3<|4pNwHl=R z1H?@Y`cDK!k#}Z02?4pDSPC*LxGrKY3*;WL`7DVGi^t+{e>k%jw8O=n1#N2>;G72l?JdAi}}o6(8Z z&r8DMhRj_32(WNpm2BK8pj*}(yo87&84m(KeQVt+!RvUsoy(!^@yP!b+DHrcmC5|u ze-)Dd)U?S+jtp0yS$nmNgO5v~_qQeoo|Z%u)bmzZxUay>TQKW0+I7i0)3TLccBc+5 zKkF)IdRfjiN@%)d$mhJhx$lx=uQQjPcWJwDWX=Bz+*dSW?$E#C$w9NX4Y(^sV7uuD zitD&?Zf~KF>PXu)Pkz7RvXhOjMbOfUF8y11X>cFFxZ7`6i=E|bOuBlu0sT>OLDlH&P!FXLzw1s>O1lm$f0H81lWafi3$c>#I8BSuvJoRXWlyChYErm;Ow*yLnx(y0bfi%FpCA zdfzsrL+;tbkFLxg{H{9q6R*RWw z-O5c=r(V+e<6kp${bvHnbIb&=&MHG@?e7Qt$#)5^3HmS@~WmRJg#LRtzFT zM}9T`o}mM)>VIeGFvu5=p@ZlZtQO#vLd{}8+e~_HbL^`9^h1xjjt=Sn{Zr!$I?JL| z5U*n#x`#h}{OcfN=BbRp=>CN{{MU-8XKxHqFrSvWomCr>&-bd+}1$q?e7(>Awx2cNUAGL$4ul z3+?@~@|`{(9yO!;L%U~tB99b_p@Zm~6ZyW;O%+U-ivmAnx`lr5Y`1#X{S}cB2L}5d zY<=7yStN#zGmV>a`$r9nT-Yx7$l{JoR$$U*89Io*!Bhq8DWsa>d^2m@fVnO2l`8d3 z6nfd=fTIJ}7SA+8$I#8^T05=Y&mBg4GwC~{QM}$LsB6gDGK?4^GP6NJ^9&tC51cmG z(JaLUZjcZiamr9ZaTq#?{=h0J3PT6cEtviw-eIO9`!-=?w?G^uI847(O`33}J*7gXkDc z-LunA;%qYWon@xof?eSrZB=Z-Kax&WK}QEDpP;50B85PS7mgDp(ZG2qNdoT2kY zi^d&6oT4#t8=5)p2x1>L$e2(T*L$-bwiM2;>=J)-@0|WOUTiCSM)#eoX=b=1h_kf* z`+(Vt#~q0LxRkx3`xX~k4E)h(lp%Uc{|UfbMFivHr}{jrfr%(h z!R`|`0)}lO47*?9K!-kT?`yg8!5%4Fx4eII!|<^X%ssBV^*DKqxnw*%i9C^_WS)iAL?|nW4ceM z{zE|@D+=nqUSTlkJ7-MU%gH%a)AoDT3XiuD-Y*;7gLGyY{7dJN~yVlBk#;E&C zIsUI!IQ^Wxr`b%72lrmiVx7@Y)cvH)7GL(Q`=@!CjG_I{IK1kKqVD^txmMO*{@}+C z{V|8S-}ub9poos#pV^1%r};?PtESNz#9vvhCtxIF_`vA77P$CuLeH0QK*e=F5Bzy) z(sM8Qwn4rzy)NX?{+)Q+Xc;02^S7R(`=5Ml`u#YS!0akTG#V$vdcsMM7>zBFC?PW1B16tUK(-P~0g#D@5*%CuL(-%B_v^wGPLru4`-Um4V=yf` zX+UjSKrOAIEtHEDLA{k;DgZAoRe=@|8EShCZ5jxOprWc)r*v4;I5g{glfW*+${Xy2 zR?=Oc}yUBFNQgZN1c z@YOp7BU&*4RuEUU(!ss!jcB4BwBGzi7{uPRhO}a*Ah8mFviKutCwqP`DW(SKSx*_5aA4L>mH9J_L%>NGzC10#o zXe0Fy@A^@cABNE3q>s+MRI;!jU0EOCb6n&hj;2K2#fYvX80g?YSSN{wodDW&RfsM& z8puj;p%0FDI$$dUu9Z{w{s4E*TaVRmJUb8rMS{K+Cw*Y^P=eb51O&V|!XNsH{n0RJ z_^m#m85h_80V_UTjiB=g7<(8S0`42~?FN60-hycK><5N!UrZinLgEJ`>6ePpmaL5yd}y=f=4w=;RZT^!N-@Qb+5$D-gsq zGh_Ajg|rM=euF`uqF8@>VKwW!v`?f4U=RcTo}MayTJeN{!BL??zabb=`y+i3a3`#> zHsuh*r#b05#tg%MZY}I9unyvlsL`?IF4iE{X+;zH-}_W*VGZQ>tN9wKSlLB|>4tJV zfeXwSjn3d;I?xkFRkm7y4i~`x5sZvdsRQ+Y0Rj115j&mESmdD+wMu@?X8Pe$QWc!^ zaj|5B^#-$T8-?(7MJkR(>O!g}hWkRQh9-g8s2ZUAgNenzs#SIoT@{207^c*I1$DTq z05=erq||1-RA#NI@K+rFDHl#=3nT|-ZBLG+}Av=P$wuqv{l&IhT4Nx+Mb0N7Hbfa7h+Qwp4V14_y zW>e_Tg`Ls$sWa*l#@6k}BXe&jag&?=Ja?w;?8~_&(jKl880DKVg7yfVPuPy|I8^Q6 zu|8JWX|u4kQ8!gE@~hNif<6Wkl2M>PgJ9?kRvB6i0D{v&O6!o6nIK=tS*%uaB1D`> zrC1Ra86^{ogQb!XNq}}FRMZ(QipkO$ukG$W3%ZoouUyy(n`)Cg$Y`?){`#CEEu17` zEuE2^oU%A!g0MEl-j zTG_cnLT<$w7gA`kMumt2@Avu%(4#qnJ+-pH)gW;Puy5(e(a^mCQ}z$j6NMvBB(xyE58LJ3XnP zcX(K=J1q_~B2?@nC3*pNq98S_-JT^g*EzZD&;0r#C8&k7t%SDNpq)|SJnD4Bd5q48 ztM=+ix6j@0YrE9Wn)>|MPTC?!MO@i0lxP^FKl+3XIpn-_)wrhzJNeorw|{2c=PGT9 zK})jo#Jt@Sh*SWy*U&P~M0CcY;p10a{<==Id2Eck&C>o&Xh}pxam9(bp(Uaxu01;A zK;)BbuZ?34-{dXn7v@>Umo{Io9?-dTd+bQ*~!@>8UQ2GHA=t;bKOFdVO)mqBEW#d+tRXiptCCdE|~Xp|*fr zUz|wEuWk*-0@Qy1m4T|oMdxxW zzinkBLoTUCR4q-^5m%VH5rtmka+zEw4$Cn1FMNxglT*Fx*v6y&v6=ahdlDW|#PSqt zin9@&Avyfu$+7xN8$FzvC>uC%*((xFapg@x!vCK0Ds&(tYPCx32Tlc)h#wvxmE*+Y z7_7V7tJjTE^z3#OW1oC^%Utb6ODUS&{p9BuhozFpZC3( z*!p1fyvEp?l)QAwulx+{LZ)=_3YlU^DN&sv^FC86x~VZ zTllq4r9u0nHVl?5-IYGw`nfA@4N+-Q!IGcV@Rr7@fU_T+;aiR~GGWZ66U}BFiS77) zdNwwz6)s8HNYq&#&1zME*$r>@iR(GmF6m8^9zNXrV%wZeeW8(PLh5*l-<{wsm#?*+ z5enC5x*qx2^7L8no@;}KUuP}Oj-ah0_8F7HC;sF&Kz*lPskzK!K^Z$IHyHNf)sfqQ zw6sD#0w-;X>84~xuECf%1;Ab0*#67<+m4+j7CeY+cysf9+A34( zrVc~^-|NpsG(EYxSA)67N4M!YWpvG}wB>lE$z-UDk+{}%;}6&0k)@mws;-q@Ugz{| zbYXAp>TrxSDM($6B&dM%IGs_kY4R(tY}MJ(Q%9b7$INO8$4JwXe?}Nbu&^t+PE*+z>PIu{O+N$2N%PXg^rzo{H)WWy$n=0b z;W+3~(z55}pC499a!&X5vZt*#;fZ`B;KYf5NtAqt2JAd=BjIBAfD_?0yuC9Q?6sXd zuk2FVY7-v-$^DV|2{gbNkIrb@<1W*t|ElxPUp9;3O^E(%GJe8>lYsDtqslyFEq*fr+T)Q6nIfbqskoz3$^l_90N_Crc5uC=%c(hvo1upzOGO>E zQ(eB2MN2jAE#$J)tunDOi`N3OzEx=7evRZu6NYVT8mqJ$cmZBzniZyayn=)|-LtYt zya$%0jXYu-R;{h7dX=N~!?hxk@k$~`vGofyK%W)i)n9kM?UskOi{rAgzX%)7V-6#+ zzA0gf=Pp@&;v_rC(Ob}Ix_F8 zq|Ah=^Leg8c}R8ICZq9ewuZBR{A(b`)VNR~@4(?QN3y1GUG;wZ#yjRvfF$k@C~zlMHrJ6F%qF1Ak1UGWXz6BBYNihBu~!K-_S30Ye> zY|;Cq@^;qqd$`)Z-`TG$NiQK2qZ&tuc1;Q;83Sa36z?B#DuA~=QZ!vE z|D3$5c39Xy@0{w`v9n*laK0rV**}^VKSk~AOH>5vM`LHlemQXLn&9}7ExJ~vanR$sq6wka^^N_X2*Ds~6w&Yy9jT1-h`t`Q?FOr$pNQ&Z-3sN_9=>vwN za=gv0oiDTz+CRJ=*&JLW^?J3IWaOF_r+DNN6@mQF{p-dA{_q#GzlM9IJl=9vNHTKG z$WwgwuK}-Bpa9ym_BXp@tAo#9Qcv|S{cg)#lCyt;48@~W*E(cyhdn)CS09}f>v3)3 z+kPWviAhE)QHU~~boC|hw+Dr<1lU~Z`lI;1nX5M>bt-#ldK z=FC_9>_10tUeh9aVAit{wV?=OLaOB7y6DfAr9segIIP1P_JE29HhtS#Ztl$IC%<8D zUC7Foe7<5qtB^StJsc6CevotPh~WEAx2SatEMJkdFl8rYz=~iS$|MWWx1Sz4%$euB z-?sc&@8Ek+Vxf~VlX8@;YY7@amhPYV&Kl{wuReX^?4Rk)(;qZKQ=ta?Z?p(gC}{cWIwD3 z$2C%+lFz|53@MdT8m0z#>sovyPWC95yyeomGb3rAuS9NWa2dB?oxnzXN5?bl$taX^P?lwAXd3DeVQ1>vJy?==cI<(J^}?>7zY1j;l1Nat6VtUV4^oXPnB)m|em(WV zaHp6YqM)?D$g9r`=l()mvwcCm}-o4&XQl+R~MtmNMklU;VdxbPC&a=KThG z^9yBR>zWdyY;OcB0QKvfxT|+&%+Bt$jz+3vfTb{p^4CGl?f@TV<4tAWbcct z;OAkkZQ`!ye7_nuSgVF+7)WiL7}b58?0o20ZrFv(VH0z|-rb^V{Am46vOaDsM|B@J zXn->wosk(n@~3J>c)ewV7S@=z+kq3$X7p~%^mRb-t1aOQ+i5XWUNS~ z?I`kx;p(C6L>(liYSjt}zzIO_hGYmzu>uHV6@cLkbfo-aiFtkwn{1O(ZXjPS4qEYI zSZ(M;y-0OX_7V$RzaZ)mwF0~%CpiJ=7~xRMZNY1%!{?DMbm|0oM)&Mvy3evCe!P%*Fr@D0)GuGCcAxN6se|cqS z4N!5nRT zIvHQyXw;JPg&5u+q58M*{wtsAd{btxzYHe`*SJV zIl0V&yM7r^cB>gl%Fb$ZI*>>BtBlzlY+d&)(7ZkJwMN#0vCvtKNjYl!Oo8T*+1*Ni z&t5WmYV{$`jepfR{$Q({+lFjBR%6rjO8YAPXXQlH`#vf(3W_FVB9xu>>KY%Vr`N+cN9kLvu(_T|zl=cq&6EhZoZ5Cnx zWh=*wZNKXHh;m4bto9WCqWea@gD67TJr`;)k~e7?o%-iZJdk((e*4xuKZnav&^;Ha z3}rXAXzS3%TgKjC?Rh=>O1sjrzPZ)AyFoX$gwiFC`lr7s*0l@uThAr;E({9^n(?%r z?WYdUA3wuT|4bC8uD=2*O?0OWgOiNq+q>0Tx$M*OwG{@ukBt8e_g4}T>iTPb57?9zb2KTY+a#8Ps3hn;MWyrLPuM@ ztDl>>K-hbiY=wOWbc<|4in4VD)IMbDyRoxe4>8L{M+Gb5&ewjo09sd=5-WLhR=tx0 z#2SX=s<8lNy+NT23DYvsC$WJ9;|8=D)-$Zy`sL9fUZZ^!lg2{5!IT(fF&b6?GPPaC z!*JJt>RS%i+h=<+c4ZC}qfN?DcJ_r*afyQf&n{SVFeLyS8AkU>NPHOi`QGM^>61Nq z6FWxQLuX%RWhuMUCeZ?NKn8o!zQ7^v(>7Ilq&hmJ?QG~y+q^Jkw_2zg#)N2wd<}e% zsV#awx5HOuT=O3(-QpSskA`lwidqe2w_4^Eg51=w;#RxGOQK6Pj}+aRAurPwy45Oj zJ(S(55;Y>l(s4WZ%Ny}48#<`l-k-5~oohq4szs=Qvip0Jxt@6!Hn?mEEH!nr2q~v)9^(zQ- z>OgMVJg5$PeiYmGdRH2~N%^t=U_67xtEJ>o*$w)r@X>9TE&JZ#tIx}N1^e}mj=k!H zp|X<{rR+_vLIv5jTK95O_AfprV|6{Y?#)b_2GE;as4Qi-ZzvtCE}afMyZ{R)LJHA3 zPpA4hS={sgba?P-(Y%O>(CwQ!QOZ^z#!5gI?>%FO@@jVNXw?I+3mFSv^@LU+W@RbM z^d1==T<{VJmrd7s^~uBU`K`11bDywE?;h0(%Je1|qHMQ_ZyvI@=;8FpE#fuI>#OEZ zXWAZ|1nm}0NKtlnqkme1CXnLn+@;UQGhH8?>pi|s`XIlo1w!cThD?OAJOy0i<-8poxlXE;uOQEqlu>DF@cEvKM7g~G%s zTeIO5(FBOqh%BGe5!ab&&Q+^s_HDQ+eZ6*SCHLAVp*33(YM^Y*W>Oc(Gt4rK(3P2o zKW9z;mg43(ur{=2D@qlA!aJt2@c_^F^sGznVcS0CE}1TTew>?R$~&fN_!HiNb%DII zjwi4i=Y&&dpR73uTw4sr68zU=U zWnF=8+({%TJNqO_dVF1`S{4%EA2EF4#IBQb9=SqipGM^=>$QfK3ERG8TK#3NH-^mq z@@^E;^}5d-sMnIpP_~xDwGMR#yL%a(RxupIdbvh$lj^$+h1QbfLez~kl|PE*59yom z$fXtcNF`+`EN>i3VBaK9Y zvh{9$+mLO8Ho0s`*thp*^x}w^`js2*ht|7fBGl=NX5oWWIT)Q|#0=ZG#pOT7z51+Z znNa8X0a#xW5z69&?$v-!U&z2?j$e4&G~}#x1-s45KC3@GfZ_wG3}x{FYh8c+g z`<3Fpf6So@sb3QxOm|4&Lh*rIh_byVzIn*buD91r?d)7jmhrB`c88-q$3lBe6H*jw ztw!W1t`&X}(Y(=~tZyTdpWL#Vf(+g@tt^SwMpBgBpkY?iw+|Wn>vZ?tfs;>747mNR zeU#UPFz5!&lo)0CIZEtlD6U864%&KL=0&A70S;c7N>0OgEb5pw&&Yr z9}dJmk@lItEF{wYat&zhV^p59b3vs_9V`w~L}6EZx|1@Tb*-j4j}P{qW_|m$ti_qe z4bz}=L35&%oeLT(0a=~j@-X65KCnC&yD#J>Uuo$z!E5L+>9h; zD^l>V48v{1^?2_Z^3OMivM#-B{xbd6L`OGhMQSWS*^HjwG+~qCIpx`*#*@k~SwFd< zW6DWnA~d6u3sE*V;+uzh#!CCj_2zvYC)Licx2)#6`>mn5(S#Ib5i!4g0H6pS^Aa*l zo^-fcI{crMvb(lyIazt2-()BvniHigA{r|J*?wfGm^)AE`z-O%1;?|&esU-xnw6z& z_hrxm(!1_P-sAhW@h@uMeivf%z}gkseUV5|h6V=O)?s}I1F$D`BBFKo!LzDKLF#5zf(noWX6+HS>V0N)`gGrwo6m#}^TL$fCX%TE^`LQkgNKw}UAM}x1pzZ!J*zw%x=k!Zn#wT%c>?+Z zzWd3Sk8L(z>mQdII^y%`DB>6}B}v&B!1BD9q$`NUo$ID+?YX~lN=;7u*y->+XbhMZ zq|EX}6(Gw;ytciSA^CRJbN!4wan#aDkmb!tQg#ciMnc07jg}c8R?30^6bts;8hTD81(-5pc9?dhJqW8ta_1NWV| zSPG8qCIzXB?F1DdlMiNnYq9Y|YtQc!cm3Sjq}L2MwwsouEG7b0+YpeBLp(ad2lvuh zee0Axlyo@B5FL8m+>q$rE?Q2UVG%imcq<_Q8PS3Ymw zFlA<^04UCz5~EV@a0Q?O3{t5A!=U-8O!I|P?#3P)KKI*E3tsJCPXu>`CB`qieeMgw7ZWk)~{~NU8_qka`gw#Qr?kHJOH9e%{f%^i_9?dmk10M84B*FYKLmTqH= z+X8GoKKJ;5Kp&Mf=tj0*tF>tm;}%?)G9)6jS;)GVdK?NHI_c$;s7S6Yv!P=z2#JVL znz9)d&E=N{03H(!Q}>(-dx?TGZQF4jQzrVi|1wu0xUr+{sA|v*TZlAev6EB}$oNAd zD|)?rckR@Qz78|Ke^s=DVrOCElx2XTilB)AgzF%ra#Cp~;&v)FV9=YiCehoV3{X-T z%5Jjot&{Csl0N;AUVC}t>UGY~^LEh_&O$d?=7lL80|u)bz5(hyeeB;2-_~mLfsLVC zk~-GjKgehdn31L|a|dTkNO|v#+%q(>aN$(3==qvEqimtf9daSccI$v)2IXc0hiy`^ zGyoz|j1nAu(>!G(=gilSE1YryY0z%nq#R|tb%F+v#qYST!t-Mi!%DVDk6T@hu00a-FtU{wA&*i)!)lM7LHf;LJm4gp8VO#S0d+?1N$>H1uax=qI}L8pdu-+JajCupX`C>xWo z0+6{w%TD&{65T9&glJwlPUmPZXiS=vdq;Eb5B9JcXGO4BsS0v-!Vnak^#aZ)oW90c z538Uv6H}s4I*ZjxPK1aPsT3=sBBNwtaj;YpA_>rrgo-+2Yy&D)cqEhO1VA-K7^Y@Z z_pw<=M;~h$cRnF%?pZ%O+5*yoRIFHax`{#BEndIJaS~d+dh@sXxNlC6`y9tItXN43 zQ>i25?($x}HkQhDSH5@LdckcIZOPD$b&GK9z~Jh;Czt?*;y~tat~L93D1n`1;BGJ6~!T&Z107 zP_8XxR=e`+@0Fhsm&oT;@D0!Ivxrn%oCKxX0{s3UC}2$(`QqZI8kHON%dx8vv%TYS zBW(#1lxvH#6rJ&j-{gDy`7_ch3}_W`dD`rsq}t*nsJO+%dZ9|{k0=qqJqO6O@O^i~ zscF349d|xd`~L9iwfOil!!4#csXwAM&T4eVineXz+)l5}Z04O%W0L&nTa#Mjq^LLn z)>#dEmnc(7Aw!p0v;AZLa{a!^ZnQpouEmLth7(|uBGl`PGZvlUX`9$^Pp5Oq_1d^v z1rD0>gI%AF00C+}x9~R_nyPa3cYFFNpdJl7(B%L#yaFLa!R7;$7=!`l$ zQ}0eZ@Uyhc!KUV_p2N!;X^AUM*_^9Xs321cXEvR45#a^&sH;`Twv7p#d5yg&|xZMftkMWc({AN&VxR0NN|a}`m7IO4v~>ij~6E_I@I9s z$LNPQl?x7tR4zZ_+czM`izA_I41@&|w*~BKKgOlJ%uX6`W0{NnW;LHM28fNR#E26O z9qQ5v+J3<)@_tIIs`pPtL?@YI#5Jbstcv?29a`vpGop^umD)enZRtzvlF$f$R>d`Z zXZ>?DYS7tEERGWkJ4KHg+VN@AV|%ZKvOo8YJokA4ElPiiCN`r67fukM@KSHq%Qp|U zq}P4=xXF>NtycfSg=rFzkzB~G|t6dI*ie*A?RYeepjBUew@Qf~FyD6DcRNmJ_Z;eI7QU1PYz_*nm97hqtU+@}qKVMiU!> z-++493CA*3v7p9DjSe|1_j&SO+R*dX=uXU4`xYE9iyB8|{T;bcYDAwA9g3@5b%cMX z>z|GfYFEB`**O)k9XWYx>(>A=epg>O2tO1Kd|jROw4q;b&g0H?-<0J}#A<+;X-+Lp zoEquSi8uRV;Wwj}`EGfTmmG4Z3KsWVFlDi!$4QV5HEr?b(v$sPYL1`$ zx#h+02RB(nkE68yX}}*=YUM!ET=0UPe#31u^LTLW>SFH-NToSfL)%TmP6H;*tiRXR ze-I}LI`pB{um#~ycx5J)2>EI2_iY=t*EVfueNQunF;1U+AhR1PY3yep+Q{UsB z&>k&WUt-vi1A#Bk(=kuEc?!zW5+qt?yIS}DkkYfGf#Lkx#jNnNIiZs zx?faUZ=|mmR$a<00o7P>VxmKfuih5D%RG6N9o#2g>wNDx8CD#D_3!Qdm8h9SU!qci zR0kkbrW2PDC|6?RvTe|nzFRsD{P^I^jO0q!BNMQDdrLIduLel?I!;z}NWDGi_A>jM zeM-;T9eToh(j2TBpt<%G%qEbduLkp+#RF4azj*hO4f|5*N4il>3Pjol#9A1X@*0FYiIo!XMe340cI~8h>hvQWGGb(P#-fGJOcmA zWr2y(=jiM9;dkF?kB_eTYH69t?pTa-A<3=p2{Hn~36BmnitScM4=}Z`fKp+R~5@1#7i(UuB@ao~SBN4c^_>FhHF!P%Y{2usy zd?@YU?{*W%&ie7}2KnoQD#1inDYOiIk}Xbg4yjT10%!Q-u2&!{H%vaf9*bu$kkI;m z3^NdL@}onl__i&5u14)BQ>|0)l3&iA!u%KoQ(C`{rU8S*IC;otP4l1YCUajj>OJN< zFREeM?QK{c&BD-HKL^M>3W5q|b=dR5Zd204y*)NI_MS6BgyjGgikPx7B!xj)O>>rO z_owVT+JyJ62RH%M23-wO5XO)NO6%8>H3%^u27>&{WUsh5=KQ^>hV8CL(T|wqvuDIw9D}#aw&qpJ0h!Cdl_Xe;IT?Jw%GE zm{{S*X8s851Z$QcjjFXv3kNbnm?!9JfRlMU_pH>Gt$eO_Y%j>EqnznM*aMj-q-^~%g+P#d z-Jp-xJCD2-(qxdXQ=j_1_Yu}F3zXJ>O_-p2gXFQJ1o>_#GvM?0)~#YZPVKKDIJPqy z!Cn*oJucRNN4+q32nyOUbHV%94wbWZ?%3bFY^ta$_Kx~*vZ2oM>MavNTlymdxyn|C zhiFna+$`nTUl7pud*dTS%ljLwu2HtK6bOo-Jf#C`d(`yc%6>IUoc4X|_>8s>zqI@g zFYCt*QdCK25X-y9}~_&`Py5*W(FprFf@=r1ycT$ zAk#u0D2Gl-aIcf+JFmUy!nNFejjC=texb>zv{L7>ni++oF4kTUq(AFt{cT->I@LTd zN;V)lwoJbQdx4{-%C3Z=Z-L-Sqni4To8S97c0`BTJu53+_V^_Y)8XxkWm@ak3^R@g zVh?ZwN@VmN$5iQh=*?Bnqt5l`3|d#W*&D28SYhd{Uo*@y65Oz$L&#<*i;EsZP^ z2E{e9P$-0cT&eVC)zFXOyq`ha9z1UG_JyU9WkPKI_`?8AxC(4M*=T~H{$lI6`{G(l zCudz8zv}p$MxCd1z~T>0no)y`AVvAUd;7 z@!g7X0o5JcQ#J-Dr(iJ)`5Ic^3K`yOMH7W(;9XJ>ZLVWqIkPG)YueS> z@lDdB`LA2=rX4{?NYOc3-=kI-Y=p7)aQF4mZ|{_A@DaK<<94sLD=?4RZ?dtzN6i!v zsQwrn#(-gs#1ClE%($!zEzXvdJx6;hsaWx$``Ht&=jlSE`irJL)vU^e<5-AEyP7o&Hf2*F9rn4 zXTG_0zrt^*6VJ%{XC#XzgU}`DSP94JpSOheInwF&Yxbj0*cqv?^u?wn{aR?oigtOj z&dWRNd9ZhlS{>f{PWxz4OXe9-ztS7bogPv_7IezD?h z7T1M^)}k4Xe}xwDrj8EDw)#2UTeZaY*pb^mr+%C5QD`mV*;wCiY{ufDbCED8JNy&TrLqSCYRj9V0Gh6JL zZ-KT;aw1$uw;6J1EwY@ppKOa?C}y{dy^p|UV{VV1t7yGUtKEBTuHy!&R8#dV?QmiG zX_le&Balo-m8l8By2r9KCKw_{e1wdNpqVrz_Rfq~u-%SRa?qjiQl#&37RmR2)(JdsN~}2vpKikaR;6e&2E-Seu6efZ~d5YbAv|C5t=zg z(4m&m^Tg@bA^Y$Bp7+w63to@KjQ>6->*uUl#AIQD(fY6-O-i1ogO1d3;q9&T1It+} z8n<)BHox>bG+oHtkttrU0zPdXeTM#a(V}1mZJVX(MZ&`Rna`HwOqg85v+d@0YWu@A zvF(UOuM=|bV42T_B)5J=6B8f==sH2ESsCwG+nOi$d^K-GY9*}xig^m_-`uIh{-8p+ zR*u%eGZewwb*T4_R>3xShBX(KYwDGHCksFoC(m1FAS|T0E>_V(Q#}ifzS{%kn+`W<)sa=8Io=5CLX^nr@O~CGW3q@@GxMy=P2ufI8J9qdqZqLv&in-G@W!#rzanFSyEv8YV z<(Q}-drDXqUnO49OsbtGb zSawapl*Kd$z++1$3fStlq_5BHfMsjmN2@=bc(cvI955v=ra1s$5L7VF^#x}}ho7Z; zPVryZp;oOj7Ulqv)cP-&8OH&L#{g0D(g2naB$lz9goaXKFL%jCc6YnI;F(63I__iJ zb=V7L7KyAM?N9c3g1kS_uAQy{jjM2=X|LQS%$}iGw0{9)l;YN?LP(??7uC66!7w}m=8A@nf3Dzae~7EAV@rIXV)@k%Xjin-Z(CGpI4;`mVa0vA(b-MKLC_} zgkO7haKg>Iz1J-1+f_bg-^z4TnUj)HDRVpkg3QY#_Ydl`qT#sM&k0pJLW8cE%G?a0 z^>fjUYL`ku#TD>+9xOrHx zK(Y}C(bT}c2qT0A${K{QnQS&w0IGHY<$zM%*c?c1Fx}tl&7REo7#GIerro;Ij_bq5 z{wJ>1zlbOVJe(5fkY;Y&_#X{j=aw7slR2ScY8UJx;`g{vw*@t*5`BW;*L>oFD0)m~ z`vYIkO7{*Nd)sqF=EJGgs}r}N7KyEY8EYa_d>=qrUG&}J5H=Wqzk2g>SK}OC=+&uA z;pqC&%|D*SE@OX_&EL^4!jM}ha!Fjdq|^Fy56Y&udG=;_;roSSWBto73xf7VpL)Qn zYJOPQzClNQ6J>*!PQJ)KRuQ}WDm;f`?+s3}bf`wHy3?BO?4+u9YwPl-ufA0%RBvz` ziqjt8V1V}lGnge691Lpg;5P||b?8$xqkKa+ zX=PLUvaPb>C(S0h_$*TwYcFup1cl%)+lO;*?zm!mecnD{9L_5w$3{elL0GgSji z^(9et3JnAI*0wzyHG#M4*^}{mjyE89-7RMSw9ugL~deOi!kI;#8CH9ix+&>G>=}~KJs?GYF=J;W5Po% zmWo(t1{B&=Y~+~sg`ke7aW$7`Ro)`cJbrL_$vDP)6T4a<2kVzEF%>KVD^1eE^LEg< z*>P(1%IJgLJdZ!w@I2ypgJ}w^bV*ToSwHZq@Tdr?^WVj6us(n}H!kkR0#>OzO|ZbL zA~B&?VPqgSgU6oQhX_)|ka zciH=99dq6Lp);!K$mf@N4%E4!4W<_>mnug~_2P6=^V(A0wNx(imP5sRHYYfHiEd~M zq!c;@47N%yOB6pr4^4-UxEVfaj(^Sa!xo+%Fw{_N)O50t(Nefy!S_I|QOQ8wCSD1H z96w#!X1~75!-=z(mdT!d*kr$Ak=ptp4WzO(^n##@jN7@I=BpDYRF3J~KQ?25Hx|-R zNOB7I@t8a%DzFO(3WgPOT{vDD>txftL)Hs-s!qH2udi4XYO;^FL`?aJ!uNroiUy9m z*RI=We-#=$JoQcOvU^BJlo@Ku*9^48r%Ef=Aq>5ZBJ9ah;y*s&rEKf2Oz9EgzWSyE z>6&4l0p)9kDHMWM_65G~v<%udTt14HXwP20g>=oZz`^?AjwX{8yeT4+`vzp|^7_>g`}j2gkS>CZ+6(&qg&>XqMGQi$j zE+Ixn=JXwr>Fjj8ZtXFXrYFJmZjsMe%UmcwDM<-<^3|PWOiA@9!h0_nf?5hyb$9q9~a6`5X3Q~4i{UM)m4YKxjp`U2FWQW9~ z7gK{ObJ?W#H|EH#pSW&f=Yj21`!VEN6)1^{nl9X=@VHoO??q`sRL0J_Qe@nK6IkMU zVd*Kq|1jwZK_{c^&z$$3U4MYbAceGj?DpQI_aEjNP`;L6P>3g0oMx^#&!I<_9DRxP zaZkp<2G^F5t|bWx`6{Ks4WhRt+W_^S8X}*Lv z7|`25qT{*NlxL;;yhv-$@N3e2*PNhDq@LNru%bMc$UG>5>PFe`ezSeR*X*@!NRRs? zYi=QpB`Opz%6GVP)JN zOd7L6PE7d;5x^i|7fY5*++TfO%_j4vjj6YkexLM&NF<^>a#5}XKD%JlwT|tz^6pbm z7andlr}%8zK2&fxvdYyflQxKB*N{do7LJ+q3+$Ne2Q9%OD6CWKBh2py23+jc{zmpf z&xVh%0y{81AmpyuQ?;vbZyya>;Y)>=nnNs;~xrUM7b}_93+DF z?p;j0e%tNZpkDBu?sL?PA*8;rLU5vf#SbBbsf}h|TI;v`QS0O1p_hFsPZ>nM;^S$k zw{#)l1kq=V%c1Sw6R|tx_}cIF>}_4iEggY~^1KFrSfY}s@HR~_z{PhYbA@*W&7%{K zRt~;Sn%7`TX8os&P3#Q@fFN!U)!E71xTiyIY+E@os7;gC*we)Y(NTWB(pMZpgWf<; ztj4gq@rVvO+RmEZwAnr&|Rkx;+Yvn(>B;5FjVWe*b{(qeico!B6}7{fk@gSF?zPE6fs7?peT|Jk$pffe`o*urD{- zEW$>3@XG}s0~gd0SBz+}ubmJnMe1276fes6`W8?TG!}iims6}mdG8a5W32SLuLJ2` zzi`ZoQ;U{D!D_$ERUfrGT5ff+v~J^ogmMLG(LAr>)S@XWf)>42y7DRwe=B$_@LsU< zk;17UEm~kk`3-;qRs!c7D6HYDQK|4dT{=|$`+Jt$W?uV!u~W{z?=UWj^aj8zJ>@q5 zCP)Yxid!&iM9XqX(shd`=-xbF8lGM?*~Xb?K>02q4~l-J#O)F|wiD~k3~T23-L7lI z(pv4vb5hJuQ@%?e;2`M3)#sA({fP22J}5NOs;N_|k?s=AkyGw5`vn9%c8ycUGw<9z zo9f6Ey%P4lrv)8ZLFzFhCoaYjW%7c1*lo|+y&E?=)NVy-m#+gVoFmET4XzU4AFSvb8d$FFtFPFZ_uPeR%6Kb(YHRrd^*;KU0P`T0?qY znWvI`3@ER@5D14Lgu7$rE(eaUi#WY*Ow&PW$v5|tR$nloqTEZN|2}Ro(;=VsyZIxQ zhK_6dV9eC}5!YIgdMQlFD4)rBhey+ttCAyk@g9~^1XT?9na&yG@Rf8Xo1vzBZ6M$v zXaT9tnQVJ{UdkN%ZF@T9W{)9V8_bbYzBc3mL0=cgtql_YUN6O->%KUq&wKjvr2}~e zEID!UjVRLqRn6)~*I0+P*m?E;a&qFf*YhloC?YlGYeSweO>H|JN_%o};eC3RC^T!` zIc+4gnsjY2OHcXQV1k4&JKn#on|3n&$K{HHZ;iXrF!CAc+F+gm9-ARWLJ);Y zZJJmzK4eTlo7Jb7@j;7zR4+Hmmox&}5(~;>bMk{Bs7JM-Mo84$o8i-adkb5apZ$R} zHpe0l%3}%)Xo86`kv2D)juOVKA5#Bm)%)UM=`o}+1!Sa@-^l+0LC}Opx9D?Uy$(JJ zIn4Nw5Okyt>5V)oZ8420@Rpbwly#Nli^Oa2{X;#EbNwb=nm(4fIAM7M3uDR*56bT& zFu4-Y5R}6yHGG8gx~jco??Q%ER&-iSdLLn#NwM__ALEZ-@t-e>J$XPop z=k~PNCvql~-?11)jE6%|)Nq@Fld9kTA<~v!w|!vEC0|JISPI}$413F9Ul55v;?gST zl<|2?1Lq^X>WBgx&O7_v%-)imycl*gcpwD5^vIma(n57=l}JdK?^XTM6*D^;GxWu< zOU96xy0e7qQZ{7D=5Deo}SMq_DtM@i)-#FfP z{=qt@Nvo1r;80BaGQ(RnXm1F^OQDjk`N-Aus$Y6{lR;Cw7FpPrk+Uc^>#QFnplF2# z)(}EH5~bJG(mNNDj?artAGNqfJ&V?voJp}+XFME&n%eJcHKa@*X|0TgiG$nkEwjj? zbvDDL7*3nNYyqJ+Yp&dJUZKm`F=(jg{U4L+>@suOB&DW2PAYFMgJZuA0k!yL@-SVn z22?7?IRQ2gUdg@DXCt!v?B^71qdt{LzYG*9`s z=2M65@1oDIYq^j#Mz~-`lt)b&GUV}Eno!U7%x+jRndzC^*T$v#6Vj-ud~}pYO&Jgn zw6L$_^4<~G=Ufg}zjE|+Q2ro|n#xZ|-6M2{z%9(^p%s^K%hc(6X9Ff^>umk-?OekI z`_iezN9fE`QtoBULr;*sa_59c1M4KeEnDkS`ldY#w~=}o$w(>RwSt|UDp-TQMB__L z@EeaL|HxT>ugj({vHRE5g|CzDTFp>X?inHAAZWtn#er>W*6l4@w`I@e^Ss^WlX^zX zkyGwZ_yq(!4T+l$yqB5o_akR4sCn+4Pp?7Emyr4s$cc+_M43D+Ikt4q#pRAFD>9CJ zUcTV<=k*1QC<1jcjwr1Ryzzt6h4$R$-0lw&=dO?EciT&k*-*fUB9c??Yr_o+&=11Y zsMvhZrL$|lRXBR-N%z-puY{2L+RV~Z9*v=uzzP_zi1@d4#RE#!W86JBG-y39xGZTj zh6xqr(HQ#g6U5%|z=lPO_EebawmWdxiRKHslSX5hl2N{+&?1sxjY;)u&kME(cUD+G zS`yo1{fMz$vPpLo`3WiCQ5Yc*#6G5O{eJC2m+si5>!ge8UgZJlj)H`e@?EG_?yEG} z4gFY>BOP0gH$lhWFiI6UtqD6ldzyXlyFySq58DdoFREe;HM zOd6kGC|;Du=UYHUP~W&~!z&;I%dEdq<4c``pjn6L}Gsw=ToF-cr(}8{OHa|AuqK@sl19*x(o%k3iAQ06GCjI}u2t}u6+2cPs0p8_pi3gH#Z?$q#bzx~ z4H5fC7E7B&OUKW*j>C1JL`r9QU2RV&3X=`!+xNv~Ro{sV~eNIpw}EA`rp|o9o_si}Fdy z7W|6$KJpIVlaTtt%+gb@5%5NZ#1BS;0ny=(+bd*V_!jNa>0Qc`gUgz>&?_7VAeF9mQZmbruOvh!QBkjMzA z_JjW4-Zz&^Po06y&mA)IVwpR?fDrZa%=yQGkg=~yT^#(ge^r06*||eXPkGE{F#7H& zYFtT!&8mDoeX;NS0|P2o6_;&r`Jg9h%%&w4l;=I<2SdrHud_tnVJ(7KW7 zjY#tzEb^dytmQ>}sFXU5N`YT+7FPT^sMLkB!^WzSjK|AAS0^27X6Y&SewZL3D8^>8 za9(mwSmy)IuYR2SFfo$U`(d5|3&TK~O@1cUI!qh-xcc_pu#5))sk^dQr?! zQ+b1}mN398uR0`5$6z_J1x{=ye^^6@o@NGlB>8n})w!*waOLGPdrWVzNy&-_H$m*p z*Q9>>xo7kJ$ElISU2GUI&q&9$_pW*dK|5+hDFbZFNcyRTvOn!H}KxLlbheVa91jjsBN#Pmkm^`9M^^O@06bv zyMz{t7p6&b>TbRK0}-tn*7@mc^8rJ^Vmk?hOl*(_KtoTdK8C(X*&uHxiGSljy!A3q zN!=<=z(5f93fruiNfjMFRQi~CP<>#aoVbdcqoZ&XD`5uDi6w9{$ofd@VG-&~|H`r1 z%6G^W8O>xA6IxSeZT-P_^Xf9-;a)vF)cNP~^LHAt8k`M$^>Y`|+L|FN9^8asQMuN$ z23I_xbRVvp@U88fHs*2T$S55ZxbM=T>}nTTC+#a%oKT}m&X$o=zvUYhxYpF2Q2bR= zn4yygB49;hag8JR?5*p!{o3X9N>w<_o4qYaPCTKI(G&w>oV@7J*F4k+Gl+3C6s{jyiJyLzWAQO#T<1jPGkbmB!&SarjC6?ESi)reAU3BKCr&_gsA1(^ z2O>6?>;~{h7<@+qI4*yL)Evwi)htD4bQ&H!M#lDYbq-falSi5|g3A98^VsPhpLp%D6@S@K!*gxO=YpKjrS6fa2-(MM>O zkUH!tHF$p&QEG$HN=$OF2jYNJDYiy8e*R+xcOrY;;mTQ$_D<}hAYwO5SX|f%a!>Z? z+wa||8H19WekxI~wlc;Nb{rvfRR(b4L;Zr_rFMp&4rE7x_u5f^%P%c-oipeBbDtlN zw)?xhSoqx;CRQ0JB%8mZUpPV2p`(fKT@TG%;oG_fGq>}Non;H(FB}`{a<)|(UnfMO zkgGu%9DiSM$%?vgfhU*A<}rCJE}O&VGc?$|>F8OtX1j6OEtY;*Qo8!*cmXkIyU>iN z%fB^+gcC3wTBJN7O!Xa^{^Vjio#&kNi^Tj}3#9ibtN)M>gjVb41gl|*A7VjGQzz8n zJm1ds{+3j0!>RDphO}ceypOObOdtIXU3LPc`jwW(XKIUS9ur2E9JXxpf%~Fy<95?h z3fu7lSW_4CXhAs`M#2zosgStvCu(|+d)8p;$MHFzKr;{UE zIy5`r(7J#N4U&;}yCvlww0}d)J1QI(>Q13*1ri2!8WOqM5BLSxMrYQp*UuwgCw&dd z?J?rw>9XNxh^J6eBI-^HgwGSkRZw&7IC|sQ7jHN>($>5g(>plBd%%1igKQZdZw?&w}Ww^DG&Vpqp*<{15J0u&o?3I63;=@_o~*(VMuow56@U@4fO7PhjVmWL$r}vku)qgO~>Wh1yzf zV$x|5C;3LTHnj)W8*z!Y1Qi-tTM7~4|TpwZ3k3 zsz8N57<5ddx!m)u?%Y;&TAV07beUK@MrzQlNwfD>*RB48I7!fjLKpurtdD%ajaJEz*-mo^Rijdj27*`;IW-r(y^Kuq<^vkM;Vo3|<=>B&1e zZ5s9)+FfgQ9+F-A&~OUpV#V5{l)f3&wtR#X`-ILZ_9MF-t8(;YKbxzY=3(U@-4w7^ zA6r({G=N3|E`6N?5WU$mi0rvSm2Xb`b;C-h?93vJX_f!11f(l??*OQ6Z!C)XdaqSDx7*_;o4e9N4XM69FaO|K_%Nx;hhmJxTukWD@#X~ zu}kU_ihUsuCMCqgJ=&DmB;o+=9PJPCH$5RxA`D~KhJ8}*AI&~D<5WU*HG6lWGi@nJ z&#=a(wKD!ZPNT;9fZH;lwEk*Df{rXlO|a4tlywVM=zi%WCp^Xfrsrr?o9O{7zm0jJ zqirfYwW%kLvYr^F?%yjRz0Mfv>4^Ovsx`eiOxg-F;4uQz$k{)Sw|5t>j*RXe?s&`O zgxMk=5X};-Kur{D8v}>h?wyrRArd61&jo!o17X&u}+*C@L+_$IB z*pvE?s%-nZJ#=^TV>`2Y3R}LWZ724mU>>G1Eyd9*{DSfQ;P+!QGApi|v(z`tcYOR| z+UA0L^oyQq9pO}yCu)tOC!&M{^G4gqZ0+K~ZH~mv*z;s*YO4PlQymq|!!$y~+Bzad zWE^telk5ftZS#JcImP$c@=w!KX&Z?OvOsKl61R3gz_jm-NQ2cz=>5tkU(oU$O7viC zNQyiV#G)-HLMOI2&A0mVX#0YIHBhs!mkN{yHSC*6SGST-%HvVBQcDIcaBLjA_RK?C zeBmgqzn37vVHCD_tYlM}0}gAR2UY*HX#VYnw54Vn|Jm`KSMn9xH|H%&h8!nD;da8> zhQHi9!hGj9yX(VuKJd%-{5v5ow5`9#;V7Lv;YxyDjcc8;^;64WJImw>P_`QQww%!$ z!@T2wVWAB(Z2rScDh2v$K|Ov@G{YH$41u$>Ga^8kTpl74Aw0f}Dd34jVzEdf5et|? z37aKiB5YX3q(VMliU_!{lq2Pem_k0x7Kp`CpdTJvB4r8rT!Dzo zF>1v?4F&^BjVZw;v{b_j<5R+qU3$B4V#x#kw&rSr6c(^EtW>IW`kj&1nZ*HPN5m4z zm{J+S60v1sF2dw<5P=kyz)}Pe$b?`V!EC;O4U6!U43rW>c(Up9f%9u+L{5e$``=CR zQQMYIx5^n4eIZFdm!TayPlex4kL9dvnsYw2?d)s!t@1tGsT*1>YP6zj;~!hYCb-h# zTx%@2^GCl(G*!?I*|O4)O3td?f|14?dGox`%G1vJrUdFaF!-axZs(1o7P-#9pe}pL z$~`j9e4}yv^M(ZaG3jo6#QBTOyKJvLbZPAHihs8mfi5gb3ssglm9fmx_g1~5&vyJ} zMg-C%wn%?6OLDme^l53QsrGhIX6GS8E_hu{k4jj1a{3afe4bVZixo(or;TG82JuI? zWoQ)_=e+eE=a}RMzz9Tjagd4WVQPVaQlGIcSf4&dIjVCiv#=Wlvft$$)M>(Hb_|Gb}| zKExk2Mf!^ii_T0s^w0Z=iu{jU5fN?nKkuhUfgaR<-p}8=gd~nI(rv{*@2BCiDsS{@ zuwB+a?o5UUX&-?j5ufRl?!mqOoMc&T~CjJp)xhaE_N?%~g+1Gd%&oH0@UdOx| zRA@#dt{`yOahr~flk2smF(RkUt9e9W`@zf?{#-#TUyVuyYFVLWKiDG7)zvO=?#_oF z#tA+hUNnA+OQRXK)-S7NKmmiaB0Li3IeWa1kG_9w^Y~BqG_>bqZB5OJQR07G9^fSe z{ec*S(BlD>dUMI8L~PXwjV=#euzOkmaNF-xDG22EBl;RGcsM7g2iif>(CZ#wp0qk# zxoI-BaP*m^csT6Sjqdh&w5Z?X4V&ouA{TD4{Xvyb?}BfObC z!L4M#o(j|{NrwcY#izhM5MDCJ1`PN8>^keQ?AFHX=gQh1piVMjAYTBFB(SZU_&lfo z{O>K-l;qr6*MoxDgnIkItthBj2#Pm?dm(TmA~2?k)EoU#TXlQL^v!&FNR7QmxVB%d zKAW)10EPyUDG-THe?w!q{s`(ZtcUbOQ2C{0#Y<)dwLE8Q{gOlmForj=(Ay#d7RldAvMS(UC5WEVp!fP9 z{mNN_2zg=|58()bX=I9oA}JyjiKSe=P%7ohWCEd7!j#Bhp*cQiWDwwko$Yul5avkPVu3`)0f5edX>Hc!Ce$RuJA-6WMt1#BUglb-`x3^-5d^QK-3>=-I6 zk<-}4!%n@!(f0NKg4*1;k*ER#!LH9Jb+%Y0k#b=!I&N4@317ex^TDzt6(Bq=N5W>p zGK9_J2}Gt+H?SdyJ*U*61FoGSPi~nMTE(?v&PUs4|4RqR#cmig2Fdd!A~3WC9GM7) zg9P~!r8HNmA_}x-Bx`re&$2DE4oLghUo=jVF zB~}X96R1FD2D*B4{he`tWueCsXBLl*z&yT~1NJa7K92|HrIara$Rtce#^#Cm5{^{L zL-+`e63932K%-o8ZRE`}tsht71ivU77(usnw_u*TK<6J%>bG6yUg{qloGUz^bc*wq3-YaL!#x`7__$g9x6c-4e698mjNzuX zcqLk)B4=`7AYP6 zp~(w<$S0fPx-W+KHB9{{bz;{7=!oY^$;dTwKzcA2%# zyN)|}{~r#bJSa0KWYGjr`oiPk?)^Fs`n(~*CGP68KL61CGH#}_p={(5<18R{wIAbB zUS=l^xUtN|ezThY4+l{m*55dpk2--)eUE!Wd$eSIiD5?$1im~^{|CkpkCRU_#TShO zWpr$^d)&d8h`4Rqj?Gf)g#3d7VruhArPv~rpkW6VJeXd=?W#7qXKwpW_N)FO(d3iK z-x0F~#VmUq{^jAe4*Mr{=T7Jl-ryfB3lVPlr1iJNw?Uf92b(d@znSc^sbuXj<##Zt z*!P)@ZiC$WWv6z{Jl|)0?frv%E-w=PO|flI^*I{VOwamT_b2M6RB)?xx;Vg@i)@45 zPQRSy7M%3?{_^RHn4$ij~|Mpakzy zdV#Cf-iEjNAZf|Zch{Y4_3%i8O9y9vOR4dP!*Tv_EM30U^uG~F2c>2W8c;-@30$Xl3v0H^U9{!ieQ0oMKY6E2jEo{55oA>DYE14nR=cb%G9zTC;CdY;rWnjS2=MT#zSnGacYXmtY zIK91lcF2+Lh<*3ijcDsbCFnNLq}$moVrC|~b?7m`@zlotbN@d`@&%^_`;!_(Am;XmEX6iQ1ihY@c}0I(ZjE{ zUWwIhBK916ap`HR+)X96no*ZNIC)ThmHpY6#(ZPeW?FF!TH1E6F8#e2gvuLEq?I?k zz`Sw-Hw!k8`Po^J#XJK@O&>LCptO~3&RYC3$b2-&xkpH# zLDWWCunNGy7F+n*T9dCifh13FSffN=Z||t>qS2@{TH9s$8mZ+25e=GZX}86aRDNMI zG@sS8k5{km9zJXz_6k#`AbnE<8^3M4+Vs~l4T88~`qOmO_-f0SqR(fb_c;uZMhxn3 zgLDQheF0+-C@{;AV*PFhIzyiaex%iKn7npc@D#K}M_+;d0K76FmxVKMwNj10Ud$Mz zg#$rSvN8ngqP87YJ#QgnP%tpRGP#DK(6y7n3N2zBP)2!!Op_3}Zyucdf&<_TI&&Cx_ zTx5zssp=sOu+8^lm0H(bSuP)5P?^~mEc;q8Q+tClS+?_#Ftnz>TnCEmpg*wLB-6;j z3mxb**7iqqj#zH34^~*j2lG7n01O}xq=LM@T6x`Mi8U0${1=|f+qNOds zG^l^U1yrs9uZP$y&Qr`l@a6!hMgfLBiplO^L23eoPI;;#5>NN;=;_hjqiZbIkse^& zc&Sthi60262UaIU4xDI}ZBi`Ge9Xog#uH}K2z)F|`wbR8s~^P1fwK(lBPM0=?6cen z{eW>U5A5REzk44rnOU=bjS8FSKwxp?eSz7Pfx2795lSOLV~+q_WM) zLUgewyEn=+AGL)JZX*Allfg2$YhYh=e4yjlWoW1qUo!D zrpG{>Z)IC;%G(LnqVE`VSAy*@YQ`$&`#j5vpJ(O!JdM7Mw6DMhddqcwo*+{eOiGQ@ zFH1>}GZ_=zU~P9$-vXVk#^$CXWm~aNY?3~0BJC4+ZAtE{MD>pEPLfG?`Uk7^rw43C zb+T&?SpS8%|RO{rj$q(vV906U+afohyRvP5K?0M;6JxyCbC z2_7`E*m5Cg3G5Mg_@pyf1SP0>vW)>h1LjL2*CKk8paE}E0o*jZBv>icsN~XKa5y*! zf#aYmSR+BSmOfy!#lpS_t9gdsGt~eO*Z8Rd5od6~0ybL#CZYml8k*#vV-=#ng0Iw~ zWz<|4q8Nnsws z6AOiGM8py^MX1m~IRF-iD}qI^)O3XDRr#y-lsRzobEZ9_Pu@B2`o5V5&%Rr6vxwlN zLbM`*PzKB`!bX5L*)T^e0%MsKOUi^Uv?fWsAo_YXk*1U?x!TwN-2=(;`1>T`X^xcW-o98fZYBSu7gHV-7AauBXaDrQNg60U$Nma_R=GIUs#Ds#Y`+ohB` zx5VzC?|PAL+}c`Sia1X}3N>3KlEEAyg7BC^77Lb&xDpm0VREf3jbYM}0Ofp=! zm{W7;8uBi1`k!|R9+@z)(b#_L4i;5vY^I1O79cX21Z4a3gfbDI&l2+lY(7f@vsh9x zbow|;bGTX_N{hIDw|mehsAhIf30kcp&QO+sDd5XszEpxR#jps}Y5{{^!s3E7XD+aL zL~Z(mZ8D*{l@D7$x=b;Bm+zdv+WC3?Mkygtvj%iT3nvv}4)EBp5Ek*JVwlGj3ix~& z0h!mJfD90$Py$NJ5QK*9{eRWE^5Fo{m8ka3% zikKWGkd+XTfUJBGFdi&~#bxt_z^V}CW`Jg_d}Rii+Iyf)DIeRmN2YgJVBeef(>AS0 z$Yd-;0Nz63@dW@H4`ze>dNxnW;jzGzIxs9U9ckw4V42y1%nk^W^jz%sBq*+=12b{& zW!q~-f(eT_BCt?_0zlv~85zjc=kk~!*Pg}WN~ExuNea^-N7JfUW`Jgl?lthJ!+3H?Xo&?j5A2&{Y(Ad@G|rVV#Y`4kLKuXGEgTN0$PBv1UANb) zIc?pyN_&zhuv0x6Rx2XBAj>q$ z$PC!BlX-56NZ%SaHGS;IM>SR3-Yk-2`8*~JYE*DwKA#QqWnuvj=Ch?Ngv(?}CE&R# zg5EKOn?84C4ANk*d{`_}Yt%CIeIH5sJT|xavN%oE6~hjXOkX;`zirV@YhY)} z;c}Q#80>18pvspJl;M#|Kn*Y{EE2Ls90Igot^+N2R*F=+)grq1LUoh+CrzAbEE+UxoE;(4qvKz6 za)e9|j(^9w5i&hC7Hw|+;Zq}IdT1<~BCT>}giMc&MU>c|JuyP22gah!N^6`KA=BgH z-*Q@nOb?5HEwI1j5DS@}VgJS>EM$6u{W}k^km>ogXeQ1o$5+Vo^!oQ6ULn)7YZ0MR zaCC)CPp*IM!4)z+w-ya41;lRBurEj*F58UIx5y+Pax!^(u;jn}p5gS~p@?~rUR58t~ zS#I=0|0-CT?*)eURspRKFEEzpHx^tM3q=SM_=EUd1ZIh(;N^Edcpe>;i)C`TQWhUv zvkF1sRjyHySd4;^310FdpDO#>yc4wT&~HmA*9TtRZ9$4#!IuudJCKZdc`PSBs0i%L zl7KrpiHI#>fv3i~2m-U2Oev^)4=xj7L<;Wx!5?rX#Wun>kb`QmC^=Tc*{;>lg?>v8 zjJ!5CVs+@lB@)}u)&MSr5fDa}fBb`r@!$bsr`lxOoIlx>FoP7sKpdk61bTp))LQhf z2m{2VfjY@TP`VeE0QW3M0CSkY7tZ1_#R4Xm$!5!hQXzVu1su&BFssEJORRu)04q#6 zk8AC7Jdu_0>}c7ES*MSl_-=dkkMs!tD!rB7v;QyKQKxH8*uVOJ!-7IPj+Z&3y~cX-zFlys6sc>=|Em3j zc7MvgqfL14dVmv9ZP3*q#edv-LP6W%4p+bFjxx?2-goCv>wSNv?SwRmEz+ONl3cC< zeOlUSs=ZylP48?p@B!QMYHj9Wv9Ec1*)kXDR@i1>+Z+(w2-Y`qJ69o303sp4HKjlx zfQ5W1mnQ=&9+xZP3WeYq8!_0va+qxJnt|au+9FTz{XiB=JT0GT^EWszRSnH^A?E4r*7-^Db6;##>+akx2(}+k|8Ju zLDu;-Efos5Oz_AEU&IHSY7l_IlkwR?v6v0QT0p1`TMVP&XM((4X~B%~olV{Icly!|T2`si#y>iVBQD*VEPZcp`3E;)-~E(c+8_*{`d$YY5)Oo0d--y~dcA1(s% z!QdRhWib&r?@=rQ4}2HYQL{hQth;nTgZrancbvc9!ZypgT>xeX93Kq5K)}HaoHhAk zK3B>Ioe@X`U~3HqBX~JW2(u(29(W`JM9UgS^MUhgWkgPfC;Q(`@lo6UpIdUF%z)3|TephwIJLiq z;MmS+syHp2T;i?f;3u~M9ZRnGzq{B% zE5k!HsT*#Va_lb%==;6#k^kCK3+23u?Abi?j!U$i%ej~fhpPNH7h0&Ddsb@8Rz6od zwio2oQO@-E=TXt8@iVZ!iIo=Fd>)m*a)~YY1iTTGlHm+&>H5ZIx*_?lH2Jl9XC%bA zr(B_8^wIb_2Y|omYqL0^e{&91X##X0Azte&4OhZ}atTOz2l)m%G`G=N4U+vp?zdJ? z#PCOSVhzYD);i$Z@^10V-H0DorX;T${hBzG z3UlnywSO(?{KvOzy3>}KZw;!6k(?w7qY$#5A>$f-?NI~%xv+2F zYi;J&v!!h;G%+ZH^>=j?*T1|zYxGm7F*nY$15qWzPjO1Z{M*^Zz^Ay ze;x7~^1g@vopO6mwSJ~*+w({;efw`^WrK=eHW}1+OTXk8*BFC;wx@nK;hYCQFL9f; zLhA=!qz_=^{}omPY)lvo_ll@jFRsNS&lXqlJ#e$o~a)-glt!`Y3FdB+{}N%2T+?D2GU(4Llt*5_y>ONS$< zY0^;{-bXe7fAUw%9RRmODuKNg34)~!aE~c38=RM-0F@jEXUslQkPKd z3wbapAtvt8ro<)@2eK;}zO$6%Ge)9epOpJYv(L>ql~7&H-ks>YE`jz6>&qrH3xXpU z1CCe?xL@DS2~Y9A={Z`}W_rNNZ)0BQ&QvyVSz(5Y@%#S064L98k)Dp&@1a`Lo5S27 z#0YI>^zP!-kky`8KRp#1}Jy|-aHQm4~1(%oiwoxRLZ@@ z2#4nqW{a7dx|NxrNr-6h0UHU41rj?2AmN~{6#@s^Kua&~5u|I|$M;^R<4Atyq(wCN zh_TuB8*H2u2&m0Cq~dJe-q9y@Kf(7P)QTWKQe#E}CJ9)Fg z^kYxDj#dx&@wt(mfx{ZZ(F;w2Ml^^Lq?$9t;RR{MK%7`0v1P3o*lha^HfFS9;IzJA zPUKq2G7PhV=`Cr+z-2>$T+C_(;{aCixAPr)ggkzldlZ^^=3v0o0XEa*XrUx6EgECa z(5V=h^0ApCx#8?}Y;WsM`x0IW)qAdWifCW}vy!YmUM;p;>-q=xk^i{Yy5`34>7&L+ zG|0w;5oeha)5KLb)$>^aZ+Z+1t+>Ry_6W9}fp`fiv0nJyT3ppmxqUhmc6M(+!znz- zNJ5SjC+`Jujn|5G*Sob=d##O#aqM-slHp@9WFPa9+d!Z1uo9mhe0t*X8`p$+tt2fX zB5Xfnom)muf^{mSy4%+AYiw@Bx6Ilkm^!(a9YzGyQmwkv-7}SgbSV#PJNcX%z20Ur zYVW3DYb7mOzo0Q-9jNys4hHo!G2QGDubu+Ouv*M?p-XC=tNt;f!50j~O0xEN-F)w} zey*qe<4j-Lo0?~&61DN9>P@SyQE>8_^m=Q_I>JKD&gJm|Qm&k>Yf- z`N^F{Tj*aN_T1aCwvB7UBkl&m7_x7ns&VL0uho@4h{NUDE26L1)#Cxxd^M)bZ77!7-rFyX*!x;V$*x%=h!# z-+sS)yO+z%g?I|IiM%>Mz#sv}z{>cRM@u@x<7PZ=;rHFaF^Cm|u_!UiLBIl~Z&iRM zk3KMAwWS)=u|U#d`$#&1t0M!%Ug;Mk0Y7c@F1WU{#0C~nS{RxpB}f)oBx%BeY(eNY z;w6>agkh9nO&#C+sOjWjZ|PZo>(nIM792Cq)&hox#VP4=cxR2(kK^=R1yiqc|G=?+ zV07tQ6#%{G-@RDyQL<`FgYHQbK4X^mZ;4#Xt|$s&s+ zO<1phX?uxRQbvowbu>~LvMN*4qV|*7=<0Z`$8hX55M>XE&>%Wz&*ZxVm5p_2vw#2Y z=?$Gj@>~iz=pyoFQ;>q~=eWH5SX5UZ+nosq*!PAom>e3}(ybny8m@PEbz@4h2EMedPnBRUx zM`IUM%HA9-hAYH)2&m3yh`bgZ^t`vIsJtWhToR5!KurmtHV!|bA$stCP|)eHBO20q z)HL;mGYEX3X%?~in@DFTyKfrZ`AJ3r^I{2?NrngH!Q zNFAu=dmm5yv-8&g_V$?ZO#CeQNQRMR0vYU`bmJ-8V`4(|j4rHUZ+}^vQEJ)|;;Jfs zU>)bRtWXj+V$Aak+VfQmSUeLgPMWEhzFx7cr)p2By0YE%S++m1hM`nyDy8x8O#@3=1kl? z*8l$8=g1llwxWAJ*XoOsQN&03I#yVps7$CzxLWtsq|VV+sbM0~)|dz}LlPv4Ax}wo z4|&H+?dvAllF#*D*CSE2MO%ZajLZZQNjpgh82b0EkoIVs0lSx>ZSAvCm$7in*tn?@HB=dRMStA& zVfFfzjBB3NZr|cqGFDDSqN_hq5}sZaBnxu3MwN{NLJ#X06;8b^NybIyQE z8rT__&S7n-TVkh9+u5|)&)N;=L75DZp zz52!2ySsLj`W8K(eaa02`;gM4fmc-g+~R${74pKCJloz6m4ty%dB8+T;~vlwdxX!s zv4g=MCwg!E*T(jNM%zhz#BrBT&r`f;h?v0IC|l?|j*%HAB>r&3@c0(|LZILFOxeLd pdphn`qgJTWFLq2Z%!**2I5+D1Fzsqhg-IZDk(p@1Gz5&qB5_$Xpmv)LTia8dmjT|U4u-1;E%6dCWQqcc zQ=_7pmw?1FK)`s{;W$pbU_c2fMlliVg>^49b+GF$3KPQMnzdM#HMp9(2EC{`W!e`oQ1-XVvWi!j1d+ zgY_%x>vm7_j}tBre(48~2X9%n(OYpZ?cuf@X6sq9DYPs^=* zkT;L!m$Dyix-xF^&O^1rjkWAGcTddk8aDeJ?ODODNH?tGFMb?BeuMqo_dQ?s77d6I zE~m5CeKj#Fxwc>l`S0xYE&UQ~zb*P!xbX}22AzFi#onj}nvZ60%x_dqr#W0S-^Jc? zZ?mGE>JTiMwCuMYANi=R<~;2;8`$p_UeAt7T5h5JKJ3o2;*zT;vo_KG1?-)p_U*M~ z+jPRs?d;t@$38H%xk6}9D|=7bpHE6nc{hcdD%txK6+D_g_vu;M{~fzeZARiQM{^&V zU&HR(mZNLcws#3PpJ&$?GF^G`;WfhLk?cX8E%CPF$z`-hVGmws)rV{R>G#BZl>Pk` zpV?-V%RkZnQ1*~DrJ;*so~O{BCic+#_tM5!r;*Fm>|r*mzA_~_oA%6N4*Q@yX;d8Kc3w1c*1_#e}{c~N_ls;GVhV_*x~H)z8N~r(*-4T-7@xs!y8jS zw_LbR*KKB>v;203v8hcVFJzx@x$GCzQ#_h{5_@9glZag-f&zt`-eFJbvA5Xk9kYa+ z$Ff_Jq7x=N4y_`$voCV?zmoktF^Bva`{H(``Gm8W{w^7RVPE=9mAp3P(_Aqx&t=b4 z{!jucTIv4f)$CbaTl|9Z2Z?dXw1b`BCCPUheTX*{Kmim$0Te(16hHwKKmim$0Te(1 z6hHwKKmim$0Te(16hHwKKmim$0Te(16hHwKKmim$0Te(16hHwKcq;`ybYI2uz`y4q z>=zGW2fijA`ge=_2H$vx-pr`K=|6RrH+$4GYVqa{)n|Dl-fULSs6~A(_00cA{$36B OS>A}cs`{)KGk*cR9`;B8 diff --git a/packages/kilo-jetbrains/build-tasks/.gradle/file-system.probe b/packages/kilo-jetbrains/build-tasks/.gradle/file-system.probe deleted file mode 100644 index 8ef9165978017e1e7d5499cd3f26dd01dfed0eb2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8 PcmZQzV4UawZpASG2|xo& diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index e4231d03c8d..911f5ac8a88 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -2,10 +2,27 @@ 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 } = {} const promise = new Promise((resolve, reject) => { @@ -36,7 +53,15 @@ describe("BackgroundProcess", () => { Effect.gen(function* () { const test = yield* TestInstance const sessionID = SessionID.descending() - const command = "printf 'ready\\n'; while true; do sleep 1; done" + const command = yield* Effect.promise(() => + script( + test.directory, + "ready.mjs", + `console.log("ready") +setInterval(() => {}, 1_000) +`, + ), + ) const info = yield* Effect.promise(() => BackgroundProcess.start({ @@ -56,8 +81,10 @@ describe("BackgroundProcess", () => { const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id)) expect(stopped?.status).toBe("stopped") - expect(stopped?.exitCode).toBeUndefined() - expect(stopped?.signal).toBe("SIGTERM") + 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 })) @@ -69,7 +96,16 @@ describe("BackgroundProcess", () => { Effect.gen(function* () { const test = yield* TestInstance const sessionID = SessionID.descending() - const command = "printf 'ready\n'; sleep 0.2; printf 'tick\n'; while true; do sleep 1; done" + const 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({