From e9241a1b93d081563468e310991f82fb36b0864b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 7 Jan 2026 19:35:33 +0000 Subject: [PATCH 1/4] Ensure child processes are stopped --- .../src/background-processes/manager.ts | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) 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" From cb2966fb08323d7905edd19fbcedc4c115c68164 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 8 Jan 2026 17:41:29 +0000 Subject: [PATCH 2/4] Add slash command prompt support --- packages/ui/src/components/alert-dialog.tsx | 90 +++++++++++++------ packages/ui/src/components/prompt-input.tsx | 88 +++++++++++++++--- packages/ui/src/components/unified-picker.tsx | 78 ++++++++++++++-- packages/ui/src/lib/command-utils.ts | 32 +++++-- packages/ui/src/stores/alerts.ts | 29 +++++- 5 files changed, 264 insertions(+), 53 deletions(-) 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/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f0f621a1..22fc1f7c 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -9,7 +9,8 @@ import type { Attachment } from "../types/attachment" import type { Agent } from "../types/session" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" -import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions" +import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions" +import { getCommands } from "../stores/commands" import { showAlertDialog } from "../stores/alerts" import { getLogger } from "../lib/logger" const log = getLogger("actions") @@ -36,6 +37,7 @@ export default function PromptInput(props: PromptInputProps) { const [historyDraft, setHistoryDraft] = createSignal(null) const [, setIsFocused] = createSignal(false) const [showPicker, setShowPicker] = createSignal(false) + const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention") const [searchQuery, setSearchQuery] = createSignal("") const [atPosition, setAtPosition] = createSignal(null) const [isDragging, setIsDragging] = createSignal(false) @@ -560,14 +562,28 @@ export default function PromptInput(props: PromptInputProps) { const currentAttachments = attachments() if (props.disabled || (!text && currentAttachments.length === 0)) return - const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments) const isShellMode = mode() === "shell" + // Slash command routing (match OpenCode TUI): only run if the command exists. + const isSlashCandidate = !isShellMode && text.startsWith("/") + const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1 + const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : "" + const commandName = isSlashCandidate ? commandToken.slice(1) : "" + const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : "" + + const isKnownSlashCommand = + isSlashCandidate && + commandName.length > 0 && + getCommands(props.instanceId).some((cmd) => cmd.name === commandName) + + const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments) + const historyEntry = resolvedPrompt + const refreshHistory = async () => { try { - await addToHistory(props.instanceFolder, resolvedPrompt) + await addToHistory(props.instanceFolder, historyEntry) setHistory((prev) => { - const next = [resolvedPrompt, ...prev] + const next = [historyEntry, ...prev] if (next.length > HISTORY_LIMIT) { next.length = HISTORY_LIMIT } @@ -580,10 +596,18 @@ export default function PromptInput(props: PromptInputProps) { } clearPrompt() - clearAttachments(props.instanceId, props.sessionId) - setIgnoredAtPositions(new Set()) - setPasteCount(0) - setImageCount(0) + + // Ignore attachments for slash commands, but keep them for next prompt. + if (!isKnownSlashCommand) { + clearAttachments(props.instanceId, props.sessionId) + setPasteCount(0) + setImageCount(0) + setIgnoredAtPositions(new Set()) + } else { + syncAttachmentCounters("", currentAttachments) + setIgnoredAtPositions(new Set()) + } + setHistoryDraft(null) try { @@ -593,6 +617,8 @@ export default function PromptInput(props: PromptInputProps) { } else { await props.onSend(resolvedPrompt, []) } + } else if (isKnownSlashCommand) { + await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs) } else { await props.onSend(resolvedPrompt, currentAttachments) } @@ -677,11 +703,27 @@ export default function PromptInput(props: PromptInputProps) { setHistoryDraft(null) const cursorPos = target.selectionStart + + // Slash command picker (only when editing the command token: "/") + if (value.startsWith("/") && cursorPos >= 1) { + const firstWhitespaceIndex = value.slice(1).search(/\s/) + const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1 + + if (cursorPos <= tokenEnd) { + setPickerMode("command") + setAtPosition(0) + setSearchQuery(value.substring(1, cursorPos)) + setShowPicker(true) + return + } + } + const textBeforeCursor = value.substring(0, cursorPos) const lastAtIndex = textBeforeCursor.lastIndexOf("@") const previousAtPosition = atPosition() + if (lastAtIndex === -1) { setIgnoredAtPositions(new Set()) } else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) { @@ -698,6 +740,7 @@ export default function PromptInput(props: PromptInputProps) { if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) { if (!ignoredAtPositions().has(lastAtIndex)) { + setPickerMode("mention") setAtPosition(lastAtIndex) setSearchQuery(textAfterAt) setShowPicker(true) @@ -716,9 +759,30 @@ export default function PromptInput(props: PromptInputProps) { | { type: "file" file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } - }, + } + | { type: "command"; command: { name: string; description?: string } }, ) { - if (item.type === "agent") { + if (item.type === "command") { + const name = item.command.name + const currentPrompt = prompt() + + const afterSlash = currentPrompt.slice(1) + const firstWhitespaceIndex = afterSlash.search(/\s/) + const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1 + + const before = "" + const after = currentPrompt.substring(tokenEnd) + const newPrompt = before + `/${name} ` + after + setPrompt(newPrompt) + + setTimeout(() => { + if (textareaRef) { + const newCursorPos = `/${name} `.length + textareaRef.setSelectionRange(newCursorPos, newCursorPos) + textareaRef.focus() + } + }, 0) + } else if (item.type === "agent") { const agentName = item.agent.name const existingAttachments = attachments() const alreadyAttached = existingAttachments.some( @@ -822,7 +886,7 @@ export default function PromptInput(props: PromptInputProps) { function handlePickerClose() { const pos = atPosition() - if (pos !== null) { + if (pickerMode() === "mention" && pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) } setShowPicker(false) @@ -981,9 +1045,11 @@ export default function PromptInput(props: PromptInputProps) { void onClose: () => void agents: Agent[] + commands?: SDKCommand[] instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement @@ -81,6 +87,8 @@ interface UnifiedPickerProps { } const UnifiedPicker: Component = (props) => { + const mode = () => props.mode ?? "mention" + const [files, setFiles] = createSignal([]) const [filteredAgents, setFilteredAgents] = createSignal([]) const [selectedIndex, setSelectedIndex] = createSignal(0) @@ -246,6 +254,11 @@ const UnifiedPicker: Component = (props) => { return } + if (mode() !== "mention") { + // Command mode doesn't use file snapshots. + return + } + const workspaceChanged = lastWorkspaceId !== props.workspaceId const queryChanged = lastQuery !== props.searchQuery @@ -262,6 +275,7 @@ const UnifiedPicker: Component = (props) => { createEffect(() => { if (!props.open) return + if (mode() !== "mention") return const query = props.searchQuery.toLowerCase() const filtered = query @@ -275,8 +289,25 @@ const UnifiedPicker: Component = (props) => { setFilteredAgents(filtered) }) + const filteredCommands = createMemo(() => { + if (mode() !== "command") return [] + const q = props.searchQuery.trim().toLowerCase() + const source = props.commands ?? [] + if (!q) return source + return source.filter((cmd) => { + const nameMatch = cmd.name.toLowerCase().includes(q) + const descMatch = (cmd.description ?? "").toLowerCase().includes(q) + return nameMatch || descMatch + }) + }) + const allItems = (): PickerItem[] => { const items: PickerItem[] = [] + if (mode() === "command") { + filteredCommands().forEach((command) => items.push({ type: "command", command })) + return items + } + filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) files().forEach((file) => items.push({ type: "file", file })) return items @@ -329,9 +360,10 @@ const UnifiedPicker: Component = (props) => { } }) + const commandCount = () => filteredCommands().length const agentCount = () => filteredAgents().length const fileCount = () => files().length - const isLoading = () => loadingState() !== "idle" + const isLoading = () => mode() === "mention" && loadingState() !== "idle" const loadingMessage = () => { if (loadingState() === "search") { return "Searching..." @@ -351,7 +383,9 @@ const UnifiedPicker: Component = (props) => { >