diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4b177e292cf..0af88b69308 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
+import { DialogTasks } from "@tui/component/dialog-tasks"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -447,6 +448,15 @@ function App() {
},
category: "System",
},
+ {
+ title: "Background tasks",
+ value: "task.list",
+ // keybind: "task_list", // TODO: Enable once tsgo picks up the new keybind
+ category: "System",
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
{
title: "Switch theme",
value: "theme.switch",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx
new file mode 100644
index 00000000000..a36f7f6f5a8
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx
@@ -0,0 +1,271 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createMemo, createSignal, createResource, createEffect, onMount, Show, onCleanup } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useSDK } from "../context/sdk"
+import { useSync } from "../context/sync"
+import { useToast } from "../ui/toast"
+import "opentui-spinner/solid"
+import { useKV } from "../context/kv"
+
+interface TaskInfo {
+ id: string
+ pid: number
+ command: string
+ startTime: number
+ status: "running" | "completed" | "failed"
+ exitCode?: number
+ workdir: string
+ description?: string
+ logFile: string
+ reconnectable: boolean
+}
+
+function formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+}
+
+function formatTime(timestamp: number): string {
+ const date = new Date(timestamp)
+ return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
+}
+
+export function DialogTasks() {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const sdk = useSDK()
+ const sync = useSync()
+ const toast = useToast()
+ const kv = useKV()
+
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ const [toKill, setToKill] = createSignal()
+ // Start with a hardcoded test task to verify rendering works
+ const [tasks, setTasks] = createSignal([
+ {
+ id: "test_task_1",
+ pid: 12345,
+ command: "TEST - If you see this, rendering works!",
+ startTime: Date.now(),
+ status: "running",
+ workdir: "/test",
+ description: "Test task to verify rendering",
+ logFile: "/test/log",
+ reconnectable: true,
+ },
+ ])
+
+ // Fetch tasks from the API
+ const fetchTasks = async () => {
+ const dir = directory()
+ const url = `${sdk.url}/task?directory=${encodeURIComponent(dir)}`
+ const response = await sdk.fetch(url)
+ if (!response.ok) return
+ const text = await response.text()
+ const data = JSON.parse(text) as TaskInfo[]
+ setTasks(data)
+ }
+
+ // Initial fetch and auto-refresh
+ onMount(() => {
+ fetchTasks()
+ })
+
+ const refreshInterval = setInterval(() => {
+ fetchTasks()
+ }, 2000)
+
+ onCleanup(() => {
+ clearInterval(refreshInterval)
+ })
+
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+
+ const options = createMemo(() => {
+ const taskList = tasks() ?? []
+ if (taskList.length === 0) {
+ return []
+ }
+
+ return taskList
+ .toSorted((a, b) => b.startTime - a.startTime)
+ .map((task) => {
+ const isKilling = toKill() === task.id
+ const duration = formatDuration(Date.now() - task.startTime)
+ const statusText =
+ task.status === "running"
+ ? `running ${duration}`
+ : task.status === "completed"
+ ? `completed (exit: ${task.exitCode ?? 0})`
+ : `failed (exit: ${task.exitCode ?? "?"})`
+
+ const category =
+ task.status === "running" ? "Running" : task.status === "completed" ? "Completed" : "Failed"
+
+ return {
+ title: isKilling
+ ? `Press again to confirm kill`
+ : task.description || task.command.substring(0, 60),
+ bg: isKilling ? theme.error : undefined,
+ value: task.id,
+ category,
+ footer: `${formatTime(task.startTime)} | ${statusText}`,
+ gutter:
+ task.status === "running" ? (
+ [⋯]}>
+
+
+ ) : task.status === "completed" ? (
+ ✓
+ ) : (
+ ✗
+ ),
+ } as DialogSelectOption
+ })
+ })
+
+ const killTask = async (taskId: string) => {
+ try {
+ const response = await sdk.fetch(`${sdk.url}/task/${taskId}/kill?directory=${encodeURIComponent(directory())}`, {
+ method: "POST",
+ })
+ if (response.ok) {
+ toast.show({ message: "Task killed", variant: "info" })
+ fetchTasks()
+ } else {
+ toast.show({ message: "Failed to kill task", variant: "error" })
+ }
+ } catch {
+ toast.show({ message: "Failed to kill task", variant: "error" })
+ }
+ setToKill(undefined)
+ }
+
+ const removeTask = async (taskId: string) => {
+ try {
+ const response = await sdk.fetch(`${sdk.url}/task/${taskId}?directory=${encodeURIComponent(directory())}`, {
+ method: "DELETE",
+ })
+ if (response.ok) {
+ toast.show({ message: "Task removed", variant: "info" })
+ fetchTasks()
+ } else {
+ toast.show({ message: "Failed to remove task", variant: "error" })
+ }
+ } catch {
+ toast.show({ message: "Failed to remove task", variant: "error" })
+ }
+ }
+
+ const viewOutput = async (taskId: string) => {
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${taskId}/tail?directory=${encodeURIComponent(directory())}&lines=100`,
+ )
+ if (response.ok) {
+ const data = await response.json()
+ dialog.replace(() => )
+ }
+ } catch {
+ toast.show({ message: "Failed to fetch output", variant: "error" })
+ }
+ }
+
+ onMount(() => {
+ dialog.setSize("large")
+ })
+
+ return (
+ 0}
+ fallback={
+
+
+
+ Background Tasks
+
+ esc
+
+ No background tasks found.
+
+ Use the [bg] button on running bash processes to send them to background.
+
+
+ }
+ >
+ {
+ setToKill(undefined)
+ }}
+ onSelect={(option) => {
+ viewOutput(option.value)
+ }}
+ keybind={[
+ {
+ keybind: { name: "k", ctrl: true, meta: false, shift: false, leader: false },
+ title: "kill",
+ onTrigger: async (option) => {
+ const task = tasks()?.find((t) => t.id === option.value)
+ if (!task || task.status !== "running") {
+ toast.show({ message: "Task is not running", variant: "warning" })
+ return
+ }
+ if (toKill() === option.value) {
+ await killTask(option.value)
+ return
+ }
+ setToKill(option.value)
+ },
+ },
+ {
+ keybind: { name: "d", ctrl: true, meta: false, shift: false, leader: false },
+ title: "remove",
+ onTrigger: async (option) => {
+ const task = tasks()?.find((t) => t.id === option.value)
+ if (task?.status === "running") {
+ toast.show({ message: "Cannot remove running task", variant: "warning" })
+ return
+ }
+ await removeTask(option.value)
+ },
+ },
+ {
+ keybind: { name: "o", ctrl: false, meta: false, shift: false, leader: false },
+ title: "output",
+ onTrigger: async (option) => {
+ await viewOutput(option.value)
+ },
+ },
+ ]}
+ />
+
+ )
+}
+
+function DialogTaskOutput(props: { taskId: string; output: string }) {
+ const { theme } = useTheme()
+
+ return (
+
+
+
+ Task Output: {props.taskId}
+
+ esc
+
+
+
+ {props.output || "(No output)"}
+
+
+
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index e19c8b70982..a178bfd1599 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -959,6 +959,12 @@ export function Prompt(props: PromptProps) {
{local.model.variant.current()}
+
+ ·
+
+ will queue
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 3339e7b00d2..0839ab653b0 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -89,6 +89,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
})
- return { client: sdk, event: emitter, url: props.url }
+ // Expose the custom fetch (or native fetch as fallback) for components that need raw HTTP calls
+ const sdkFetch = props.fetch ?? globalThis.fetch
+
+ return { client: sdk, event: emitter, url: props.url, fetch: sdkFetch }
},
})
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 1294ab849e9..2106cb3a38c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -7,6 +7,7 @@ import {
For,
Match,
on,
+ onCleanup,
Show,
Switch,
useContext,
@@ -1388,7 +1389,11 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
const toolprops = {
get metadata() {
- return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
+ // Always return metadata if available (needed for running bash processes)
+ // ToolStatePending doesn't have metadata, only ToolStateRunning and ToolStateCompleted do
+ const state = props.part.state
+ if (state.status === "pending") return {}
+ return state.metadata ?? {}
},
get input() {
return props.part.state.input ?? {}
@@ -1599,8 +1604,11 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
function Bash(props: ToolProps) {
const { theme } = useTheme()
const sync = useSync()
+ const sdk = useSDK()
+ const toast = useToast()
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
+ const [now, setNow] = createSignal(Date.now())
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
@@ -1608,6 +1616,83 @@ function Bash(props: ToolProps) {
return [...lines().slice(0, 10), "…"].join("\n")
})
+ // Hover states for buttons (to avoid TextBuffer destroyed errors)
+ const [bgHover, setBgHover] = createSignal(false)
+ const [killHover, setKillHover] = createSignal(false)
+
+ // Check if process is running (has metadata with running flag or status is running)
+ const isRunning = createMemo(() => {
+ const status = props.part.state.status
+ // Process is running if status is "running" or "pending" with running metadata
+ return props.metadata.running === true && (status === "pending" || status === "running")
+ })
+ const callID = createMemo(() => props.metadata.callID as string | undefined)
+ const startTime = createMemo(() => props.metadata.startTime as number | undefined)
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ // Format elapsed time
+ const elapsed = createMemo(() => {
+ const st = startTime()
+ if (!st) return ""
+ const ms = now() - st
+ const seconds = Math.floor(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+ })
+
+ // Update timer when running
+ createEffect(() => {
+ if (isRunning()) {
+ const interval = setInterval(() => setNow(Date.now()), 1000)
+ onCleanup(() => clearInterval(interval))
+ }
+ })
+
+ // Send to background handler
+ const sendToBackground = async () => {
+ const id = callID()
+ if (!id) return
+
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/foreground/${id}/background?directory=${encodeURIComponent(directory())}`,
+ { method: "POST" },
+ )
+ const data = await response.json()
+ if (data.success) {
+ toast.show({ message: `Sent to background: ${data.taskId}`, variant: "info" })
+ } else {
+ toast.show({ message: data.error || "Failed to send to background", variant: "error" })
+ }
+ } catch (err) {
+ toast.show({ message: "Failed to send to background", variant: "error" })
+ }
+ }
+
+ // Kill process handler
+ const killProcess = async () => {
+ const id = callID()
+ if (!id) return
+
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/foreground/${id}/kill?directory=${encodeURIComponent(directory())}`,
+ { method: "POST" },
+ )
+ const data = await response.json()
+ if (data.success) {
+ toast.show({ message: "Process killed", variant: "info" })
+ } else {
+ toast.show({ message: "Failed to kill process", variant: "error" })
+ }
+ } catch (err) {
+ toast.show({ message: "Failed to kill process", variant: "error" })
+ }
+ }
+
const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
@@ -1635,7 +1720,47 @@ function Bash(props: ToolProps) {
return (
-
+ {/* Running process with controls */}
+
+ setExpanded((prev) => !prev) : undefined}
+ >
+
+
+ $ {props.input.command}
+
+ {elapsed()}
+ setBgHover(true)}
+ onMouseOut={() => setBgHover(false)}
+ >
+ [bg]
+
+ setKillHover(true)}
+ onMouseOut={() => setKillHover(false)}
+ >
+ [kill]
+
+
+
+
+ {limited()}
+
+
+ {expanded() ? "Click to collapse" : "Click to expand"}
+
+
+
+
+ {/* Completed process with output */}
+
) {
+ {/* Pending (no output yet) */}
{props.input.command}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx b/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx
new file mode 100644
index 00000000000..59ee0a0608c
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx
@@ -0,0 +1,505 @@
+import { TextareaRenderable, TextAttributes, ScrollBoxRenderable } from "@opentui/core"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { createSignal, createMemo, createEffect, onMount, onCleanup, Show, on } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useSDK } from "../context/sdk"
+import { useSync } from "../context/sync"
+import { useDialog, type DialogContext } from "./dialog"
+import { useToast } from "./toast"
+import { useKV } from "../context/kv"
+import "opentui-spinner/solid"
+
+/**
+ * Task info structure matching the task manager
+ */
+interface TaskInfo {
+ id: string
+ pid: number
+ command: string
+ startTime: number
+ status: "running" | "completed" | "failed"
+ exitCode?: number
+ workdir: string
+ description?: string
+ logFile: string
+ reconnectable: boolean
+}
+
+/**
+ * Format duration in human readable format
+ */
+function formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+}
+
+/**
+ * Format time for display
+ */
+function formatTime(timestamp: number): string {
+ const date = new Date(timestamp)
+ return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
+}
+
+export type ProcessViewerProps = {
+ /**
+ * The task ID to view
+ */
+ taskId: string
+ /**
+ * Initial output to display (optional, will be fetched if not provided)
+ */
+ initialOutput?: string
+ /**
+ * Called when the viewer is closed
+ */
+ onClose?: () => void
+}
+
+/**
+ * ProcessViewer component - An interactive terminal viewer for background tasks
+ *
+ * Features:
+ * - Shows scrollable output from a task
+ * - Updates in real-time as new output arrives
+ * - Allows sending input to the process via TaskManager.input()
+ * - Has keybinds for: kill (Ctrl+C), scroll, close (Esc)
+ *
+ * Usage:
+ * ```tsx
+ *
+ * ```
+ */
+export function ProcessViewer(props: ProcessViewerProps) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const sdk = useSDK()
+ const sync = useSync()
+ const toast = useToast()
+ const kv = useKV()
+ const dimensions = useTerminalDimensions()
+
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ // Task state
+ const [task, setTask] = createSignal(null)
+ const [output, setOutput] = createSignal(props.initialOutput ?? "")
+ const [inputValue, setInputValue] = createSignal("")
+ const [now, setNow] = createSignal(Date.now())
+ const [confirmKill, setConfirmKill] = createSignal(false)
+ const [autoScroll, setAutoScroll] = createSignal(true)
+
+ let scrollbox: ScrollBoxRenderable | undefined
+ let textarea: TextareaRenderable | undefined
+
+ // Fetch task info
+ const fetchTask = async () => {
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${props.taskId}?directory=${encodeURIComponent(directory())}`,
+ )
+ if (response.ok) {
+ const data: TaskInfo = await response.json()
+ setTask(data)
+ }
+ } catch {
+ // Silently fail
+ }
+ }
+
+ // Fetch output
+ const fetchOutput = async () => {
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${props.taskId}/read?directory=${encodeURIComponent(directory())}`,
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const newOutput = data.output ?? ""
+ const prevOutput = output()
+ setOutput(newOutput)
+
+ // Auto-scroll to bottom if enabled and output changed
+ if (autoScroll() && newOutput !== prevOutput && scrollbox) {
+ setTimeout(() => {
+ if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight)
+ }, 10)
+ }
+ }
+ } catch {
+ // Fall back to tail
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${props.taskId}/tail?directory=${encodeURIComponent(directory())}&lines=500`,
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const newOutput = data.output ?? ""
+ const prevOutput = output()
+ setOutput(newOutput)
+
+ if (autoScroll() && newOutput !== prevOutput && scrollbox) {
+ setTimeout(() => {
+ if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight)
+ }, 10)
+ }
+ }
+ } catch {
+ // Silently fail
+ }
+ }
+ }
+
+ // Send input to task
+ const sendInput = async (data: string) => {
+ const t = task()
+ if (!t || t.status !== "running") {
+ toast.show({ message: "Task is not running", variant: "warning" })
+ return false
+ }
+
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${props.taskId}/input?directory=${encodeURIComponent(directory())}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ data: data + "\n" }),
+ },
+ )
+ if (response.ok) {
+ setInputValue("")
+ // Refresh output after sending input
+ await fetchOutput()
+ return true
+ } else {
+ toast.show({ message: "Failed to send input", variant: "error" })
+ return false
+ }
+ } catch {
+ toast.show({ message: "Failed to send input", variant: "error" })
+ return false
+ }
+ }
+
+ // Kill task
+ const killTask = async () => {
+ try {
+ const response = await sdk.fetch(
+ `${sdk.url}/task/${props.taskId}/kill?directory=${encodeURIComponent(directory())}`,
+ { method: "POST" },
+ )
+ if (response.ok) {
+ toast.show({ message: "Task killed", variant: "info" })
+ setConfirmKill(false)
+ await fetchTask()
+ await fetchOutput()
+ } else {
+ toast.show({ message: "Failed to kill task", variant: "error" })
+ }
+ } catch {
+ toast.show({ message: "Failed to kill task", variant: "error" })
+ }
+ setConfirmKill(false)
+ }
+
+ // Computed values
+ const elapsed = createMemo(() => {
+ const t = task()
+ if (!t) return ""
+ return formatDuration(now() - t.startTime)
+ })
+
+ const statusText = createMemo(() => {
+ const t = task()
+ if (!t) return "Loading..."
+ switch (t.status) {
+ case "running":
+ return `Running (${elapsed()})`
+ case "completed":
+ return `Completed (exit: ${t.exitCode ?? 0})`
+ case "failed":
+ return `Failed (exit: ${t.exitCode ?? "?"})`
+ default:
+ return t.status
+ }
+ })
+
+ const statusColor = createMemo(() => {
+ const t = task()
+ if (!t) return theme.textMuted
+ switch (t.status) {
+ case "running":
+ return theme.primary
+ case "completed":
+ return theme.success
+ case "failed":
+ return theme.error
+ default:
+ return theme.textMuted
+ }
+ })
+
+ const isRunning = createMemo(() => task()?.status === "running")
+
+ const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
+ const animationsEnabled = () => kv.get("animations_enabled", true)
+
+ // Height for scrollbox
+ const scrollHeight = createMemo(() => Math.floor(dimensions().height / 2) - 8)
+
+ // Setup polling and keyboard handlers
+ onMount(() => {
+ dialog.setSize("large")
+
+ // Initial fetch
+ fetchTask()
+ if (!props.initialOutput) {
+ fetchOutput()
+ }
+
+ // Poll for updates
+ const taskInterval = setInterval(fetchTask, 2000)
+ const outputInterval = setInterval(() => {
+ if (task()?.status === "running") {
+ fetchOutput()
+ }
+ }, 1000)
+
+ // Update elapsed time
+ const timeInterval = setInterval(() => setNow(Date.now()), 1000)
+
+ // Focus textarea if running
+ setTimeout(() => {
+ if (isRunning() && textarea) {
+ textarea.focus()
+ }
+ }, 100)
+
+ onCleanup(() => {
+ clearInterval(taskInterval)
+ clearInterval(outputInterval)
+ clearInterval(timeInterval)
+ })
+ })
+
+ // Auto-scroll to bottom on initial load
+ createEffect(
+ on(
+ () => output(),
+ () => {
+ if (autoScroll() && scrollbox) {
+ setTimeout(() => {
+ if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight)
+ }, 10)
+ }
+ },
+ ),
+ )
+
+ // Keyboard handler
+ useKeyboard((evt) => {
+ // Ctrl+C to kill (with confirmation)
+ if (evt.ctrl && evt.name === "c") {
+ if (isRunning()) {
+ if (confirmKill()) {
+ killTask()
+ } else {
+ setConfirmKill(true)
+ // Reset confirmation after 3 seconds
+ setTimeout(() => setConfirmKill(false), 3000)
+ }
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
+ }
+
+ // Page Up/Down for scrolling
+ if (evt.name === "pageup") {
+ setAutoScroll(false)
+ if (scrollbox) {
+ scrollbox.scrollBy(-10)
+ }
+ evt.preventDefault()
+ return
+ }
+
+ if (evt.name === "pagedown") {
+ if (scrollbox) {
+ scrollbox.scrollBy(10)
+ // Re-enable auto-scroll if at bottom
+ const scrolled = scrollbox.scrollTop
+ const max = scrollbox.scrollHeight - scrollbox.height
+ if (scrolled >= max - 1) {
+ setAutoScroll(true)
+ }
+ }
+ evt.preventDefault()
+ return
+ }
+
+ // Ctrl+End to scroll to bottom and enable auto-scroll
+ if (evt.ctrl && evt.name === "end") {
+ setAutoScroll(true)
+ if (scrollbox) {
+ scrollbox.scrollTo(scrollbox.scrollHeight)
+ }
+ evt.preventDefault()
+ return
+ }
+
+ // Ctrl+Home to scroll to top
+ if (evt.ctrl && evt.name === "home") {
+ setAutoScroll(false)
+ if (scrollbox) {
+ scrollbox.scrollTo(0)
+ }
+ evt.preventDefault()
+ return
+ }
+ })
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Status indicator */}
+
+ {task()?.status === "completed" ? "\u2713" : "\u2717"}
+
+ }
+ >
+ [\u22EF]}
+ >
+
+
+
+
+ {task()?.description || task()?.command?.substring(0, 40) || props.taskId}
+
+
+ esc
+
+
+ {/* Status bar */}
+
+
+ PID: {task()?.pid ?? "?"}
+
+
+ Status: {statusText()}
+
+
+ Started: {task() ? formatTime(task()!.startTime) : "?"}
+
+
+ [Scroll paused]
+
+
+
+ {/* Output area */}
+
+ (scrollbox = r)}
+ maxHeight={scrollHeight()}
+ paddingLeft={1}
+ paddingRight={1}
+ scrollbarOptions={{ visible: true }}
+ >
+
+ {output() || "(No output yet)"}
+
+
+
+
+ {/* Input area (only for running tasks) */}
+
+
+ >
+
+
+
+
+
+ {/* Keybinds footer */}
+
+
+
+
+
+ kill{" "}
+
+ Ctrl+C
+ >
+ }
+ >
+
+ Press Ctrl+C again to confirm kill
+
+
+
+
+
+ send{" "}
+
+ Enter
+
+
+
+
+ scroll{" "}
+
+ PgUp/PgDn
+
+
+
+ top/bottom{" "}
+
+ Ctrl+Home/End
+
+
+
+ )
+}
+
+/**
+ * Static helper to show the ProcessViewer in a dialog
+ */
+ProcessViewer.show = (dialog: DialogContext, taskId: string, initialOutput?: string) => {
+ dialog.replace(
+ () => ,
+ () => {},
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/progress.tsx b/packages/opencode/src/cli/cmd/tui/ui/progress.tsx
new file mode 100644
index 00000000000..1782db6bca8
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/progress.tsx
@@ -0,0 +1,430 @@
+import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useTheme } from "../context/theme"
+import { useSync } from "../context/sync"
+import { useSDK } from "../context/sdk"
+import { useKV } from "../context/kv"
+import "opentui-spinner/solid"
+
+/**
+ * Task info structure matching the task manager
+ */
+interface TaskInfo {
+ id: string
+ pid: number
+ command: string
+ startTime: number
+ status: "running" | "completed" | "failed"
+ exitCode?: number
+ workdir: string
+ description?: string
+ logFile: string
+ reconnectable: boolean
+}
+
+/**
+ * Format duration in human readable format
+ */
+function formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+}
+
+/**
+ * Truncate text to a max length with ellipsis
+ */
+function truncate(text: string, maxLength: number): string {
+ if (text.length <= maxLength) return text
+ return text.slice(0, maxLength - 1) + "\u2026"
+}
+
+export type TaskProgressProps = {
+ /**
+ * Maximum number of tasks to display at once
+ */
+ maxTasks?: number
+ /**
+ * Whether to show completed tasks briefly before they disappear
+ */
+ showCompleted?: boolean
+ /**
+ * How long to show completed tasks (in ms)
+ */
+ completedDuration?: number
+ /**
+ * Maximum width for task description
+ */
+ maxDescriptionWidth?: number
+ /**
+ * Compact mode - single line per task
+ */
+ compact?: boolean
+}
+
+/**
+ * TaskProgress component - displays running background tasks with real-time updates
+ *
+ * Features:
+ * - Shows task description/command (truncated)
+ * - Displays elapsed time (updating in real-time)
+ * - Spinner animation for active tasks
+ * - Status indicator (running/completed/failed)
+ *
+ * Usage:
+ * ```tsx
+ *
+ * ```
+ */
+export function TaskProgress(props: TaskProgressProps) {
+ const { theme } = useTheme()
+ const sync = useSync()
+ const sdk = useSDK()
+ const kv = useKV()
+
+ const maxTasks = () => props.maxTasks ?? 5
+ const showCompleted = () => props.showCompleted ?? true
+ const completedDuration = () => props.completedDuration ?? 3000
+ const maxDescWidth = () => props.maxDescriptionWidth ?? 40
+ const compact = () => props.compact ?? false
+
+ // Store for tasks with local state for elapsed time updates
+ const [store, setStore] = createStore<{
+ tasks: TaskInfo[]
+ recentlyCompleted: Set
+ }>({
+ tasks: [],
+ recentlyCompleted: new Set(),
+ })
+
+ // Track current time for elapsed duration updates
+ const [now, setNow] = createSignal(Date.now())
+
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ // Fetch tasks from API
+ const fetchTasks = async () => {
+ try {
+ const response = await sdk.fetch(`${sdk.url}/task?directory=${encodeURIComponent(directory())}`)
+ if (!response.ok) return
+ const tasks: TaskInfo[] = await response.json()
+ setStore("tasks", tasks)
+ } catch {
+ // Silently fail - tasks may not be available yet
+ }
+ }
+
+ // Set up polling for task updates
+ onMount(() => {
+ fetchTasks()
+
+ // Poll for task updates every 2 seconds
+ const pollInterval = setInterval(fetchTasks, 2000)
+
+ // Update elapsed time every second
+ const timeInterval = setInterval(() => {
+ setNow(Date.now())
+ }, 1000)
+
+ onCleanup(() => {
+ clearInterval(pollInterval)
+ clearInterval(timeInterval)
+ })
+ })
+
+ // Listen for task events for real-time updates
+ onMount(() => {
+ const handleTaskCreated = () => {
+ fetchTasks()
+ }
+
+ const handleTaskCompleted = (evt: any) => {
+ if (showCompleted()) {
+ const taskId = evt.properties?.id
+ if (taskId) {
+ setStore(
+ produce((draft) => {
+ draft.recentlyCompleted.add(taskId)
+ }),
+ )
+ // Remove from recently completed after duration
+ setTimeout(() => {
+ setStore(
+ produce((draft) => {
+ draft.recentlyCompleted.delete(taskId)
+ }),
+ )
+ }, completedDuration())
+ }
+ }
+ fetchTasks()
+ }
+
+ const handleTaskKilled = () => {
+ fetchTasks()
+ }
+
+ // Subscribe to SDK events
+ const unsubs = [
+ sdk.event.on("task.created" as any, handleTaskCreated),
+ sdk.event.on("task.completed" as any, handleTaskCompleted),
+ sdk.event.on("task.killed" as any, handleTaskKilled),
+ ]
+
+ onCleanup(() => {
+ unsubs.forEach((unsub) => unsub())
+ })
+ })
+
+ // Filter and sort tasks - running first, then recently completed
+ const visibleTasks = createMemo(() => {
+ const running = store.tasks
+ .filter((t) => t.status === "running")
+ .toSorted((a, b) => b.startTime - a.startTime)
+
+ const completed = showCompleted()
+ ? store.tasks
+ .filter((t) => t.status !== "running" && store.recentlyCompleted.has(t.id))
+ .toSorted((a, b) => b.startTime - a.startTime)
+ : []
+
+ return [...running, ...completed].slice(0, maxTasks())
+ })
+
+ const runningCount = createMemo(() => store.tasks.filter((t) => t.status === "running").length)
+
+ // Spinner frames for animation
+ const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
+ const animationsEnabled = () => kv.get("animations_enabled", true)
+
+ return (
+ 0}>
+
+
+ {(task) => {
+ const elapsed = createMemo(() => formatDuration(now() - task.startTime))
+ const description = createMemo(() => {
+ const desc = task.description || task.command
+ return truncate(desc, maxDescWidth())
+ })
+
+ const statusColor = createMemo(() => {
+ switch (task.status) {
+ case "running":
+ return theme.primary
+ case "completed":
+ return theme.success
+ case "failed":
+ return theme.error
+ default:
+ return theme.textMuted
+ }
+ })
+
+ const statusIcon = createMemo(() => {
+ switch (task.status) {
+ case "running":
+ return null // Will use spinner
+ case "completed":
+ return "\u2713"
+ case "failed":
+ return "\u2717"
+ default:
+ return "\u2022"
+ }
+ })
+
+ return (
+
+ {/* Status indicator - spinner for running, icon for completed/failed */}
+
+
+ {statusIcon()}
+
+ }
+ >
+
+ [...]
+
+ }
+ >
+
+
+
+
+
+ {/* Task description */}
+
+ {description()}
+
+
+ {/* Elapsed time */}
+
+ {elapsed()}
+
+
+ {/* Exit code for completed tasks */}
+
+
+ (exit: {task.exitCode})
+
+
+
+ )
+ }}
+
+
+ {/* Show overflow indicator if more tasks than maxTasks */}
+ maxTasks()}>
+
+ +{runningCount() - maxTasks()} more task{runningCount() - maxTasks() > 1 ? "s" : ""} running
+
+
+
+
+ )
+}
+
+/**
+ * TaskProgressBadge - Compact badge showing number of running tasks
+ *
+ * Useful for status bars or headers where space is limited
+ */
+export function TaskProgressBadge() {
+ const { theme } = useTheme()
+ const sync = useSync()
+ const sdk = useSDK()
+ const kv = useKV()
+
+ const [runningCount, setRunningCount] = createSignal(0)
+
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ const fetchTaskCount = async () => {
+ try {
+ const response = await sdk.fetch(`${sdk.url}/task?directory=${encodeURIComponent(directory())}`)
+ if (!response.ok) return
+ const tasks: TaskInfo[] = await response.json()
+ setRunningCount(tasks.filter((t) => t.status === "running").length)
+ } catch {
+ // Silently fail
+ }
+ }
+
+ onMount(() => {
+ fetchTaskCount()
+ const interval = setInterval(fetchTaskCount, 2000)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
+ const animationsEnabled = () => kv.get("animations_enabled", true)
+
+ return (
+ 0}>
+
+
+ [...]
+
+ }
+ >
+
+
+
+ {runningCount()} task{runningCount() > 1 ? "s" : ""}
+
+
+
+ )
+}
+
+/**
+ * TaskProgressInline - Single-line progress indicator for embedding in status areas
+ *
+ * Shows the most recent running task with a spinner and truncated description
+ */
+export function TaskProgressInline(props: { maxWidth?: number }) {
+ const { theme } = useTheme()
+ const sync = useSync()
+ const sdk = useSDK()
+ const kv = useKV()
+
+ const maxWidth = () => props.maxWidth ?? 30
+
+ const [latestTask, setLatestTask] = createSignal(null)
+ const [now, setNow] = createSignal(Date.now())
+
+ const directory = () => sync.data.path.directory || process.cwd()
+
+ const fetchLatestTask = async () => {
+ try {
+ const response = await sdk.fetch(`${sdk.url}/task?directory=${encodeURIComponent(directory())}`)
+ if (!response.ok) return
+ const tasks: TaskInfo[] = await response.json()
+ const running = tasks
+ .filter((t) => t.status === "running")
+ .toSorted((a, b) => b.startTime - a.startTime)
+ setLatestTask(running[0] ?? null)
+ } catch {
+ // Silently fail
+ }
+ }
+
+ onMount(() => {
+ fetchLatestTask()
+ const pollInterval = setInterval(fetchLatestTask, 2000)
+ const timeInterval = setInterval(() => setNow(Date.now()), 1000)
+
+ onCleanup(() => {
+ clearInterval(pollInterval)
+ clearInterval(timeInterval)
+ })
+ })
+
+ const elapsed = createMemo(() => {
+ const task = latestTask()
+ if (!task) return ""
+ return formatDuration(now() - task.startTime)
+ })
+
+ const description = createMemo(() => {
+ const task = latestTask()
+ if (!task) return ""
+ const desc = task.description || task.command
+ return truncate(desc, maxWidth())
+ })
+
+ const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
+ const animationsEnabled = () => kv.get("animations_enabled", true)
+
+ return (
+
+
+
+ [...]
+
+ }
+ >
+
+
+ {description()}
+ {elapsed()}
+
+
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index e63f10ba80c..470c0660992 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
+import { TaskManager, type TaskInfo } from "@/task"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -22,13 +23,15 @@ await Log.init({
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
- e: e instanceof Error ? e.message : e,
+ message: e instanceof Error ? e.message : String(e),
+ stack: e instanceof Error ? e.stack : undefined,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
- e: e instanceof Error ? e.message : e,
+ message: e instanceof Error ? e.message : String(e),
+ stack: e instanceof Error ? e.stack : undefined,
})
})
@@ -65,22 +68,39 @@ const startEventStream = (directory: string) => {
;(async () => {
while (!signal.aborted) {
- const events = await Promise.resolve(
- sdk.event.subscribe(
- {},
- {
- signal,
- },
- ),
- ).catch(() => undefined)
+ let events: AsyncIterable | undefined
+ try {
+ // Attempt to subscribe to server-side events; log any error
+ events = await Promise.resolve(
+ sdk.event.subscribe({}, { signal }),
+ )
+ } catch (err) {
+ Log.Default.error("event subscribe failed", {
+ directory,
+ error: err instanceof Error ? err.message : String(err),
+ stack: err instanceof Error ? err.stack : undefined,
+ })
+ // Wait a bit before retrying
+ await Bun.sleep(500)
+ continue
+ }
if (!events) {
+ Log.Default.debug("no events from subscribe, retrying", { directory })
await Bun.sleep(250)
continue
}
- for await (const event of events.stream) {
- Rpc.emit("event", event as Event)
+ try {
+ for await (const event of (events as any).stream ?? events) {
+ Rpc.emit("event", event as Event)
+ }
+ } catch (err) {
+ Log.Default.error("error while reading event stream", {
+ directory,
+ error: err instanceof Error ? err.message : String(err),
+ stack: err instanceof Error ? err.stack : undefined,
+ })
}
if (!signal.aborted) {
@@ -88,8 +108,10 @@ const startEventStream = (directory: string) => {
}
}
})().catch((error) => {
- Log.Default.error("event stream error", {
- error: error instanceof Error ? error.message : error,
+ Log.Default.error("event stream fatal", {
+ directory,
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
})
})
}
@@ -108,8 +130,30 @@ export const rpc = {
headers,
body: input.body,
})
- const response = await Server.App().fetch(request)
+
+ let response: Response
+ try {
+ response = await Server.App().fetch(request)
+ } catch (err) {
+ Log.Default.error("rpc.fetch network error", {
+ url: input.url,
+ method: input.method,
+ error: err instanceof Error ? err.message : String(err),
+ stack: err instanceof Error ? err.stack : undefined,
+ })
+ throw err
+ }
+
const body = await response.text()
+
+ if (!response.ok) {
+ Log.Default.error("rpc.fetch bad response", {
+ url: input.url,
+ status: response.status,
+ body: body.slice(0, 2000),
+ })
+ }
+
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
@@ -140,6 +184,71 @@ export const rpc = {
await Instance.disposeAll()
if (server) server.stop(true)
},
+
+ // Task management RPC handlers
+ async taskList(input: { directory: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.list(),
+ })
+ },
+
+ async taskGet(input: { directory: string; taskId: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.get(input.taskId),
+ })
+ },
+
+ async taskKill(input: { directory: string; taskId: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.kill(input.taskId),
+ })
+ },
+
+ async taskRead(input: { directory: string; taskId: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.read(input.taskId),
+ })
+ },
+
+ async taskTail(input: { directory: string; taskId: string; lines?: number }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.tail(input.taskId, input.lines ?? 50),
+ })
+ },
+
+ async taskInput(input: { directory: string; taskId: string; data: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.input(input.taskId, input.data),
+ })
+ },
+
+ async taskCleanup(input: { directory: string; maxAge?: number }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.cleanup(input.maxAge),
+ })
+ },
+
+ async taskRemove(input: { directory: string; taskId: string }): Promise {
+ return Instance.provide({
+ directory: input.directory,
+ init: InstanceBootstrap,
+ fn: () => TaskManager.remove(input.taskId),
+ })
+ },
}
Rpc.listen(rpc)
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index b2142e29b94..1a887456c5b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -693,6 +693,7 @@ export namespace Config {
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
+ task_list: z.string().optional().default("j").describe("List background tasks"),
agent_list: z.string().optional().default("a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
diff --git a/packages/opencode/src/server/routes/task.ts b/packages/opencode/src/server/routes/task.ts
new file mode 100644
index 00000000000..bc34f2ad0c2
--- /dev/null
+++ b/packages/opencode/src/server/routes/task.ts
@@ -0,0 +1,374 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+import { TaskManager, TaskEvent, type TaskInfo } from "../../task"
+import {
+ migrateToBackground,
+ killForegroundProcess,
+ listForegroundProcesses,
+ getForegroundProcess,
+} from "../../tool/bash"
+import { Log } from "../../util/log"
+import { Instance } from "../../project/instance"
+
+const log = Log.create({ service: "task-routes" })
+
+const TaskInfoSchema = z
+ .object({
+ id: z.string(),
+ pid: z.number(),
+ command: z.string(),
+ startTime: z.number(),
+ status: z.enum(["running", "completed", "failed"]),
+ exitCode: z.number().optional(),
+ workdir: z.string(),
+ description: z.string().optional(),
+ logFile: z.string(),
+ reconnectable: z.boolean(),
+ })
+ .meta({ ref: "TaskInfo" })
+
+export const TaskRoutes = lazy(() =>
+ new Hono()
+ .get(
+ "/",
+ describeRoute({
+ summary: "List all background tasks",
+ description: "Get a list of all background tasks for the current project.",
+ operationId: "task.list",
+ responses: {
+ 200: {
+ description: "List of tasks",
+ content: {
+ "application/json": {
+ schema: resolver(TaskInfoSchema.array()),
+ },
+ },
+ },
+ },
+ }),
+ validator("query", z.object({ directory: z.string().optional() })),
+ async (c) => {
+ try {
+ const { directory } = c.req.valid("query")
+ const targetDir = directory || Instance.directory
+ log.debug("listing tasks", { directory, targetDir, instanceDir: Instance.directory })
+ const tasks = await TaskManager.list(targetDir)
+ log.debug("tasks returned", { count: tasks.length })
+ return c.json(tasks)
+ } catch (err) {
+ log.error("failed to list tasks", { error: String(err) })
+ throw err
+ }
+ },
+ )
+ .get(
+ "/:taskId",
+ describeRoute({
+ summary: "Get task details",
+ description: "Get details of a specific background task.",
+ operationId: "task.get",
+ responses: {
+ 200: {
+ description: "Task details",
+ content: {
+ "application/json": {
+ schema: resolver(TaskInfoSchema.nullable()),
+ },
+ },
+ },
+ ...errors(404),
+ },
+ }),
+ validator("param", z.object({ taskId: z.string() })),
+ async (c) => {
+ const { taskId } = c.req.valid("param")
+ const task = await TaskManager.get(taskId)
+ if (!task) {
+ return c.json(null, 404)
+ }
+ return c.json(task)
+ },
+ )
+ .post(
+ "/:taskId/kill",
+ describeRoute({
+ summary: "Kill a running task",
+ description: "Terminate a running background task.",
+ operationId: "task.kill",
+ responses: {
+ 200: {
+ description: "Task killed",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(404),
+ },
+ }),
+ validator("param", z.object({ taskId: z.string() })),
+ async (c) => {
+ const { taskId } = c.req.valid("param")
+ const killed = await TaskManager.kill(taskId)
+ return c.json(killed)
+ },
+ )
+ .delete(
+ "/:taskId",
+ describeRoute({
+ summary: "Remove a completed task",
+ description: "Remove a completed or failed task from the list.",
+ operationId: "task.remove",
+ responses: {
+ 200: {
+ description: "Task removed",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("param", z.object({ taskId: z.string() })),
+ async (c) => {
+ const { taskId } = c.req.valid("param")
+ const removed = await TaskManager.remove(taskId)
+ return c.json(removed)
+ },
+ )
+ .get(
+ "/:taskId/output",
+ describeRoute({
+ summary: "Get task output",
+ description: "Get the full output of a task.",
+ operationId: "task.output",
+ responses: {
+ 200: {
+ description: "Task output",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ output: z.string() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ taskId: z.string() })),
+ async (c) => {
+ const { taskId } = c.req.valid("param")
+ const output = await TaskManager.read(taskId)
+ return c.json({ output })
+ },
+ )
+ .get(
+ "/:taskId/tail",
+ describeRoute({
+ summary: "Get last N lines of task output",
+ description: "Get the last N lines of a task's output.",
+ operationId: "task.tail",
+ responses: {
+ 200: {
+ description: "Task output tail",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ output: z.string() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ taskId: z.string() })),
+ validator("query", z.object({ lines: z.coerce.number().optional().default(50) })),
+ async (c) => {
+ const { taskId } = c.req.valid("param")
+ const { lines } = c.req.valid("query")
+ const output = await TaskManager.tail(taskId, lines)
+ return c.json({ output })
+ },
+ )
+ .post(
+ "/cleanup",
+ describeRoute({
+ summary: "Cleanup old tasks",
+ description: "Remove completed tasks older than the specified age.",
+ operationId: "task.cleanup",
+ responses: {
+ 200: {
+ description: "Number of tasks removed",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ removed: z.number() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("json", z.object({ maxAge: z.number().optional() })),
+ async (c) => {
+ const { maxAge } = c.req.valid("json")
+ const removed = await TaskManager.cleanup(maxAge)
+ return c.json({ removed })
+ },
+ )
+ // Foreground process endpoints
+ .get(
+ "/foreground",
+ describeRoute({
+ summary: "List foreground processes",
+ description: "Get a list of all running foreground bash processes that can be migrated to background.",
+ operationId: "task.foreground.list",
+ responses: {
+ 200: {
+ description: "List of foreground processes",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.array(
+ z.object({
+ callID: z.string(),
+ sessionID: z.string(),
+ pid: z.number(),
+ command: z.string(),
+ description: z.string(),
+ workdir: z.string(),
+ startTime: z.number(),
+ }),
+ ),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const processes = listForegroundProcesses().map((p) => ({
+ callID: p.callID,
+ sessionID: p.sessionID,
+ pid: p.pid,
+ command: p.command,
+ description: p.description,
+ workdir: p.workdir,
+ startTime: p.startTime,
+ }))
+ return c.json(processes)
+ },
+ )
+ .get(
+ "/foreground/:callID",
+ describeRoute({
+ summary: "Get foreground process",
+ description: "Get details of a specific foreground bash process.",
+ operationId: "task.foreground.get",
+ responses: {
+ 200: {
+ description: "Foreground process details",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .object({
+ callID: z.string(),
+ sessionID: z.string(),
+ pid: z.number(),
+ command: z.string(),
+ description: z.string(),
+ workdir: z.string(),
+ startTime: z.number(),
+ outputLength: z.number(),
+ })
+ .nullable(),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ callID: z.string() })),
+ async (c) => {
+ const { callID } = c.req.valid("param")
+ const proc = getForegroundProcess(callID)
+ if (!proc) {
+ return c.json(null)
+ }
+ return c.json({
+ callID: proc.callID,
+ sessionID: proc.sessionID,
+ pid: proc.pid,
+ command: proc.command,
+ description: proc.description,
+ workdir: proc.workdir,
+ startTime: proc.startTime,
+ outputLength: proc.output.length,
+ })
+ },
+ )
+ .post(
+ "/foreground/:callID/background",
+ describeRoute({
+ summary: "Migrate foreground process to background",
+ description:
+ "Migrate a running foreground bash process to a background task. The process continues running and can be managed via the task API.",
+ operationId: "task.foreground.migrate",
+ responses: {
+ 200: {
+ description: "Migration result",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ success: z.boolean(),
+ taskId: z.string().nullable(),
+ error: z.string().optional(),
+ }),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ callID: z.string() })),
+ async (c) => {
+ const { callID } = c.req.valid("param")
+ try {
+ const taskId = await migrateToBackground(callID)
+ if (taskId) {
+ return c.json({ success: true, taskId })
+ } else {
+ return c.json({ success: false, taskId: null, error: "Process not found or already migrated" })
+ }
+ } catch (err) {
+ return c.json({ success: false, taskId: null, error: String(err) })
+ }
+ },
+ )
+ .post(
+ "/foreground/:callID/kill",
+ describeRoute({
+ summary: "Kill foreground process",
+ description: "Terminate a running foreground bash process.",
+ operationId: "task.foreground.kill",
+ responses: {
+ 200: {
+ description: "Kill result",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ success: z.boolean() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ callID: z.string() })),
+ async (c) => {
+ const { callID } = c.req.valid("param")
+ const success = await killForegroundProcess(callID)
+ return c.json({ success })
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 15b7f829b9c..6a83b20274b 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -39,6 +39,7 @@ import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
+import { TaskRoutes } from "./routes/task"
import { MDNS } from "./mdns"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
@@ -161,6 +162,7 @@ export namespace Server {
.route("/provider", ProviderRoutes())
.route("/", FileRoutes())
.route("/mcp", McpRoutes())
+ .route("/task", TaskRoutes())
.route("/tui", TuiRoutes())
.post(
"/instance/dispose",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 587f9498008..cd83efaec53 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -44,6 +44,7 @@ import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
+import { registerForegroundProcess, unregisterForegroundProcess } from "@/tool/bash"
import { Truncate } from "@/tool/truncation"
// @ts-ignore
@@ -663,17 +664,14 @@ export namespace SessionPrompt {
agent: input.agent.name,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
+ // Only update metadata when status is "running" (has title and metadata fields)
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
- title: val.title,
+ ...match.state,
+ title: val.title ?? match.state.title,
metadata: val.metadata,
- status: "running",
- input: args,
- time: {
- start: Date.now(),
- },
},
})
}
@@ -1497,27 +1495,81 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
})
+ const startTime = Date.now()
+ const callID = part.callID
let output = ""
+ // Initialize metadata with process info for UI controls
+ // Cast to ToolPart since we know it's a tool part with running status
+ const toolPart = part as MessageV2.ToolPart
+ if (toolPart.state.status === "running") {
+ toolPart.state.metadata = {
+ output: "",
+ description: "",
+ pid: proc.pid,
+ startTime,
+ callID,
+ running: true,
+ }
+ await Session.updatePart(toolPart)
+
+ }
+
+ // Track migration state
+ const migrationState = {
+ migrated: false,
+ taskId: null as string | null,
+ resolveWait: null as (() => void) | null,
+ }
+
+ // Register for [bg] and [kill] controls in UI
+ registerForegroundProcess(
+ {
+ callID,
+ sessionID: input.sessionID,
+ pid: proc.pid!,
+ command: input.command,
+ description: input.command,
+ workdir: Instance.directory,
+ startTime,
+ process: proc,
+ output: "",
+ },
+ (result) => {
+ // Called when process is migrated to background
+ migrationState.migrated = true
+ migrationState.taskId = result.taskId
+ if (migrationState.resolveWait) migrationState.resolveWait()
+ },
+ )
+
proc.stdout?.on("data", (chunk) => {
output += chunk.toString()
- if (part.state.status === "running") {
- part.state.metadata = {
+ if (toolPart.state.status === "running") {
+ toolPart.state.metadata = {
output: output,
description: "",
+ pid: proc.pid,
+ startTime,
+ callID,
+ running: true,
}
- Session.updatePart(part)
+ Session.updatePart(toolPart)
}
})
proc.stderr?.on("data", (chunk) => {
output += chunk.toString()
- if (part.state.status === "running") {
- part.state.metadata = {
+ if (toolPart.state.status === "running") {
+ toolPart.state.metadata = {
output: output,
description: "",
+ pid: proc.pid,
+ startTime,
+ callID,
+ running: true,
}
- Session.updatePart(part)
+ Session.updatePart(toolPart)
}
})
@@ -1539,6 +1591,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
abort.addEventListener("abort", abortHandler, { once: true })
await new Promise((resolve) => {
+ migrationState.resolveWait = resolve
proc.on("close", () => {
exited = true
abort.removeEventListener("abort", abortHandler)
@@ -1546,6 +1599,48 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
})
+ // Unregister from foreground processes (if not migrated, migration already removes it)
+ if (!migrationState.migrated) {
+ unregisterForegroundProcess(callID)
+ }
+
+ // Handle migration case - mark as completed with migration info
+ if (migrationState.migrated) {
+ // Clean up abort handler to prevent killing the migrated process
+ abort.removeEventListener("abort", abortHandler)
+
+ msg.time.completed = Date.now()
+ if (part.state.status === "running") {
+ part.state = {
+ status: "completed",
+ time: {
+ ...part.state.time,
+ end: Date.now(),
+ },
+ input: part.state.input,
+ title: "",
+ metadata: {
+ output: `Process migrated to background task: ${migrationState.taskId}`,
+ description: "",
+ running: false,
+ migrated: true,
+ taskId: migrationState.taskId,
+ },
+ output: `Process migrated to background task: ${migrationState.taskId}`,
+ }
+ }
+
+ // Fire-and-forget: Persist session state in background (non-blocking for faster UI response)
+ void (async () => {
+ await Session.updateMessage(msg)
+ if (part.state.status === "completed") {
+ await Session.updatePart(part)
+ }
+ })()
+
+ return { info: msg, parts: [part] }
+ }
+
if (aborted) {
output += "\n\n" + ["", "User aborted the command", ""].join("\n")
}
@@ -1563,6 +1658,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
metadata: {
output,
description: "",
+ running: false,
},
output,
}
diff --git a/packages/opencode/src/task/events.ts b/packages/opencode/src/task/events.ts
new file mode 100644
index 00000000000..1c9a1b20bf5
--- /dev/null
+++ b/packages/opencode/src/task/events.ts
@@ -0,0 +1,89 @@
+import z from "zod"
+import { BusEvent } from "../bus/bus-event"
+
+/**
+ * Task status type
+ */
+export const TaskStatus = z.enum(["running", "completed", "failed"])
+export type TaskStatus = z.infer
+
+/**
+ * Task info schema for events
+ */
+export const TaskInfoSchema = z.object({
+ id: z.string(),
+ pid: z.number(),
+ command: z.string(),
+ startTime: z.number(),
+ status: TaskStatus,
+ exitCode: z.number().optional(),
+ workdir: z.string(),
+ description: z.string().optional(),
+ logFile: z.string(),
+ reconnectable: z.boolean(),
+})
+
+export type TaskInfo = z.infer
+
+/**
+ * Event: Task created and started
+ */
+export const TaskCreated = BusEvent.define(
+ "task.created",
+ z.object({
+ info: TaskInfoSchema,
+ }),
+)
+
+/**
+ * Event: Task output received
+ */
+export const TaskOutput = BusEvent.define(
+ "task.output",
+ z.object({
+ id: z.string(),
+ data: z.string(),
+ isError: z.boolean(),
+ }),
+)
+
+/**
+ * Event: Task completed (success or failure)
+ */
+export const TaskCompleted = BusEvent.define(
+ "task.completed",
+ z.object({
+ id: z.string(),
+ exitCode: z.number().nullable(),
+ status: TaskStatus,
+ }),
+)
+
+/**
+ * Event: Task killed
+ */
+export const TaskKilled = BusEvent.define(
+ "task.killed",
+ z.object({
+ id: z.string(),
+ }),
+)
+
+/**
+ * Event: Task reconnected (after restart)
+ */
+export const TaskReconnected = BusEvent.define(
+ "task.reconnected",
+ z.object({
+ id: z.string(),
+ stillRunning: z.boolean(),
+ }),
+)
+
+export namespace TaskEvent {
+ export const Created = TaskCreated
+ export const Output = TaskOutput
+ export const Completed = TaskCompleted
+ export const Killed = TaskKilled
+ export const Reconnected = TaskReconnected
+}
diff --git a/packages/opencode/src/task/index.ts b/packages/opencode/src/task/index.ts
new file mode 100644
index 00000000000..570433d729c
--- /dev/null
+++ b/packages/opencode/src/task/index.ts
@@ -0,0 +1,801 @@
+import z from "zod"
+import { spawn, type ChildProcess } from "child_process"
+import { randomBytes } from "crypto"
+import { Log } from "../util/log"
+import { Instance } from "../project/instance"
+import { Bus } from "../bus"
+import { Shell } from "../shell/shell"
+import { TaskPersistence } from "./persistence"
+import { TaskEvent, type TaskInfo, type TaskStatus } from "./events"
+
+export { TaskEvent, type TaskInfo, type TaskStatus } from "./events"
+
+const log = Log.create({ service: "task-manager" })
+
+/**
+ * Options for creating a background task
+ */
+export interface CreateTaskOptions {
+ command: string
+ workdir?: string
+ description?: string
+ reconnectable?: boolean
+}
+
+/**
+ * Internal task state with process handle
+ */
+interface ManagedTask extends TaskInfo {
+ process?: ChildProcess
+ output: string[]
+}
+
+/**
+ * Generate a unique task ID
+ */
+function generateTaskId(): string {
+ const timestamp = Date.now().toString(36)
+ const random = randomBytes(4).toString("hex")
+ return `task_${timestamp}_${random}`
+}
+
+/**
+ * Create instance-scoped task state
+ */
+const state = Instance.state(
+ () => {
+ const tasks = new Map()
+ let initialized = false
+ return { tasks, initialized }
+ },
+ async (entry) => {
+ // Cleanup: don't kill processes on dispose, just clear references
+ // (allows tasks to survive instance disposal if reconnectable)
+ for (const task of entry.tasks.values()) {
+ task.process = undefined
+ }
+ entry.tasks.clear()
+ },
+)
+
+export namespace TaskManager {
+ /**
+ * Initialize task manager, loading persisted tasks and reconnecting to running processes
+ */
+ export async function init(): Promise {
+ const s = state()
+ if (s.initialized) return
+ s.initialized = true
+
+ let persisted = await TaskPersistence.loadAll(Instance.directory)
+ log.info("loading persisted tasks", { count: persisted.length, directory: Instance.directory })
+
+ // Also try loading persisted tasks from the current process cwd as a fallback.
+ // This helps when a task was created or migrated while the server's Instance.directory
+ // differed from the process working directory used when launching the task.
+ try {
+ const cwd = process.cwd()
+ if (cwd && cwd !== Instance.directory) {
+ const cwdPersisted = await TaskPersistence.loadAll(cwd)
+ log.info("loading persisted tasks from cwd fallback", { count: cwdPersisted.length, cwd })
+ // Merge without duplicating IDs
+ for (const p of cwdPersisted) {
+ if (!persisted.find((x) => x.id === p.id)) persisted.push(p)
+ }
+ }
+ } catch (err) {
+ log.debug("failed to load persisted tasks from cwd fallback", { error: String(err) })
+ }
+
+ for (const saved of persisted) {
+ try {
+ const stillRunning = TaskPersistence.isProcessRunning(saved.pid)
+
+ const task: ManagedTask = {
+ ...saved,
+ output: [],
+ process: undefined,
+ }
+
+ // If process is still running and task was marked as running, keep it
+ if (stillRunning && saved.status === "running" && saved.reconnectable) {
+ log.info("reconnected to running task", { taskId: saved.id, pid: saved.pid })
+ s.tasks.set(saved.id, task)
+ try {
+ await Bus.publish(TaskEvent.Reconnected, {
+ id: saved.id,
+ stillRunning: true,
+ })
+ } catch (err) {
+ log.error("failed to publish reconnected event", { taskId: saved.id, error: String(err) })
+ }
+ } else if (saved.status === "running") {
+ // Process died while we weren't watching - mark as failed
+ task.status = "failed"
+ try {
+ await TaskPersistence.save(Instance.directory, task)
+ } catch (err) {
+ log.error("failed to save task state during init", { taskId: saved.id, error: String(err) })
+ }
+ s.tasks.set(saved.id, task)
+ try {
+ await Bus.publish(TaskEvent.Reconnected, {
+ id: saved.id,
+ stillRunning: false,
+ })
+ } catch (err) {
+ log.error("failed to publish reconnected event (dead)", { taskId: saved.id, error: String(err) })
+ }
+ } else {
+ // Already completed/failed, just load it
+ s.tasks.set(saved.id, task)
+ }
+ } catch (err) {
+ log.error("error while initializing persisted task", { id: saved.id, error: String(err) })
+ }
+ }
+ }
+
+ /**
+ * Create and start a new background task
+ */
+ export async function create(opts: CreateTaskOptions): Promise {
+ await init()
+
+ const taskId = generateTaskId()
+ const directory = Instance.directory // Capture directory for use in async callbacks
+ const workdir = opts.workdir || directory
+ const logFile = TaskPersistence.getLogFile(directory, taskId)
+ const shell = Shell.acceptable()
+
+ log.info("creating background task", {
+ taskId,
+ command: opts.command,
+ workdir,
+ })
+
+ let proc: ChildProcess
+ try {
+ proc = spawn(opts.command, {
+ shell,
+ cwd: workdir,
+ env: { ...process.env },
+ stdio: ["pipe", "pipe", "pipe"],
+ detached: process.platform !== "win32",
+ })
+ } catch (err) {
+ log.error("spawn failed", { taskId, command: opts.command, error: String(err) })
+ throw err
+ }
+
+ const task: ManagedTask = {
+ id: taskId,
+ pid: proc.pid!,
+ command: opts.command,
+ startTime: Date.now(),
+ status: "running",
+ workdir,
+ description: opts.description,
+ logFile,
+ reconnectable: opts.reconnectable ?? true,
+ process: proc,
+ output: [],
+ }
+
+ state().tasks.set(taskId, task)
+
+ // Save initial state
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (err) {
+ log.error("failed to save initial task state", { taskId, error: String(err) })
+ }
+
+ // Handle stdout - wrapped in Instance.provide for async context
+ proc.stdout?.on("data", (chunk: Buffer) => {
+ const data = chunk.toString()
+ task.output.push(data)
+ log.debug("task stdout chunk", { taskId, len: data.length })
+ // Fire and forget - don't await to avoid blocking process
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.appendLog(directory, taskId, data)
+ } catch (err) {
+ log.error("failed to append stdout to log", { taskId, error: String(err) })
+ }
+
+ try {
+ await Bus.publish(TaskEvent.Output, {
+ id: taskId,
+ data,
+ isError: false,
+ })
+ } catch (err) {
+ log.error("failed to publish stdout output event", { taskId, error: String(err) })
+ }
+ },
+ })
+ })
+
+ // Handle stderr - wrapped in Instance.provide for async context
+ proc.stderr?.on("data", (chunk: Buffer) => {
+ const data = chunk.toString()
+ task.output.push(data)
+ log.debug("task stderr chunk", { taskId, len: data.length })
+ // Fire and forget - don't await to avoid blocking process
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.appendLog(directory, taskId, data)
+ } catch (err) {
+ log.error("failed to append stderr to log", { taskId, error: String(err) })
+ }
+
+ try {
+ await Bus.publish(TaskEvent.Output, {
+ id: taskId,
+ data,
+ isError: true,
+ })
+ } catch (err) {
+ log.error("failed to publish stderr output event", { taskId, error: String(err) })
+ }
+ },
+ })
+ })
+
+ // Handle exit - wrapped in Instance.provide for async context
+ proc.on("exit", (code) => {
+ task.exitCode = code ?? undefined
+ task.status = code === 0 ? "completed" : "failed"
+ task.process = undefined
+
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (err) {
+ log.error("failed to save task state on exit", { taskId, exitCode: code, error: String(err) })
+ }
+
+ try {
+ await Bus.publish(TaskEvent.Completed, {
+ id: taskId,
+ exitCode: code,
+ status: task.status,
+ })
+ } catch (err) {
+ log.error("failed to publish completed event", { taskId, exitCode: code, error: String(err) })
+ }
+
+ log.info("background task completed", {
+ taskId,
+ exitCode: code,
+ status: task.status,
+ })
+ },
+ })
+ })
+
+ // Handle errors - wrapped in Instance.provide for async context
+ proc.once("error", (err) => {
+ const errMsg = err instanceof Error ? `${err.message} ${err.stack ?? ""}` : String(err)
+ log.error("background task error", { taskId, error: errMsg })
+ task.status = "failed"
+ task.process = undefined
+
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (e) {
+ log.error("failed to save task state after error", { taskId, error: String(e) })
+ }
+
+ try {
+ await Bus.publish(TaskEvent.Completed, {
+ id: taskId,
+ exitCode: null,
+ status: "failed",
+ })
+ } catch (e) {
+ log.error("failed to publish completed event after error", { taskId, error: String(e) })
+ }
+ },
+ })
+ })
+
+ // Publish created event
+ try {
+ await Bus.publish(TaskEvent.Created, {
+ info: {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ },
+ })
+ } catch (err) {
+ log.error("failed to publish created event", { taskId, error: String(err) })
+ }
+
+ return {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ }
+ }
+
+ /**
+ * List all tasks
+ * @param directory Optional directory to search for tasks. If not provided, uses Instance.directory
+ */
+ export async function list(directory?: string): Promise {
+ await init()
+
+ const targetDir = directory || Instance.directory
+ log.debug("list called", { directory, targetDir, instanceDir: Instance.directory })
+
+ // Ensure we pick up any tasks that were persisted on disk by another instance
+ // after init() ran (e.g. migrated foreground processes). This merges persisted
+ // tasks into the in-memory state if they're missing.
+ try {
+ const persisted = await TaskPersistence.loadAll(targetDir)
+ log.debug("loaded persisted tasks", { count: persisted.length })
+ const cwd = process.cwd()
+ if (cwd && cwd !== targetDir) {
+ const cwdPersisted = await TaskPersistence.loadAll(cwd)
+ for (const p of cwdPersisted) {
+ if (!persisted.find((x) => x.id === p.id)) persisted.push(p)
+ }
+ }
+ // Also check Instance.directory if different from targetDir
+ if (Instance.directory !== targetDir && Instance.directory !== cwd) {
+ const instancePersisted = await TaskPersistence.loadAll(Instance.directory)
+ for (const p of instancePersisted) {
+ if (!persisted.find((x) => x.id === p.id)) persisted.push(p)
+ }
+ }
+
+ for (const saved of persisted) {
+ if (!state().tasks.has(saved.id)) {
+ const task: ManagedTask = {
+ ...saved,
+ output: [],
+ process: undefined,
+ }
+ state().tasks.set(saved.id, task)
+ }
+ }
+ } catch (err) {
+ log.debug("failed to refresh tasks from disk during list", { error: String(err) })
+ }
+
+ return Array.from(state().tasks.values()).map((t) => ({
+ id: t.id,
+ pid: t.pid,
+ command: t.command,
+ startTime: t.startTime,
+ status: t.status,
+ exitCode: t.exitCode,
+ workdir: t.workdir,
+ description: t.description,
+ logFile: t.logFile,
+ reconnectable: t.reconnectable,
+ }))
+ }
+
+ /**
+ * Get a specific task by ID
+ */
+ export async function get(taskId: string): Promise {
+ await init()
+ const task = state().tasks.get(taskId)
+ if (!task) return undefined
+
+ // Check if process has exited but event hasn't fired yet
+ if (task.status === "running" && task.process) {
+ const exitCode = task.process.exitCode
+ if (exitCode !== null) {
+ // Process has exited, update status
+ task.exitCode = exitCode
+ task.status = exitCode === 0 ? "completed" : "failed"
+ task.process = undefined
+ }
+ }
+
+ return {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ exitCode: task.exitCode,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ }
+ }
+
+ /**
+ * Kill a running task
+ */
+ export async function kill(taskId: string): Promise {
+ await init()
+ const task = state().tasks.get(taskId)
+ if (!task) {
+ log.warn("cannot kill unknown task", { taskId })
+ return false
+ }
+
+ if (task.status !== "running") {
+ log.warn("cannot kill non-running task", { taskId, status: task.status })
+ return false
+ }
+
+ log.info("killing task", { taskId, pid: task.pid })
+
+ // If we have the process handle, use Shell.killTree
+ if (task.process) {
+ let exited = false
+ task.process.once("exit", () => {
+ exited = true
+ })
+ await Shell.killTree(task.process, { exited: () => exited })
+ } else {
+ // No process handle (reconnected task), try direct kill
+ try {
+ if (process.platform === "win32") {
+ spawn("taskkill", ["/pid", String(task.pid), "/f", "/t"], { stdio: "ignore" })
+ } else {
+ process.kill(-task.pid, "SIGTERM")
+ await Bun.sleep(200)
+ try {
+ process.kill(-task.pid, "SIGKILL")
+ } catch {
+ // Process may have already exited
+ }
+ }
+ } catch {
+ // Process may have already exited
+ try {
+ process.kill(task.pid, "SIGTERM")
+ await Bun.sleep(200)
+ process.kill(task.pid, "SIGKILL")
+ } catch {
+ // Ignore
+ }
+ }
+ }
+
+ task.status = "failed"
+ task.process = undefined
+
+ try {
+ await TaskPersistence.save(Instance.directory, task)
+ } catch (err) {
+ log.error("failed to save task state after kill", { taskId, error: String(err) })
+ }
+
+ try {
+ await Bus.publish(TaskEvent.Killed, { id: taskId })
+ } catch (err) {
+ log.error("failed to publish killed event", { taskId, error: String(err) })
+ }
+
+ return true
+ }
+
+ /**
+ * Read full output from a task
+ */
+ export async function read(taskId: string): Promise {
+ await init()
+
+ // First check in-memory output
+ const task = state().tasks.get(taskId)
+ if (task && task.output.length > 0) {
+ return task.output.join("")
+ }
+
+ // Fall back to log file
+ return TaskPersistence.readLog(Instance.directory, taskId)
+ }
+
+ /**
+ * Get last N lines of output from a task
+ */
+ export async function tail(taskId: string, lines: number = 50): Promise {
+ await init()
+ return TaskPersistence.tailLog(Instance.directory, taskId, lines)
+ }
+
+ /**
+ * Send input to a task's stdin
+ */
+ export async function input(taskId: string, data: string): Promise {
+ await init()
+ const task = state().tasks.get(taskId)
+
+ if (!task) {
+ log.warn("cannot send input to unknown task", { taskId })
+ return false
+ }
+
+ if (!task.process) {
+ log.warn("cannot send input to task without process handle", { taskId })
+ return false
+ }
+
+ if (task.status !== "running") {
+ log.warn("cannot send input to non-running task", { taskId, status: task.status })
+ return false
+ }
+
+ if (!task.process.stdin) {
+ log.warn("task stdin not available", { taskId })
+ return false
+ }
+
+ try {
+ task.process.stdin.write(data)
+ log.debug("sent input to task", { taskId, dataLength: data.length })
+ return true
+ } catch (err) {
+ log.error("failed to send input to task", { taskId, error: String(err) })
+ return false
+ }
+ }
+
+ /**
+ * Cleanup completed tasks older than maxAge milliseconds
+ */
+ export async function cleanup(maxAge: number = 24 * 60 * 60 * 1000): Promise {
+ await init()
+ const now = Date.now()
+ let removed = 0
+
+ for (const task of state().tasks.values()) {
+ if (task.status !== "running" && now - task.startTime > maxAge) {
+ state().tasks.delete(task.id)
+ await TaskPersistence.remove(Instance.directory, task.id)
+ removed++
+ log.debug("cleaned up old task", { taskId: task.id })
+ }
+ }
+
+ if (removed > 0) {
+ log.info("cleaned up old tasks", { count: removed })
+ }
+
+ return removed
+ }
+
+ /**
+ * Remove a specific task (must be completed/failed)
+ */
+ export async function remove(taskId: string): Promise {
+ await init()
+ const task = state().tasks.get(taskId)
+
+ if (!task) {
+ return false
+ }
+
+ if (task.status === "running") {
+ log.warn("cannot remove running task", { taskId })
+ return false
+ }
+
+ state().tasks.delete(taskId)
+ await TaskPersistence.remove(Instance.directory, taskId)
+ log.debug("removed task", { taskId })
+ return true
+ }
+
+ /**
+ * Options for adopting an existing process as a background task
+ */
+ export interface AdoptTaskOptions {
+ process: ChildProcess
+ command: string
+ workdir?: string
+ description?: string
+ initialOutput?: string
+ startTime?: number
+ }
+
+ /**
+ * Adopt an existing running process as a background task
+ * This is used when migrating a foreground bash process to background
+ */
+ export async function adopt(opts: AdoptTaskOptions): Promise {
+ await init()
+
+ const taskId = generateTaskId()
+ const directory = Instance.directory
+ const workdir = opts.workdir || directory
+ const logFile = TaskPersistence.getLogFile(directory, taskId)
+ const startTime = opts.startTime ?? Date.now()
+
+ log.info("adopting process as background task", {
+ taskId,
+ pid: opts.process.pid,
+ command: opts.command,
+ })
+
+ const task: ManagedTask = {
+ id: taskId,
+ pid: opts.process.pid!,
+ command: opts.command,
+ startTime,
+ status: "running",
+ workdir,
+ description: opts.description,
+ logFile,
+ reconnectable: true,
+ process: opts.process,
+ output: opts.initialOutput ? [opts.initialOutput] : [],
+ }
+
+ state().tasks.set(taskId, task)
+
+ // Fire-and-forget: Write initial output to log file (non-blocking for faster migration)
+ if (opts.initialOutput) {
+ const initialOutput = opts.initialOutput
+ void (async () => {
+ try {
+ await TaskPersistence.appendLog(directory, taskId, initialOutput)
+ } catch (err) {
+ log.error("failed to write initial output", { taskId, error: String(err) })
+ }
+ })()
+ }
+
+ // Fire-and-forget: Save initial state (non-blocking for faster migration)
+ void (async () => {
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (err) {
+ log.error("failed to save initial task state", { taskId, error: String(err) })
+ }
+ })()
+
+ // Handle stdout
+ opts.process.stdout?.on("data", (chunk: Buffer) => {
+ const data = chunk.toString()
+ task.output.push(data)
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.appendLog(directory, taskId, data)
+ } catch (err) {
+ log.error("failed to append stdout to log", { taskId, error: String(err) })
+ }
+ try {
+ await Bus.publish(TaskEvent.Output, { id: taskId, data, isError: false })
+ } catch (err) {
+ log.error("failed to publish stdout event", { taskId, error: String(err) })
+ }
+ },
+ })
+ })
+
+ // Handle stderr
+ opts.process.stderr?.on("data", (chunk: Buffer) => {
+ const data = chunk.toString()
+ task.output.push(data)
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.appendLog(directory, taskId, data)
+ } catch (err) {
+ log.error("failed to append stderr to log", { taskId, error: String(err) })
+ }
+ try {
+ await Bus.publish(TaskEvent.Output, { id: taskId, data, isError: true })
+ } catch (err) {
+ log.error("failed to publish stderr event", { taskId, error: String(err) })
+ }
+ },
+ })
+ })
+
+ // Handle exit
+ opts.process.on("exit", (code) => {
+ task.exitCode = code ?? undefined
+ task.status = code === 0 ? "completed" : "failed"
+ task.process = undefined
+
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (err) {
+ log.error("failed to save task state on exit", { taskId, error: String(err) })
+ }
+ try {
+ await Bus.publish(TaskEvent.Completed, { id: taskId, exitCode: code, status: task.status })
+ } catch (err) {
+ log.error("failed to publish completed event", { taskId, error: String(err) })
+ }
+ log.info("adopted task completed", { taskId, exitCode: code, status: task.status })
+ },
+ })
+ })
+
+ // Handle errors
+ opts.process.once("error", (err) => {
+ log.error("adopted task error", { taskId, error: String(err) })
+ task.status = "failed"
+ task.process = undefined
+
+ void Instance.provide({
+ directory,
+ fn: async () => {
+ try {
+ await TaskPersistence.save(directory, task)
+ } catch (e) {
+ log.error("failed to save task state after error", { taskId, error: String(e) })
+ }
+ try {
+ await Bus.publish(TaskEvent.Completed, { id: taskId, exitCode: null, status: "failed" })
+ } catch (e) {
+ log.error("failed to publish completed event after error", { taskId, error: String(e) })
+ }
+ },
+ })
+ })
+
+ // Publish created event
+ try {
+ await Bus.publish(TaskEvent.Created, {
+ info: {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ },
+ })
+ } catch (err) {
+ log.error("failed to publish created event", { taskId, error: String(err) })
+ }
+
+ return {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ }
+ }
+}
diff --git a/packages/opencode/src/task/persistence.ts b/packages/opencode/src/task/persistence.ts
new file mode 100644
index 00000000000..7adc9707a39
--- /dev/null
+++ b/packages/opencode/src/task/persistence.ts
@@ -0,0 +1,234 @@
+import fs from "fs/promises"
+import path from "path"
+import { Log } from "../util/log"
+import { Filesystem } from "../util/filesystem"
+import type { TaskInfo, TaskStatus } from "./events"
+
+const log = Log.create({ service: "task-persistence" })
+
+/**
+ * Persistence schema for task state
+ */
+export interface PersistedTask {
+ id: string
+ pid: number
+ command: string
+ startTime: number
+ status: TaskStatus
+ exitCode?: number
+ workdir: string
+ description?: string
+ logFile: string
+ reconnectable: boolean
+}
+
+export namespace TaskPersistence {
+ /**
+ * Get the tasks directory for a project
+ */
+ export function getTasksDir(projectDir: string): string {
+ return path.join(projectDir, ".opencode", "tasks")
+ }
+
+ /**
+ * Get the state file path for a task
+ */
+ export function getStateFile(projectDir: string, taskId: string): string {
+ return path.join(getTasksDir(projectDir), `${taskId}.json`)
+ }
+
+ /**
+ * Get the log file path for a task
+ */
+ export function getLogFile(projectDir: string, taskId: string): string {
+ return path.join(getTasksDir(projectDir), `${taskId}.log`)
+ }
+
+ /**
+ * Ensure the tasks directory exists
+ */
+ export async function ensureDir(projectDir: string): Promise {
+ const dir = getTasksDir(projectDir)
+ await fs.mkdir(dir, { recursive: true })
+ }
+
+ /**
+ * Save task state to disk (atomic write via temp file + rename)
+ */
+ export async function save(projectDir: string, task: TaskInfo): Promise {
+ await ensureDir(projectDir)
+ const stateFile = getStateFile(projectDir, task.id)
+ const tempFile = stateFile + ".tmp"
+
+ const persisted: PersistedTask = {
+ id: task.id,
+ pid: task.pid,
+ command: task.command,
+ startTime: task.startTime,
+ status: task.status,
+ exitCode: task.exitCode,
+ workdir: task.workdir,
+ description: task.description,
+ logFile: task.logFile,
+ reconnectable: task.reconnectable,
+ }
+
+ try {
+ await fs.writeFile(tempFile, JSON.stringify(persisted, null, 2))
+ await fs.rename(tempFile, stateFile)
+ log.debug("saved task state", { taskId: task.id, stateFile })
+ } catch (err) {
+ log.error("failed to save task state", { taskId: task.id, error: err })
+ // Clean up temp file if rename failed
+ await fs.unlink(tempFile).catch(() => {})
+ throw err
+ }
+ }
+
+ /**
+ * Load a single task state from disk
+ */
+ export async function load(projectDir: string, taskId: string): Promise {
+ const stateFile = getStateFile(projectDir, taskId)
+ try {
+ const content = await fs.readFile(stateFile, "utf-8")
+ return JSON.parse(content) as PersistedTask
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+ log.error("failed to load task state", { taskId, error: err })
+ }
+ return undefined
+ }
+ }
+
+ /**
+ * Load all persisted tasks for a project
+ */
+ export async function loadAll(projectDir: string): Promise {
+ const seen = new Map()
+
+ // Helper to load tasks from a specific .opencode/tasks directory
+ const loadFromDir = async (tasksDir: string, baseDir: string) => {
+ try {
+ const exists = await Filesystem.exists(tasksDir)
+ if (!exists) return
+ const files = await fs.readdir(tasksDir)
+ for (const file of files) {
+ if (!file.endsWith(".json")) continue
+ const taskId = file.replace(".json", "")
+ try {
+ const content = await fs.readFile(path.join(tasksDir, file), "utf-8")
+ const parsed = JSON.parse(content) as PersistedTask
+ if (!seen.has(parsed.id)) seen.set(parsed.id, parsed)
+ } catch (err) {
+ log.debug("failed to load task file", { tasksDir, file, error: String(err) })
+ }
+ }
+ } catch (err) {
+ // ignore
+ }
+ }
+
+ // 1) Load from the exact project directory
+ const dir = getTasksDir(projectDir)
+ await loadFromDir(dir, projectDir)
+
+ // 2) Also search for nested .opencode/tasks directories under projectDir (limited depth)
+ const maxDepth = 4
+ async function searchDir(current: string, depth: number) {
+ if (depth > maxDepth) return
+ let entries: string[]
+ try {
+ entries = await fs.readdir(current, { withFileTypes: true }).then((ents) => ents.map((e) => e.name))
+ } catch {
+ return
+ }
+ for (const name of entries) {
+ const full = path.join(current, name)
+ if (name === ".opencode") {
+ const tasksDir = path.join(full, "tasks")
+ await loadFromDir(tasksDir, current)
+ continue
+ }
+ // skip node_modules and .git for performance
+ if (name === "node_modules" || name === ".git") continue
+ try {
+ const stat = await fs.stat(full)
+ if (stat.isDirectory()) {
+ await searchDir(full, depth + 1)
+ }
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ await searchDir(projectDir, 0)
+
+ return Array.from(seen.values())
+ }
+
+ /**
+ * Delete task state and log files
+ */
+ export async function remove(projectDir: string, taskId: string): Promise {
+ const stateFile = getStateFile(projectDir, taskId)
+ const logFile = getLogFile(projectDir, taskId)
+
+ await Promise.all([
+ fs.unlink(stateFile).catch(() => {}),
+ fs.unlink(logFile).catch(() => {}),
+ ])
+ log.debug("removed task files", { taskId })
+ }
+
+ /**
+ * Append output to a task's log file
+ */
+ export async function appendLog(projectDir: string, taskId: string, data: string): Promise {
+ await ensureDir(projectDir)
+ const logFile = getLogFile(projectDir, taskId)
+ await fs.appendFile(logFile, data)
+ }
+
+ /**
+ * Read all output from a task's log file
+ */
+ export async function readLog(projectDir: string, taskId: string): Promise {
+ const logFile = getLogFile(projectDir, taskId)
+ try {
+ return await fs.readFile(logFile, "utf-8")
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+ log.error("failed to read task log", { taskId, error: err })
+ }
+ return ""
+ }
+ }
+
+ /**
+ * Read the last N lines from a task's log file
+ */
+ export async function tailLog(projectDir: string, taskId: string, lines: number): Promise {
+ const content = await readLog(projectDir, taskId)
+ if (!content) return ""
+
+ // Split and filter out empty strings from trailing newlines
+ const allLines = content.split("\n").filter((line) => line.length > 0)
+ const lastLines = allLines.slice(-lines)
+ return lastLines.join("\n")
+ }
+
+ /**
+ * Check if a process is still running by PID
+ */
+ export function isProcessRunning(pid: number): boolean {
+ try {
+ // Sending signal 0 doesn't actually send a signal, but checks if process exists
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index f3a1b04d431..2b5fea01b3b 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -16,12 +16,161 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
+import { TaskManager } from "@/task"
+import type { ChildProcess } from "child_process"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
export const log = Log.create({ service: "bash-tool" })
+/**
+ * Registry of foreground bash processes that can be migrated to background
+ */
+export interface ForegroundProcess {
+ callID: string
+ sessionID: string
+ pid: number
+ command: string
+ description: string
+ workdir: string
+ startTime: number
+ process: ChildProcess
+ output: string
+ /** Resolver to complete the tool execution after migration */
+ resolve: (result: { migrated: true; taskId: string }) => void
+ /** Flag to prevent normal completion after migration */
+ migrated: boolean
+}
+
+const foregroundProcesses = Instance.state(
+ () => new Map(),
+ async (processes) => {
+ // Cleanup: kill any remaining foreground processes on instance disposal
+ for (const proc of processes.values()) {
+ if (!proc.migrated && proc.process.exitCode === null) {
+ try {
+ proc.process.kill()
+ } catch {
+ // ignore
+ }
+ }
+ }
+ processes.clear()
+ },
+)
+
+/**
+ * Get a foreground process by callID
+ */
+export function getForegroundProcess(callID: string): ForegroundProcess | undefined {
+ return foregroundProcesses().get(callID)
+}
+
+/**
+ * List all active foreground processes
+ */
+export function listForegroundProcesses(): ForegroundProcess[] {
+ return Array.from(foregroundProcesses().values()).filter(
+ (p) => !p.migrated && p.process.exitCode === null,
+ )
+}
+
+/**
+ * Register a foreground process (for shell commands executed outside BashTool)
+ * @param proc - Process info
+ * @param resolve - Optional callback to invoke when process is migrated to background
+ */
+export function registerForegroundProcess(
+ proc: Omit,
+ resolve?: (result: { migrated: true; taskId: string }) => void,
+): void {
+ foregroundProcesses().set(proc.callID, {
+ ...proc,
+ resolve: resolve ?? (() => {}),
+ migrated: false,
+ })
+}
+
+/**
+ * Unregister a foreground process
+ */
+export function unregisterForegroundProcess(callID: string): void {
+ foregroundProcesses().delete(callID)
+}
+
+/**
+ * Migrate a foreground process to a background task
+ * Returns the new task ID if successful
+ */
+export async function migrateToBackground(callID: string): Promise {
+ const proc = foregroundProcesses().get(callID)
+ if (!proc) {
+ log.warn("cannot migrate unknown process", { callID })
+ return null
+ }
+
+ if (proc.migrated) {
+ log.warn("process already migrated", { callID })
+ return null
+ }
+
+ if (proc.process.exitCode !== null) {
+ log.warn("cannot migrate completed process", { callID, exitCode: proc.process.exitCode })
+ return null
+ }
+
+ log.info("migrating foreground process to background", {
+ callID,
+ pid: proc.pid,
+ command: proc.command,
+ })
+
+ // Adopt the process into TaskManager
+ const task = await TaskManager.adopt({
+ process: proc.process,
+ command: proc.command,
+ workdir: proc.workdir,
+ description: proc.description,
+ initialOutput: proc.output,
+ startTime: proc.startTime,
+ })
+
+ // Mark as migrated
+ proc.migrated = true
+
+ // Resolve the original tool execution with migration info
+ proc.resolve({ migrated: true, taskId: task.id })
+
+ // Remove from foreground registry
+ foregroundProcesses().delete(callID)
+
+ log.info("process migrated to background task", { callID, taskId: task.id })
+
+ return task.id
+}
+
+/**
+ * Kill a foreground process
+ */
+export async function killForegroundProcess(callID: string): Promise {
+ const proc = foregroundProcesses().get(callID)
+ if (!proc) {
+ return false
+ }
+
+ if (proc.process.exitCode !== null) {
+ return false
+ }
+
+ try {
+ await Shell.killTree(proc.process, { exited: () => proc.process.exitCode !== null })
+ return true
+ } catch {
+ return false
+ }
+}
+
const resolveWasm = (asset: string) => {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
@@ -160,29 +309,54 @@ export const BashTool = Tool.define("bash", async () => {
env: {
...process.env,
},
- stdio: ["ignore", "pipe", "pipe"],
+ stdio: ["pipe", "pipe", "pipe"], // Changed to pipe stdin for potential input
detached: process.platform !== "win32",
})
let output = ""
+ const startTime = Date.now()
+ const callID = ctx.callID ?? `bash_${Date.now()}_${Math.random().toString(36).slice(2)}`
+
+ // Metadata object for UI controls
+ const metadataObj = {
+ output: "",
+ description: params.description,
+ pid: proc.pid,
+ startTime,
+ callID,
+ running: true,
+ }
- // Initialize metadata with empty output
- ctx.metadata({
- metadata: {
- output: "",
- description: params.description,
- },
- })
+ // Initialize metadata with process info for UI controls
+ // Retry a few times since status might still be "pending" initially
+ log.info("bash: initializing foreground process metadata", { callID, pid: proc.pid, startTime })
+ let metadataRetries = 0
+ const trySetMetadata = async () => {
+ await ctx.metadata({ metadata: metadataObj })
+ metadataRetries++
+ }
+ await trySetMetadata()
+ // Retry a few more times in case status was "pending"
+ const metadataRetryInterval = setInterval(async () => {
+ if (metadataRetries < 5) {
+ await trySetMetadata()
+ } else {
+ clearInterval(metadataRetryInterval)
+ }
+ }, 50)
const append = (chunk: Buffer) => {
output += chunk.toString()
- ctx.metadata({
- metadata: {
- // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
- output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
- description: params.description,
- },
- })
+ // Stop retry interval once we have output (metadata should be set by now)
+ clearInterval(metadataRetryInterval)
+ // Update foreground process output if registered
+ const fg = foregroundProcesses().get(callID)
+ if (fg) {
+ fg.output = output
+ }
+ // Update metadata object and send update
+ metadataObj.output = output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output
+ ctx.metadata({ metadata: metadataObj })
}
proc.stdout?.on("data", append)
@@ -191,6 +365,8 @@ export const BashTool = Tool.define("bash", async () => {
let timedOut = false
let aborted = false
let exited = false
+ let migrated = false
+ let migratedTaskId: string | null = null
const kill = () => Shell.killTree(proc, { exited: () => exited })
@@ -211,12 +387,35 @@ export const BashTool = Tool.define("bash", async () => {
void kill()
}, timeout + 100)
- await new Promise((resolve, reject) => {
+ // Register this process so it can be migrated to background
+ const migrationResult = await new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timeoutTimer)
+ clearInterval(metadataRetryInterval)
ctx.abort.removeEventListener("abort", abortHandler)
+ foregroundProcesses().delete(callID)
}
+ // Register the foreground process for potential migration
+ foregroundProcesses().set(callID, {
+ callID,
+ sessionID: ctx.sessionID,
+ pid: proc.pid!,
+ command: params.command,
+ description: params.description,
+ workdir: cwd,
+ startTime,
+ process: proc,
+ output,
+ resolve: (result) => {
+ migrated = true
+ migratedTaskId = result.taskId
+ cleanup()
+ resolve(result)
+ },
+ migrated: false,
+ })
+
proc.once("exit", () => {
exited = true
cleanup()
@@ -230,6 +429,27 @@ export const BashTool = Tool.define("bash", async () => {
})
})
+ // Handle migration case
+ if (migrated && migratedTaskId) {
+ const migratedOutput = `Process migrated to background task.\nTask ID: ${migratedTaskId}\nOriginal output (${output.length} bytes) preserved.\n\nUse process_query tool to read output later.`
+
+ return {
+ title: params.description,
+ metadata: {
+ output: migratedOutput,
+ description: params.description,
+ taskId: migratedTaskId,
+ migrated: true,
+ running: false,
+ exit: null as number | null,
+ pid: undefined as number | undefined,
+ callID: undefined as string | undefined,
+ startTime: undefined as number | undefined,
+ },
+ output: migratedOutput,
+ }
+ }
+
const resultMetadata: string[] = []
if (timedOut) {
@@ -250,6 +470,12 @@ export const BashTool = Tool.define("bash", async () => {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: proc.exitCode,
description: params.description,
+ running: false,
+ taskId: undefined as string | undefined,
+ migrated: false,
+ pid: undefined as number | undefined,
+ callID: undefined as string | undefined,
+ startTime: undefined as number | undefined,
},
output,
}
diff --git a/packages/opencode/src/tool/process-query.ts b/packages/opencode/src/tool/process-query.ts
new file mode 100644
index 00000000000..aef6008a246
--- /dev/null
+++ b/packages/opencode/src/tool/process-query.ts
@@ -0,0 +1,245 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { TaskManager, type TaskInfo } from "../task"
+import { Log } from "../util/log"
+
+const log = Log.create({ service: "process-query-tool" })
+
+export const ProcessQueryTool = Tool.define("process_query", {
+ description: `Query and interact with background processes.
+
+Use this tool to:
+- Read output from background tasks
+- Check the status of running processes
+- Search for patterns in process logs
+- Get information about specific tasks
+
+Available actions:
+- "status": Get the current status of a task
+- "read_output": Read the full output of a task
+- "read_last_n": Read the last N lines of output
+- "search": Search for a pattern in the task output
+- "list": List all background tasks
+
+The identifier can be:
+- A task ID (e.g., "task_abc123")
+- "last" to refer to the most recently created task
+- A partial match of the task description`,
+ parameters: z.object({
+ action: z
+ .enum(["status", "read_output", "read_last_n", "search", "list"])
+ .describe("The action to perform on the background process"),
+ identifier: z
+ .string()
+ .describe(
+ 'Task identifier: task ID, "last" for most recent, or partial description match. Not required for "list" action.',
+ )
+ .optional(),
+ lines: z
+ .number()
+ .describe("Number of lines to read (for read_last_n action). Defaults to 50.")
+ .optional(),
+ pattern: z.string().describe("Search pattern (for search action)").optional(),
+ }),
+ async execute(params, _ctx) {
+ log.info("process query", { action: params.action, identifier: params.identifier })
+
+ // Handle list action separately
+ if (params.action === "list") {
+ const tasks = await TaskManager.list()
+ if (tasks.length === 0) {
+ return {
+ title: "List background tasks",
+ metadata: { taskCount: 0 } as Record,
+ output: "No background tasks found.",
+ }
+ }
+
+ const taskList = tasks
+ .map((t) => {
+ const duration = t.status === "running" ? `running for ${formatDuration(Date.now() - t.startTime)}` : t.status
+ return `- ${t.id}: ${t.description || t.command.substring(0, 50)} [${duration}]${t.exitCode !== undefined ? ` (exit: ${t.exitCode})` : ""}`
+ })
+ .join("\n")
+
+ return {
+ title: "List background tasks",
+ metadata: { taskCount: tasks.length } as Record,
+ output: `Found ${tasks.length} background task(s):\n\n${taskList}`,
+ }
+ }
+
+ // For other actions, we need an identifier
+ if (!params.identifier) {
+ return {
+ title: "Process query error",
+ metadata: { error: "missing_identifier" } as Record,
+ output: 'Error: identifier is required for this action. Use "last" for the most recent task, or provide a task ID.',
+ }
+ }
+
+ // Resolve the task
+ const task = await resolveTask(params.identifier)
+ if (!task) {
+ return {
+ title: "Process query error",
+ metadata: { error: "task_not_found" },
+ output: `Error: Could not find task matching "${params.identifier}". Use process_query with action "list" to see available tasks.`,
+ }
+ }
+
+ switch (params.action) {
+ case "status": {
+ const duration = formatDuration(Date.now() - task.startTime)
+ const statusInfo = [
+ `Task ID: ${task.id}`,
+ `Command: ${task.command}`,
+ `Status: ${task.status}`,
+ `PID: ${task.pid}`,
+ `Started: ${new Date(task.startTime).toISOString()}`,
+ `Duration: ${duration}`,
+ task.description ? `Description: ${task.description}` : null,
+ task.exitCode !== undefined ? `Exit Code: ${task.exitCode}` : null,
+ `Log File: ${task.logFile}`,
+ ]
+ .filter(Boolean)
+ .join("\n")
+
+ return {
+ title: `Status: ${task.description || task.id}`,
+ metadata: {
+ taskId: task.id,
+ status: task.status,
+ exitCode: task.exitCode,
+ },
+ output: statusInfo,
+ }
+ }
+
+ case "read_output": {
+ const output = await TaskManager.read(task.id)
+ if (!output) {
+ return {
+ title: `Output: ${task.description || task.id}`,
+ metadata: { taskId: task.id, outputLength: 0 },
+ output: "(No output yet)",
+ }
+ }
+
+ return {
+ title: `Output: ${task.description || task.id}`,
+ metadata: { taskId: task.id, outputLength: output.length },
+ output: output,
+ }
+ }
+
+ case "read_last_n": {
+ const lines = params.lines ?? 50
+ const output = await TaskManager.tail(task.id, lines)
+ if (!output) {
+ return {
+ title: `Last ${lines} lines: ${task.description || task.id}`,
+ metadata: { taskId: task.id, lines: 0 },
+ output: "(No output yet)",
+ }
+ }
+
+ return {
+ title: `Last ${lines} lines: ${task.description || task.id}`,
+ metadata: { taskId: task.id, lines },
+ output: output,
+ }
+ }
+
+ case "search": {
+ if (!params.pattern) {
+ return {
+ title: "Process query error",
+ metadata: { error: "missing_pattern" },
+ output: 'Error: pattern is required for search action. Example: process_query(action="search", identifier="last", pattern="error")',
+ }
+ }
+
+ const output = await TaskManager.read(task.id)
+ if (!output) {
+ return {
+ title: `Search in: ${task.description || task.id}`,
+ metadata: { taskId: task.id, matches: 0 },
+ output: "(No output to search)",
+ }
+ }
+
+ const lines = output.split("\n")
+ const matches = lines
+ .map((line, idx) => ({ line, idx: idx + 1 }))
+ .filter(({ line }) => line.toLowerCase().includes(params.pattern!.toLowerCase()))
+
+ if (matches.length === 0) {
+ return {
+ title: `Search in: ${task.description || task.id}`,
+ metadata: { taskId: task.id, pattern: params.pattern, matches: 0 },
+ output: `No matches found for "${params.pattern}"`,
+ }
+ }
+
+ const matchOutput = matches.map(({ line, idx }) => `${idx}: ${line}`).join("\n")
+
+ return {
+ title: `Search in: ${task.description || task.id}`,
+ metadata: { taskId: task.id, pattern: params.pattern, matches: matches.length },
+ output: `Found ${matches.length} match(es) for "${params.pattern}":\n\n${matchOutput}`,
+ }
+ }
+
+ default:
+ return {
+ title: "Process query error",
+ metadata: { error: "unknown_action" },
+ output: `Unknown action: ${params.action}`,
+ }
+ }
+ },
+})
+
+/**
+ * Resolve a task identifier to a TaskInfo object
+ */
+async function resolveTask(identifier: string): Promise {
+ const tasks = await TaskManager.list()
+
+ // Direct ID match
+ const directMatch = tasks.find((t) => t.id === identifier)
+ if (directMatch) return directMatch
+
+ // "last" keyword - most recent task
+ if (identifier.toLowerCase() === "last") {
+ if (tasks.length === 0) return undefined
+ return tasks.reduce((latest, t) => (t.startTime > latest.startTime ? t : latest), tasks[0])
+ }
+
+ // Partial ID match
+ const partialIdMatch = tasks.find((t) => t.id.includes(identifier))
+ if (partialIdMatch) return partialIdMatch
+
+ // Description match (case-insensitive)
+ const descMatch = tasks.find((t) => t.description?.toLowerCase().includes(identifier.toLowerCase()))
+ if (descMatch) return descMatch
+
+ // Command match (case-insensitive)
+ const cmdMatch = tasks.find((t) => t.command.toLowerCase().includes(identifier.toLowerCase()))
+ if (cmdMatch) return cmdMatch
+
+ return undefined
+}
+
+/**
+ * Format a duration in milliseconds to a human-readable string
+ */
+function formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+}
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index dad9914a289..f0f9d54b964 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -26,6 +26,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
+import { ProcessQueryTool } from "./process-query"
import { ApplyPatchTool } from "./apply_patch"
export namespace ToolRegistry {
@@ -103,6 +104,7 @@ export namespace ToolRegistry {
EditTool,
WriteTool,
TaskTool,
+ ProcessQueryTool,
WebFetchTool,
TodoWriteTool,
TodoReadTool,
diff --git a/packages/opencode/test/task/task.test.ts b/packages/opencode/test/task/task.test.ts
new file mode 100644
index 00000000000..5700e6694b9
--- /dev/null
+++ b/packages/opencode/test/task/task.test.ts
@@ -0,0 +1,668 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { TaskManager, TaskEvent, type TaskInfo } from "../../src/task"
+import { TaskPersistence } from "../../src/task/persistence"
+import { Bus } from "../../src/bus"
+import { Instance } from "../../src/project/instance"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+// Helper to wait for task completion with timeout
+async function waitForTaskCompletion(taskId: string, timeoutMs: number = 10000): Promise {
+ const startTime = Date.now()
+ while (Date.now() - startTime < timeoutMs) {
+ const task = await TaskManager.get(taskId)
+ if (task && task.status !== "running") {
+ return task
+ }
+ await Bun.sleep(100)
+ }
+ return TaskManager.get(taskId)
+}
+
+describe("TaskManager", () => {
+ describe("create()", () => {
+ test(
+ "creates task, spawns process, returns task info",
+ async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "echo hello",
+ description: "Test echo",
+ })
+
+ expect(task.id).toMatch(/^task_/)
+ expect(task.pid).toBeGreaterThan(0)
+ expect(task.command).toBe("echo hello")
+ expect(task.status).toBe("running")
+ expect(task.workdir).toBe(tmp.path)
+ expect(task.description).toBe("Test echo")
+ expect(task.logFile).toContain(".opencode/tasks/")
+ expect(task.reconnectable).toBe(true)
+
+ // Wait for completion
+ const completed = await waitForTaskCompletion(task.id)
+ expect(completed?.status).toBe("completed")
+ expect(completed?.exitCode).toBe(0)
+ },
+ })
+ },
+ { timeout: 30000 },
+ )
+
+ test("creates task with custom workdir", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const subdir = path.join(tmp.path, "subdir")
+ await fs.mkdir(subdir, { recursive: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "pwd",
+ workdir: subdir,
+ description: "Test pwd in subdir",
+ })
+
+ expect(task.workdir).toBe(subdir)
+
+ await waitForTaskCompletion(task.id)
+ const output = await TaskManager.read(task.id)
+ expect(output.trim()).toBe(subdir)
+ },
+ })
+ })
+
+ test("handles command failure with non-zero exit code", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "exit 42",
+ description: "Test failure",
+ })
+
+ const completed = await waitForTaskCompletion(task.id)
+ expect(completed?.status).toBe("failed")
+ expect(completed?.exitCode).toBe(42)
+ },
+ })
+ })
+ })
+
+ describe("list()", () => {
+ test("returns all tasks", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task1 = await TaskManager.create({
+ command: "sleep 0.1",
+ description: "Task 1",
+ })
+ const task2 = await TaskManager.create({
+ command: "sleep 0.1",
+ description: "Task 2",
+ })
+
+ const tasks = await TaskManager.list()
+ expect(tasks.length).toBeGreaterThanOrEqual(2)
+ expect(tasks.some((t) => t.id === task1.id)).toBe(true)
+ expect(tasks.some((t) => t.id === task2.id)).toBe(true)
+
+ // Wait for completion
+ await waitForTaskCompletion(task1.id)
+ await waitForTaskCompletion(task2.id)
+ },
+ })
+ })
+
+ test("returns empty array when no tasks", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Clear any existing tasks
+ const existingTasks = await TaskManager.list()
+ for (const task of existingTasks) {
+ if (task.status !== "running") {
+ await TaskManager.remove(task.id)
+ }
+ }
+
+ // Check remaining (may have running tasks from other tests)
+ const tasks = await TaskManager.list()
+ expect(Array.isArray(tasks)).toBe(true)
+ },
+ })
+ })
+ })
+
+ describe("get()", () => {
+ test("returns specific task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const created = await TaskManager.create({
+ command: "echo test",
+ description: "Get test",
+ })
+
+ const task = await TaskManager.get(created.id)
+ expect(task).toBeDefined()
+ expect(task?.id).toBe(created.id)
+ expect(task?.command).toBe("echo test")
+
+ await waitForTaskCompletion(created.id)
+ },
+ })
+ })
+
+ test("returns undefined for unknown task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.get("task_nonexistent_12345")
+ expect(task).toBeUndefined()
+ },
+ })
+ })
+ })
+
+ describe("kill()", () => {
+ test("terminates running task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "sleep 60",
+ description: "Long running task",
+ })
+
+ expect(task.status).toBe("running")
+
+ // Kill the task
+ const killed = await TaskManager.kill(task.id)
+ expect(killed).toBe(true)
+
+ // Wait a bit for cleanup
+ await Bun.sleep(300)
+
+ const after = await TaskManager.get(task.id)
+ expect(after?.status).toBe("failed")
+ },
+ })
+ })
+
+ test("returns false for unknown task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const killed = await TaskManager.kill("task_nonexistent_12345")
+ expect(killed).toBe(false)
+ },
+ })
+ })
+
+ test("returns false for already completed task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "echo done",
+ description: "Quick task",
+ })
+
+ await waitForTaskCompletion(task.id)
+
+ const killed = await TaskManager.kill(task.id)
+ expect(killed).toBe(false)
+ },
+ })
+ })
+ })
+
+ describe("read()", () => {
+ test("returns full output", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "echo line1 && echo line2 && echo line3",
+ description: "Multi-line output",
+ })
+
+ await waitForTaskCompletion(task.id)
+ const output = await TaskManager.read(task.id)
+
+ expect(output).toContain("line1")
+ expect(output).toContain("line2")
+ expect(output).toContain("line3")
+ },
+ })
+ })
+
+ test("returns empty string for task without output", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const output = await TaskManager.read("task_nonexistent_12345")
+ expect(output).toBe("")
+ },
+ })
+ })
+ })
+
+ describe("tail()", () => {
+ test("returns last N lines", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "seq 1 20",
+ description: "Generate numbers",
+ })
+
+ await waitForTaskCompletion(task.id)
+ const output = await TaskManager.tail(task.id, 5)
+
+ const lines = output
+ .trim()
+ .split("\n")
+ .filter((l) => l)
+ expect(lines.length).toBeLessThanOrEqual(5)
+ expect(lines[lines.length - 1]).toBe("20")
+ },
+ })
+ })
+ })
+
+ describe("input()", () => {
+ test("sends stdin to process", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Use a simple command that reads stdin and echoes it
+ const task = await TaskManager.create({
+ command: "read line && echo \"received: $line\"",
+ description: "Read stdin",
+ })
+
+ // Give process time to start
+ await Bun.sleep(200)
+
+ // Send input
+ const sent = await TaskManager.input(task.id, "hello\n")
+ expect(sent).toBe(true)
+
+ // Wait for completion (the read command should exit after receiving input)
+ await waitForTaskCompletion(task.id)
+
+ const output = await TaskManager.read(task.id)
+ expect(output).toContain("received: hello")
+ },
+ })
+ })
+
+ test("returns false for task without process handle", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const sent = await TaskManager.input("task_nonexistent_12345", "test")
+ expect(sent).toBe(false)
+ },
+ })
+ })
+ })
+})
+
+describe("TaskPersistence", () => {
+ describe("save() and load()", () => {
+ test("tasks survive across restarts", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ // Create a task and save its state
+ const taskInfo: TaskInfo = {
+ id: "task_test_persist",
+ pid: 12345,
+ command: "echo test",
+ startTime: Date.now(),
+ status: "completed",
+ exitCode: 0,
+ workdir: tmp.path,
+ description: "Persistence test",
+ logFile: TaskPersistence.getLogFile(tmp.path, "task_test_persist"),
+ reconnectable: true,
+ }
+
+ await TaskPersistence.save(tmp.path, taskInfo)
+
+ // Load it back
+ const loaded = await TaskPersistence.load(tmp.path, "task_test_persist")
+ expect(loaded).toBeDefined()
+ expect(loaded?.id).toBe("task_test_persist")
+ expect(loaded?.command).toBe("echo test")
+ expect(loaded?.status).toBe("completed")
+ expect(loaded?.exitCode).toBe(0)
+ })
+
+ test("loadAll returns all persisted tasks", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const task1: TaskInfo = {
+ id: "task_test_1",
+ pid: 1001,
+ command: "echo 1",
+ startTime: Date.now(),
+ status: "completed",
+ exitCode: 0,
+ workdir: tmp.path,
+ logFile: TaskPersistence.getLogFile(tmp.path, "task_test_1"),
+ reconnectable: true,
+ }
+
+ const task2: TaskInfo = {
+ id: "task_test_2",
+ pid: 1002,
+ command: "echo 2",
+ startTime: Date.now(),
+ status: "failed",
+ exitCode: 1,
+ workdir: tmp.path,
+ logFile: TaskPersistence.getLogFile(tmp.path, "task_test_2"),
+ reconnectable: true,
+ }
+
+ await TaskPersistence.save(tmp.path, task1)
+ await TaskPersistence.save(tmp.path, task2)
+
+ const all = await TaskPersistence.loadAll(tmp.path)
+ expect(all.length).toBe(2)
+ expect(all.some((t) => t.id === "task_test_1")).toBe(true)
+ expect(all.some((t) => t.id === "task_test_2")).toBe(true)
+ })
+ })
+
+ describe("log files", () => {
+ test("appendLog and readLog work correctly", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await TaskPersistence.appendLog(tmp.path, "task_log_test", "line 1\n")
+ await TaskPersistence.appendLog(tmp.path, "task_log_test", "line 2\n")
+ await TaskPersistence.appendLog(tmp.path, "task_log_test", "line 3\n")
+
+ const content = await TaskPersistence.readLog(tmp.path, "task_log_test")
+ expect(content).toBe("line 1\nline 2\nline 3\n")
+ })
+
+ test("tailLog returns last N lines", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await TaskPersistence.appendLog(tmp.path, "task_tail_test", "line 1\nline 2\nline 3\nline 4\nline 5\n")
+
+ const tail = await TaskPersistence.tailLog(tmp.path, "task_tail_test", 2)
+ const lines = tail.split("\n").filter((l) => l)
+ expect(lines).toEqual(["line 4", "line 5"])
+ })
+ })
+
+ describe("remove()", () => {
+ test("deletes state and log files", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const taskInfo: TaskInfo = {
+ id: "task_remove_test",
+ pid: 9999,
+ command: "echo remove",
+ startTime: Date.now(),
+ status: "completed",
+ exitCode: 0,
+ workdir: tmp.path,
+ logFile: TaskPersistence.getLogFile(tmp.path, "task_remove_test"),
+ reconnectable: true,
+ }
+
+ await TaskPersistence.save(tmp.path, taskInfo)
+ await TaskPersistence.appendLog(tmp.path, "task_remove_test", "some log\n")
+
+ // Verify files exist
+ const stateFile = TaskPersistence.getStateFile(tmp.path, "task_remove_test")
+ const logFile = TaskPersistence.getLogFile(tmp.path, "task_remove_test")
+ expect(await Bun.file(stateFile).exists()).toBe(true)
+ expect(await Bun.file(logFile).exists()).toBe(true)
+
+ // Remove
+ await TaskPersistence.remove(tmp.path, "task_remove_test")
+
+ // Verify files are gone
+ expect(await Bun.file(stateFile).exists()).toBe(false)
+ expect(await Bun.file(logFile).exists()).toBe(false)
+ })
+ })
+
+ describe("isProcessRunning()", () => {
+ test("returns true for running process", () => {
+ // Current process should be running
+ const running = TaskPersistence.isProcessRunning(process.pid)
+ expect(running).toBe(true)
+ })
+
+ test("returns false for non-existent process", () => {
+ // Use a very high PID that likely doesn't exist
+ const running = TaskPersistence.isProcessRunning(999999999)
+ expect(running).toBe(false)
+ })
+ })
+})
+
+describe("TaskEvent", () => {
+ test("emits task.created event on task creation", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let eventReceived = false
+ let receivedTaskId: string | undefined
+
+ const unsub = Bus.subscribe(TaskEvent.Created, (event) => {
+ eventReceived = true
+ receivedTaskId = event.properties.info.id
+ })
+
+ const task = await TaskManager.create({
+ command: "echo event_test",
+ description: "Event test",
+ })
+
+ // Wait for event to propagate
+ await Bun.sleep(100)
+
+ unsub()
+
+ expect(eventReceived).toBe(true)
+ expect(receivedTaskId).toBe(task.id)
+
+ await waitForTaskCompletion(task.id)
+ },
+ })
+ })
+
+ test("emits task.output event on output", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const outputs: string[] = []
+
+ const unsub = Bus.subscribe(TaskEvent.Output, (event) => {
+ outputs.push(event.properties.data)
+ })
+
+ const task = await TaskManager.create({
+ command: "echo output_test",
+ description: "Output event test",
+ })
+
+ await waitForTaskCompletion(task.id)
+
+ // Wait for events to propagate
+ await Bun.sleep(100)
+
+ unsub()
+
+ expect(outputs.some((o) => o.includes("output_test"))).toBe(true)
+ },
+ })
+ })
+
+ test("emits task.completed event on completion", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let completedTaskId: string | undefined
+ let completedStatus: string | undefined
+
+ const unsub = Bus.subscribe(TaskEvent.Completed, (event) => {
+ completedTaskId = event.properties.id
+ completedStatus = event.properties.status
+ })
+
+ const task = await TaskManager.create({
+ command: "echo completed_test",
+ description: "Completed event test",
+ })
+
+ await waitForTaskCompletion(task.id)
+
+ // Wait for event to propagate
+ await Bun.sleep(100)
+
+ unsub()
+
+ expect(completedTaskId).toBe(task.id)
+ expect(completedStatus).toBe("completed")
+ },
+ })
+ })
+
+ test("emits task.killed event on kill", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let killedTaskId: string | undefined
+
+ const unsub = Bus.subscribe(TaskEvent.Killed, (event) => {
+ killedTaskId = event.properties.id
+ })
+
+ const task = await TaskManager.create({
+ command: "sleep 60",
+ description: "Kill event test",
+ })
+
+ await Bun.sleep(100) // Let process start
+
+ await TaskManager.kill(task.id)
+
+ // Wait for event to propagate
+ await Bun.sleep(100)
+
+ unsub()
+
+ expect(killedTaskId).toBe(task.id)
+ },
+ })
+ })
+})
+
+describe("TaskManager.cleanup()", () => {
+ test("removes completed tasks older than maxAge", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Create a task that completes quickly
+ const task = await TaskManager.create({
+ command: "echo cleanup_test",
+ description: "Cleanup test",
+ })
+
+ await waitForTaskCompletion(task.id)
+
+ // Get current tasks
+ const beforeCleanup = await TaskManager.list()
+ expect(beforeCleanup.some((t) => t.id === task.id)).toBe(true)
+
+ // Cleanup with maxAge of 0 (should remove immediately)
+ const removed = await TaskManager.cleanup(0)
+ expect(removed).toBeGreaterThanOrEqual(1)
+
+ // Verify task is removed
+ const afterCleanup = await TaskManager.list()
+ expect(afterCleanup.some((t) => t.id === task.id)).toBe(false)
+ },
+ })
+ })
+})
+
+describe("TaskManager.remove()", () => {
+ test("removes completed task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "echo remove_test",
+ description: "Remove test",
+ })
+
+ await waitForTaskCompletion(task.id)
+
+ const removed = await TaskManager.remove(task.id)
+ expect(removed).toBe(true)
+
+ const afterRemove = await TaskManager.get(task.id)
+ expect(afterRemove).toBeUndefined()
+ },
+ })
+ })
+
+ test("cannot remove running task", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const task = await TaskManager.create({
+ command: "sleep 60",
+ description: "Running task",
+ })
+
+ const removed = await TaskManager.remove(task.id)
+ expect(removed).toBe(false)
+
+ // Clean up
+ await TaskManager.kill(task.id)
+ await waitForTaskCompletion(task.id)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/bash-foreground-process.test.ts b/packages/opencode/test/tool/bash-foreground-process.test.ts
new file mode 100644
index 00000000000..464afe0fce0
--- /dev/null
+++ b/packages/opencode/test/tool/bash-foreground-process.test.ts
@@ -0,0 +1,634 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import path from "path"
+import { spawn, type ChildProcess } from "child_process"
+import {
+ registerForegroundProcess,
+ unregisterForegroundProcess,
+ getForegroundProcess,
+ listForegroundProcesses,
+ migrateToBackground,
+ killForegroundProcess,
+ type ForegroundProcess,
+} from "../../src/tool/bash"
+import { Instance } from "../../src/project/instance"
+import { TaskManager, type TaskInfo } from "../../src/task"
+import { tmpdir } from "../fixture/fixture"
+import { Log } from "../../src/util/log"
+import { Shell } from "../../src/shell/shell"
+
+Log.init({ print: false })
+
+// Helper to create a mock process for testing
+function createMockProcess(command: string = "sleep 5"): ChildProcess {
+ const shell = Shell.acceptable()
+ return spawn(command, {
+ shell,
+ stdio: ["pipe", "pipe", "pipe"],
+ detached: process.platform !== "win32",
+ })
+}
+
+// Helper to wait for task completion with timeout
+async function waitForTaskCompletion(taskId: string, timeoutMs: number = 10000): Promise {
+ const startTime = Date.now()
+ while (Date.now() - startTime < timeoutMs) {
+ const task = await TaskManager.get(taskId)
+ if (task && task.status !== "running") {
+ return task
+ }
+ await Bun.sleep(100)
+ }
+ return TaskManager.get(taskId)
+}
+
+describe("Foreground Process Registry", () => {
+ describe("registerForegroundProcess", () => {
+ test("registers a process and can be retrieved by callID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_${Date.now()}`
+
+ try {
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ const registered = getForegroundProcess(callID)
+ expect(registered).toBeDefined()
+ expect(registered?.callID).toBe(callID)
+ expect(registered?.pid).toBe(proc.pid!)
+ expect(registered?.command).toBe("sleep 5")
+ expect(registered?.sessionID).toBe("test-session")
+ expect(registered?.migrated).toBe(false)
+ } finally {
+ // Cleanup
+ proc.kill()
+ unregisterForegroundProcess(callID)
+ }
+ },
+ })
+ })
+
+ test("registers with default no-op resolve callback when none provided", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_noop_${Date.now()}`
+
+ try {
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ const registered = getForegroundProcess(callID)
+ expect(registered).toBeDefined()
+ expect(typeof registered?.resolve).toBe("function")
+
+ // Should not throw when called (no-op)
+ expect(() => registered?.resolve({ migrated: true, taskId: "test" })).not.toThrow()
+ } finally {
+ proc.kill()
+ unregisterForegroundProcess(callID)
+ }
+ },
+ })
+ })
+
+ test("registers with custom resolve callback", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_resolve_${Date.now()}`
+ let resolveCalled = false
+ let receivedTaskId: string | null = null
+
+ try {
+ registerForegroundProcess(
+ {
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ },
+ (result) => {
+ resolveCalled = true
+ receivedTaskId = result.taskId
+ },
+ )
+
+ const registered = getForegroundProcess(callID)
+ expect(registered).toBeDefined()
+
+ // Manually invoke resolve to test callback
+ registered?.resolve({ migrated: true, taskId: "task_123" })
+
+ expect(resolveCalled).toBe(true)
+ expect(receivedTaskId).not.toBeNull()
+ expect(String(receivedTaskId)).toBe("task_123")
+ } finally {
+ proc.kill()
+ unregisterForegroundProcess(callID)
+ }
+ },
+ })
+ })
+
+ test("appears in listForegroundProcesses when active", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_list_${Date.now()}`
+
+ try {
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ const processes = listForegroundProcesses()
+ expect(processes.some((p) => p.callID === callID)).toBe(true)
+ } finally {
+ proc.kill()
+ unregisterForegroundProcess(callID)
+ }
+ },
+ })
+ })
+ })
+
+ describe("unregisterForegroundProcess", () => {
+ test("removes process from registry", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_unregister_${Date.now()}`
+
+ try {
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Verify it was registered
+ expect(getForegroundProcess(callID)).toBeDefined()
+
+ // Unregister
+ unregisterForegroundProcess(callID)
+
+ // Verify it's gone
+ expect(getForegroundProcess(callID)).toBeUndefined()
+ } finally {
+ proc.kill()
+ }
+ },
+ })
+ })
+
+ test("no longer appears in listForegroundProcesses after unregistration", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess()
+ const callID = `test_call_list_unregister_${Date.now()}`
+
+ try {
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 5",
+ description: "Test process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Verify it's in the list
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(true)
+
+ // Unregister
+ unregisterForegroundProcess(callID)
+
+ // Verify it's no longer in the list
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(false)
+ } finally {
+ proc.kill()
+ }
+ },
+ })
+ })
+
+ test("handles unregistering non-existent process gracefully", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Should not throw
+ expect(() => unregisterForegroundProcess("non_existent_call_id")).not.toThrow()
+ },
+ })
+ })
+ })
+})
+
+describe("Migration Flow", () => {
+ describe("migrateToBackground", () => {
+ test(
+ "migrates running process to background task",
+ async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("sleep 10")
+ const callID = `test_migrate_${Date.now()}`
+ let resolveCalled = false
+ let receivedTaskId: string | null = null
+
+ registerForegroundProcess(
+ {
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 10",
+ description: "Migratable process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "initial output",
+ },
+ (result) => {
+ resolveCalled = true
+ receivedTaskId = result.taskId
+ },
+ )
+
+ // Migrate to background
+ const taskId = await migrateToBackground(callID)
+
+ expect(taskId).not.toBeNull()
+ expect(taskId).toMatch(/^task_/)
+
+ // Verify resolve callback was invoked with taskId
+ expect(resolveCalled).toBe(true)
+ expect(String(receivedTaskId)).toBe(String(taskId))
+
+ // Verify process is removed from foreground registry
+ expect(getForegroundProcess(callID)).toBeUndefined()
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(false)
+
+ // Verify task exists in TaskManager
+ const task = await TaskManager.get(taskId!)
+ expect(task).toBeDefined()
+ expect(task?.command).toBe("sleep 10")
+ expect(task?.description).toBe("Migratable process")
+ expect(task?.status).toBe("running")
+
+ // Clean up
+ await TaskManager.kill(taskId!)
+ await waitForTaskCompletion(taskId!)
+ },
+ })
+ },
+ { timeout: 30000 },
+ )
+
+ test("returns null for unknown callID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const taskId = await migrateToBackground("non_existent_call_id")
+ expect(taskId).toBeNull()
+ },
+ })
+ })
+
+ test("returns null for already migrated process", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("sleep 10")
+ const callID = `test_double_migrate_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 10",
+ description: "Double migrate test",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // First migration should succeed
+ const taskId1 = await migrateToBackground(callID)
+ expect(taskId1).not.toBeNull()
+
+ // Second migration should fail (process already removed from registry)
+ const taskId2 = await migrateToBackground(callID)
+ expect(taskId2).toBeNull()
+
+ // Clean up
+ await TaskManager.kill(taskId1!)
+ await waitForTaskCompletion(taskId1!)
+ },
+ })
+ })
+
+ test("returns null for completed process", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Create a process that exits immediately
+ const proc = createMockProcess("echo done")
+ const callID = `test_migrate_completed_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "echo done",
+ description: "Completed process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Wait for process to exit
+ await new Promise((resolve) => {
+ proc.on("exit", () => resolve())
+ })
+
+ // Migration should fail for completed process
+ const taskId = await migrateToBackground(callID)
+ expect(taskId).toBeNull()
+
+ // Clean up
+ unregisterForegroundProcess(callID)
+ },
+ })
+ })
+
+ test(
+ "preserves initial output when migrating",
+ async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Use a command that outputs something and then sleeps
+ const proc = createMockProcess('echo "initial" && sleep 5')
+ const callID = `test_migrate_output_${Date.now()}`
+ const initialOutput = "previously captured output\n"
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: 'echo "initial" && sleep 5',
+ description: "Output test",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: initialOutput,
+ })
+
+ // Let the process run a bit to generate output
+ await Bun.sleep(300)
+
+ // Migrate to background
+ const taskId = await migrateToBackground(callID)
+ expect(taskId).not.toBeNull()
+
+ // Verify task was created
+ const task = await TaskManager.get(taskId!)
+ expect(task).toBeDefined()
+
+ // Clean up
+ await TaskManager.kill(taskId!)
+ await waitForTaskCompletion(taskId!)
+ },
+ })
+ },
+ { timeout: 30000 },
+ )
+ })
+})
+
+describe("killForegroundProcess", () => {
+ test(
+ "kills a running foreground process",
+ async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("sleep 60")
+ const callID = `test_kill_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 60",
+ description: "Kill test",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Verify process is registered
+ expect(getForegroundProcess(callID)).toBeDefined()
+
+ // Kill the process
+ const killed = await killForegroundProcess(callID)
+ expect(killed).toBe(true)
+
+ // Wait a bit for process to be cleaned up
+ await Bun.sleep(300)
+
+ // Clean up registry
+ unregisterForegroundProcess(callID)
+ },
+ })
+ },
+ { timeout: 10000 },
+ )
+
+ test("returns false for unknown callID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const killed = await killForegroundProcess("non_existent_call_id")
+ expect(killed).toBe(false)
+ },
+ })
+ })
+
+ test("returns false for already exited process", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("echo done")
+ const callID = `test_kill_exited_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "echo done",
+ description: "Already exited",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Wait for process to exit
+ await new Promise((resolve) => {
+ proc.on("exit", () => resolve())
+ })
+
+ // Kill should return false since process already exited
+ const killed = await killForegroundProcess(callID)
+ expect(killed).toBe(false)
+
+ // Clean up
+ unregisterForegroundProcess(callID)
+ },
+ })
+ })
+})
+
+describe("listForegroundProcesses filtering", () => {
+ test("excludes migrated processes", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("sleep 10")
+ const callID = `test_list_migrated_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "sleep 10",
+ description: "Migrated process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Verify it's in the list before migration
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(true)
+
+ // Migrate
+ const taskId = await migrateToBackground(callID)
+ expect(taskId).not.toBeNull()
+
+ // Process should no longer be in the list (removed from registry)
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(false)
+
+ // Clean up
+ await TaskManager.kill(taskId!)
+ await waitForTaskCompletion(taskId!)
+ },
+ })
+ })
+
+ test("excludes exited processes", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const proc = createMockProcess("echo quick")
+ const callID = `test_list_exited_${Date.now()}`
+
+ registerForegroundProcess({
+ callID,
+ sessionID: "test-session",
+ pid: proc.pid!,
+ command: "echo quick",
+ description: "Exited process",
+ workdir: tmp.path,
+ startTime: Date.now(),
+ process: proc,
+ output: "",
+ })
+
+ // Wait for process to exit
+ await new Promise((resolve) => {
+ proc.on("exit", () => resolve())
+ })
+
+ // Process should not appear in list (exited)
+ expect(listForegroundProcesses().some((p) => p.callID === callID)).toBe(false)
+
+ // But should still be gettable by callID until unregistered
+ expect(getForegroundProcess(callID)).toBeDefined()
+
+ // Clean up
+ unregisterForegroundProcess(callID)
+ },
+ })
+ })
+})