diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 18d79e7f..6864f180 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -11,6 +11,7 @@ const ROOT_DIR = ".codenomad/background_processes" const INDEX_FILE = "index.json" const OUTPUT_FILE = "output.txt" const STOP_TIMEOUT_MS = 2000 +const EXIT_WAIT_TIMEOUT_MS = 5000 const MAX_OUTPUT_BYTES = 20 * 1024 const OUTPUT_PUBLISH_INTERVAL_MS = 1000 @@ -21,6 +22,7 @@ interface ManagerDeps { } interface RunningProcess { + id: string child: ChildProcess outputPath: string exitPromise: Promise @@ -61,9 +63,15 @@ export class BackgroundProcessManager { const child = spawn("bash", ["-c", command], { cwd: workspace.path, stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + child.on("exit", () => { + this.killProcessTree(child, "SIGTERM") }) const record: BackgroundProcess = { + id, workspaceId, title, @@ -91,7 +99,7 @@ export class BackgroundProcessManager { }) }) - this.running.set(id, { child, outputPath, exitPromise, workspaceId }) + this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) let lastPublishAt = 0 const maybePublishSize = () => { @@ -128,7 +136,7 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { - running.child.kill("SIGTERM") + this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) } @@ -149,7 +157,7 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { - running.child.kill("SIGTERM") + this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) } @@ -255,26 +263,64 @@ export class BackgroundProcessManager { private async cleanupWorkspace(workspaceId: string) { for (const [, running] of this.running.entries()) { if (running.workspaceId !== workspaceId) continue - running.child.kill("SIGTERM") + this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) } + await this.removeWorkspaceDir(workspaceId) } + private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) { + const pid = child.pid + if (!pid) return + + if (process.platform !== "win32") { + try { + process.kill(-pid, signal) + return + } catch { + // Fall back to killing the direct child. + } + } + + try { + child.kill(signal) + } catch { + // ignore + } + } + private async waitForExit(running: RunningProcess) { - let resolved = false - const timeout = setTimeout(() => { - if (!resolved) { - running.child.kill("SIGKILL") + let exited = false + const exitPromise = running.exitPromise.finally(() => { + exited = true + }) + + const killTimeout = setTimeout(() => { + if (!exited) { + this.killProcessTree(running.child, "SIGKILL") } }, STOP_TIMEOUT_MS) - await running.exitPromise.finally(() => { - resolved = true - clearTimeout(timeout) - }) + try { + await Promise.race([ + exitPromise, + new Promise((resolve) => { + setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS) + }), + ]) + + if (!exited) { + this.killProcessTree(running.child, "SIGKILL") + this.running.delete(running.id) + this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit") + } + } finally { + clearTimeout(killTimeout) + } } + private statusFromExit(code: number | null): BackgroundProcessStatus { if (code === null) return "stopped" if (code === 0) return "stopped" diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 7dc7b73f..fce38bad 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@kobalte/core/dialog" -import { Component, Show, createEffect } from "solid-js" +import { Component, Show, createEffect, createSignal } from "solid-js" import { alertDialogState, dismissAlertDialog } from "../stores/alerts" import type { AlertVariant, AlertDialogState } from "../stores/alerts" @@ -27,8 +27,9 @@ const variantAccent: Record { const accent = variantAccent[variant] const title = payload.title || accent.fallbackTitle const isConfirm = payload.type === "confirm" - const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK") + const isPrompt = payload.type === "prompt" + const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK") const cancelLabel = payload.cancelLabel || "Cancel" + const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "") + return ( { -
- {isConfirm && ( - - )} - -
+ +
+ + setInputValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + dismiss(true, payload, inputValue()) + } + }} + /> +
+
+ +
+ {(isConfirm || isPrompt) && ( + + )} + +
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 6931ef8f..0579e149 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -39,8 +39,7 @@ import { import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" import { messageStoreBus } from "../../stores/message-v2/bus" import { clearSessionRenderCache } from "../message-block" -import { buildCustomCommandEntries } from "../../lib/command-utils" -import { getCommands as getInstanceCommands } from "../../stores/commands" + import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" import SessionList from "../session-list" import KeyboardHint from "../keyboard-hint" @@ -376,9 +375,7 @@ const InstanceShell2: Component = (props) => { } } - const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) - - const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) + const instancePaletteCommands = createMemo(() => props.paletteCommands()) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) const keyboardShortcuts = createMemo(() => diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index df3f28f3..a16f4b8e 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getToolIcon } from "./tool-call/utils" -import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid" +import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" @@ -17,6 +17,7 @@ export interface TimelineSegment { tooltip: string shortLabel?: string variant?: "auto" | "manual" + toolPartIds?: string[] } interface MessageTimelineProps { @@ -47,6 +48,7 @@ interface PendingSegment { toolTitles: string[] toolTypeLabels: string[] toolIcons: string[] + toolPartIds: string[] hasPrimaryText: boolean } @@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) label, tooltip, shortLabel, + toolPartIds: isToolSegment ? pending.toolPartIds : undefined, }) segmentIndex += 1 pending = null @@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) const ensureSegment = (type: TimelineSegmentType): PendingSegment => { if (!pending || pending.type !== type) { flushPending() - pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" } + pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" } } return pending! } @@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) target.toolTitles.push(getToolTitle(toolPart)) target.toolTypeLabels.push(getToolTypeLabel(toolPart)) target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool")) + if (typeof toolPart.id === "string" && toolPart.id.length > 0) { + target.toolPartIds.push(toolPart.id) + } continue } @@ -359,9 +365,25 @@ const MessageTimeline: Component = (props) => { {(segment) => { onCleanup(() => buttonRefs.delete(segment.id)) const isActive = () => props.activeMessageId === segment.messageId - const isHidden = () => segment.type === "tool" && !(showTools() || isActive()) + + const hasActivePermission = () => { + if (segment.type !== "tool") return false + const partIds = segment.toolPartIds ?? [] + if (partIds.length === 0) return false + for (const partId of partIds) { + const permissionState = store().getPermissionState(segment.messageId, partId) + if (permissionState?.active) return true + } + return false + } + + const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission()) + const shortLabelContent = () => { if (segment.type === "tool") { + if (hasActivePermission()) { + return