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