From a58e23c3001d16f7e90d39ca3024a7ec06c561a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Sat, 17 Jan 2026 15:28:08 -0300 Subject: [PATCH 1/3] feat(task): add background task manager for persistent processes Implement a system for running and managing background processes that persist across sessions: - TaskManager core with create/list/get/kill/read/tail/input operations - Persistence layer saving state to .opencode/tasks/{taskId}.json - Bus events for task lifecycle (created, output, completed, killed) - Bash tool now supports run_in_background parameter - RPC handlers for task management from TUI - Comprehensive test suite (29 tests) Background tasks can be reconnected after session restart by checking if the process (by PID) is still running. --- packages/opencode/src/cli/cmd/tui/worker.ts | 66 ++ packages/opencode/src/task/events.ts | 89 +++ packages/opencode/src/task/index.ts | 477 ++++++++++++++ packages/opencode/src/task/persistence.ts | 191 ++++++ packages/opencode/src/tool/bash.ts | 32 + packages/opencode/test/task/task.test.ts | 668 ++++++++++++++++++++ 6 files changed, 1523 insertions(+) create mode 100644 packages/opencode/src/task/events.ts create mode 100644 packages/opencode/src/task/index.ts create mode 100644 packages/opencode/src/task/persistence.ts create mode 100644 packages/opencode/test/task/task.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..3f79c0bf63b 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"), @@ -140,6 +141,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/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..102aa2de531 --- /dev/null +++ b/packages/opencode/src/task/index.ts @@ -0,0 +1,477 @@ +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 + + const persisted = await TaskPersistence.loadAll(Instance.directory) + log.info("loading persisted tasks", { count: persisted.length }) + + for (const saved of persisted) { + 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) + await Bus.publish(TaskEvent.Reconnected, { + id: saved.id, + stillRunning: true, + }) + } else if (saved.status === "running") { + // Process died while we weren't watching - mark as failed + task.status = "failed" + await TaskPersistence.save(Instance.directory, task) + s.tasks.set(saved.id, task) + await Bus.publish(TaskEvent.Reconnected, { + id: saved.id, + stillRunning: false, + }) + } else { + // Already completed/failed, just load it + s.tasks.set(saved.id, task) + } + } + } + + /** + * 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, + }) + + const proc = spawn(opts.command, { + shell, + cwd: workdir, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + 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 + await TaskPersistence.save(directory, task) + + // Handle stdout - wrapped in Instance.provide for async context + proc.stdout?.on("data", (chunk: Buffer) => { + const data = chunk.toString() + task.output.push(data) + // Fire and forget - don't await to avoid blocking process + void Instance.provide({ + directory, + fn: async () => { + await TaskPersistence.appendLog(directory, taskId, data) + await Bus.publish(TaskEvent.Output, { + id: taskId, + data, + isError: false, + }) + }, + }) + }) + + // Handle stderr - wrapped in Instance.provide for async context + proc.stderr?.on("data", (chunk: Buffer) => { + const data = chunk.toString() + task.output.push(data) + // Fire and forget - don't await to avoid blocking process + void Instance.provide({ + directory, + fn: async () => { + await TaskPersistence.appendLog(directory, taskId, data) + await Bus.publish(TaskEvent.Output, { + id: taskId, + data, + isError: true, + }) + }, + }) + }) + + // 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 () => { + await TaskPersistence.save(directory, task) + await Bus.publish(TaskEvent.Completed, { + id: taskId, + exitCode: code, + status: task.status, + }) + + log.info("background task completed", { + taskId, + exitCode: code, + status: task.status, + }) + }, + }) + }) + + // Handle errors - wrapped in Instance.provide for async context + proc.once("error", (err) => { + log.error("background task error", { taskId, error: err }) + task.status = "failed" + task.process = undefined + + void Instance.provide({ + directory, + fn: async () => { + await TaskPersistence.save(directory, task) + await Bus.publish(TaskEvent.Completed, { + id: taskId, + exitCode: null, + status: "failed", + }) + }, + }) + }) + + // Publish created event + 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, + }, + }) + + 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 + */ + export async function list(): Promise { + await init() + 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 + + await TaskPersistence.save(Instance.directory, task) + await Bus.publish(TaskEvent.Killed, { id: taskId }) + + 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: 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 + } +} diff --git a/packages/opencode/src/task/persistence.ts b/packages/opencode/src/task/persistence.ts new file mode 100644 index 00000000000..11414e02448 --- /dev/null +++ b/packages/opencode/src/task/persistence.ts @@ -0,0 +1,191 @@ +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 dir = getTasksDir(projectDir) + if (!(await Filesystem.exists(dir))) { + return [] + } + + const files = await fs.readdir(dir) + const tasks: PersistedTask[] = [] + + for (const file of files) { + if (!file.endsWith(".json")) continue + const taskId = file.replace(".json", "") + const task = await load(projectDir, taskId) + if (task) { + tasks.push(task) + } + } + + return tasks + } + + /** + * 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..ec33b6ee06c 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -16,6 +16,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" +import { TaskManager } from "@/task" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -73,6 +74,12 @@ export const BashTool = Tool.define("bash", async () => { .describe( "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", ), + run_in_background: z + .boolean() + .describe( + "If true, run the command in background and return immediately. Use TaskOutput to read the output later. The command will continue running even if the session ends.", + ) + .optional(), }), async execute(params, ctx) { const cwd = params.workdir || Instance.directory @@ -154,6 +161,31 @@ export const BashTool = Tool.define("bash", async () => { }) } + // Handle background task execution + if (params.run_in_background) { + const task = await TaskManager.create({ + command: params.command, + workdir: cwd, + description: params.description, + reconnectable: true, + }) + + const output = `Background task started with ID: ${task.id}\nPID: ${task.pid}\nLog file: ${task.logFile}\n\nUse TaskOutput tool to read output later.` + + return { + title: params.description, + metadata: { + output, + description: params.description, + taskId: task.id, + pid: task.pid, + logFile: task.logFile, + background: true, + } as Record, + output, + } + } + const proc = spawn(params.command, { shell, cwd, 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) + }, + }) + }) +}) From 1f09f612b585931af9c606916a5672ff54c7157e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Sat, 17 Jan 2026 15:35:14 -0300 Subject: [PATCH 2/3] feat(tool): add process_query tool for querying background tasks New tool allows LLM to: - List all background tasks - Check task status - Read full or partial output - Search for patterns in task logs Resolves task identifiers by ID, "last" keyword, or description match. --- packages/opencode/src/tool/process-query.ts | 245 ++++++++++++++++++++ packages/opencode/src/tool/registry.ts | 2 + 2 files changed, 247 insertions(+) create mode 100644 packages/opencode/src/tool/process-query.ts 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 35e378f080b..d5cc8317b14 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" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -102,6 +103,7 @@ export namespace ToolRegistry { EditTool, WriteTool, TaskTool, + ProcessQueryTool, WebFetchTool, TodoWriteTool, TodoReadTool, From f331e05bec97e581b4762502ce2f979215910494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Tue, 20 Jan 2026 16:54:14 -0300 Subject: [PATCH 3/3] feat: add background tasks management with [bg] and [kill] controls - Add Background Tasks modal (ctrl+p > "Background tasks") - Add [bg] button to migrate running processes to background - Add [kill] button to terminate running processes - Support [bg]/[kill] for shell commands (! prefix) - Add TaskManager.adopt() for process migration - Add foreground process registry for tracking - Clean up debug logs (use Log utility instead of console) - Optimize migration latency with fire-and-forget persistence - Add 17 tests for foreground process management --- packages/opencode/src/cli/cmd/tui/app.tsx | 10 + .../cli/cmd/tui/component/dialog-tasks.tsx | 271 ++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 6 + .../opencode/src/cli/cmd/tui/context/sdk.tsx | 5 +- .../src/cli/cmd/tui/routes/session/index.tsx | 130 +++- .../src/cli/cmd/tui/ui/process-viewer.tsx | 505 ++++++++++++++ .../opencode/src/cli/cmd/tui/ui/progress.tsx | 430 ++++++++++++ packages/opencode/src/cli/cmd/tui/worker.ts | 73 +- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/routes/task.ts | 374 +++++++++++ packages/opencode/src/server/server.ts | 2 + packages/opencode/src/session/prompt.ts | 120 +++- packages/opencode/src/task/index.ts | 480 ++++++++++--- packages/opencode/src/task/persistence.ts | 69 +- packages/opencode/src/tool/bash.ts | 288 ++++++-- .../test/tool/bash-foreground-process.test.ts | 634 ++++++++++++++++++ 16 files changed, 3230 insertions(+), 168 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/ui/progress.tsx create mode 100644 packages/opencode/src/server/routes/task.ts create mode 100644 packages/opencode/test/tool/bash-foreground-process.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2ec1fb703f9..37d879299bf 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" @@ -421,6 +422,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 145fa9da0c3..a544a47438e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -949,6 +949,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 1d64a2ff156..0ada6567dd9 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, @@ -1328,7 +1329,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 ?? {} @@ -1539,8 +1544,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(() => { @@ -1548,6 +1556,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 @@ -1575,7 +1660,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) */} + + + > + +