diff --git a/src/index.ts b/src/index.ts index 15f257bb..24179787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -228,7 +228,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const backgroundNotificationHook = isHookEnabled("background-notification") ? createBackgroundNotificationHook(backgroundManager) : null; - const backgroundTools = createBackgroundTools(); + const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); const lookAt = createLookAt(ctx); diff --git a/src/tools/background-task/constants.ts b/src/tools/background-task/constants.ts new file mode 100644 index 00000000..9a2e1fc9 --- /dev/null +++ b/src/tools/background-task/constants.ts @@ -0,0 +1,7 @@ +export const BACKGROUND_TASK_DESCRIPTION = `Run agent task in background. Returns task_id immediately; notifies on completion. + +Use \`background_output\` to get results. Prompts MUST be in English.` + +export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. System notifies on completion, so block=true rarely needed.` + +export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.` diff --git a/src/tools/background-task/index.ts b/src/tools/background-task/index.ts new file mode 100644 index 00000000..14cb4cea --- /dev/null +++ b/src/tools/background-task/index.ts @@ -0,0 +1,7 @@ +export { + createBackgroundOutput, + createBackgroundCancel, +} from "./tools" + +export type * from "./types" +export * from "./constants" diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts new file mode 100644 index 00000000..b9637e23 --- /dev/null +++ b/src/tools/background-task/tools.ts @@ -0,0 +1,370 @@ +import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" +import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types" +import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" + +type OpencodeClient = PluginInput["client"] + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} + +function formatDuration(start: Date, end?: Date): string { + const duration = (end ?? new Date()).getTime() - start.getTime() + const seconds = Math.floor(duration / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s` + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s` + } else { + return `${seconds}s` + } +} + +type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void +} + +export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { + return tool({ + description: BACKGROUND_TASK_DESCRIPTION, + args: { + description: tool.schema.string().describe("Short task description (shown in status)"), + prompt: tool.schema.string().describe("Full detailed prompt for the agent"), + agent: tool.schema.string().describe("Agent type to use (any registered agent)"), + }, + async execute(args: BackgroundTaskArgs, toolContext) { + const ctx = toolContext as ToolContextWithMetadata + + if (!args.agent || args.agent.trim() === "") { + return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` + } + + try { + const messageDir = getMessageDir(ctx.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } + : undefined + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.agent.trim(), + parentSessionID: ctx.sessionID, + parentMessageID: ctx.messageID, + parentModel, + }) + + ctx.metadata?.({ + title: args.description, + metadata: { sessionId: task.sessionID }, + }) + + return `Background task launched successfully. + +Task ID: ${task.id} +Session ID: ${task.sessionID} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} + +The system will notify you when the task completes. +Use \`background_output\` tool with task_id="${task.id}" to check progress: +- block=false (default): Check status immediately - returns full status info +- block=true: Wait for completion (rarely needed since system notifies)` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `❌ Failed to launch background task: ${message}` + } + }, + }) +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + "..." +} + +function formatTaskStatus(task: BackgroundTask): string { + const duration = formatDuration(task.startedAt, task.completedAt) + const promptPreview = truncateText(task.prompt, 500) + + let progressSection = "" + if (task.progress?.lastTool) { + progressSection = `\n| Last tool | ${task.progress.lastTool} |` + } + + let lastMessageSection = "" + if (task.progress?.lastMessage) { + const truncated = truncateText(task.progress.lastMessage, 500) + const messageTime = task.progress.lastMessageAt + ? task.progress.lastMessageAt.toISOString() + : "N/A" + lastMessageSection = ` + +## Last Message (${messageTime}) + +\`\`\` +${truncated} +\`\`\`` + } + + let statusNote = "" + if (task.status === "running") { + statusNote = ` + +> **Note**: No need to wait explicitly - the system will notify you when this task completes.` + } else if (task.status === "error") { + statusNote = ` + +> **Failed**: The task encountered an error. Check the last message for details.` + } + + return `# Task Status + +| Field | Value | +|-------|-------| +| Task ID | \`${task.id}\` | +| Description | ${task.description} | +| Agent | ${task.agent} | +| Status | **${task.status}** | +| Duration | ${duration} | +| Session ID | \`${task.sessionID}\` |${progressSection} +${statusNote} +## Original Prompt + +\`\`\` +${promptPreview} +\`\`\`${lastMessageSection}` +} + +async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise { + const messagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + if (messagesResult.error) { + return `Error fetching messages: ${messagesResult.error}` + } + + // Handle both SDK response structures: direct array or wrapped in .data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ + info?: { role?: string } + parts?: Array<{ type?: string; text?: string }> + }> + + if (!Array.isArray(messages) || messages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt, task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No messages found)` + } + + const assistantMessages = messages.filter( + (m) => m.info?.role === "assistant" + ) + + if (assistantMessages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt, task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No assistant response found)` + } + + const lastMessage = assistantMessages[assistantMessages.length - 1] + const textParts = lastMessage?.parts?.filter( + (p) => p.type === "text" + ) ?? [] + const textContent = textParts + .map((p) => p.text ?? "") + .filter((text) => text.length > 0) + .join("\n") + + const duration = formatDuration(task.startedAt, task.completedAt) + + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +${textContent || "(No text output)"}` +} + +export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { + return tool({ + description: BACKGROUND_OUTPUT_DESCRIPTION, + args: { + task_id: tool.schema.string().describe("Task ID to get output from"), + block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."), + timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), + }, + async execute(args: BackgroundOutputArgs) { + try { + const task = manager.getTask(args.task_id) + if (!task) { + return `Task not found: ${args.task_id}` + } + + const shouldBlock = args.block === true + const timeoutMs = Math.min(args.timeout ?? 60000, 600000) + + // Already completed: return result immediately (regardless of block flag) + if (task.status === "completed") { + return await formatTaskResult(task, client) + } + + // Error or cancelled: return status immediately + if (task.status === "error" || task.status === "cancelled") { + return formatTaskStatus(task) + } + + // Non-blocking and still running: return status + if (!shouldBlock) { + return formatTaskStatus(task) + } + + // Blocking: poll until completion or timeout + const startTime = Date.now() + + while (Date.now() - startTime < timeoutMs) { + await delay(1000) + + const currentTask = manager.getTask(args.task_id) + if (!currentTask) { + return `Task was deleted: ${args.task_id}` + } + + if (currentTask.status === "completed") { + return await formatTaskResult(currentTask, client) + } + + if (currentTask.status === "error" || currentTask.status === "cancelled") { + return formatTaskStatus(currentTask) + } + } + + // Timeout exceeded: return current status + const finalTask = manager.getTask(args.task_id) + if (!finalTask) { + return `Task was deleted: ${args.task_id}` + } + return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` + } catch (error) { + return `Error getting output: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} + +export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { + return tool({ + description: BACKGROUND_CANCEL_DESCRIPTION, + args: { + taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), + all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), + }, + async execute(args: BackgroundCancelArgs, toolContext) { + try { + const cancelAll = args.all === true + + if (!cancelAll && !args.taskId) { + return `❌ Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` + } + + if (cancelAll) { + const tasks = manager.getAllDescendantTasks(toolContext.sessionID) + const runningTasks = tasks.filter(t => t.status === "running") + + if (runningTasks.length === 0) { + return `✅ No running background tasks to cancel.` + } + + const results: string[] = [] + for (const task of runningTasks) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + + task.status = "cancelled" + task.completedAt = new Date() + results.push(`- ${task.id}: ${task.description}`) + } + + return `✅ Cancelled ${runningTasks.length} background task(s): + +${results.join("\n")}` + } + + const task = manager.getTask(args.taskId!) + if (!task) { + return `❌ Task not found: ${args.taskId}` + } + + if (task.status !== "running") { + return `❌ Cannot cancel task: current status is "${task.status}". +Only running tasks can be cancelled.` + } + + // Fire-and-forget: abort 요청을 보내고 await 하지 않음 + // await 하면 메인 세션까지 abort 되는 문제 발생 + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + + task.status = "cancelled" + task.completedAt = new Date() + + return `✅ Task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Session ID: ${task.sessionID} +Status: ${task.status}` + } catch (error) { + return `❌ Error cancelling task: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/types.ts b/src/tools/background-task/types.ts new file mode 100644 index 00000000..1b6cf879 --- /dev/null +++ b/src/tools/background-task/types.ts @@ -0,0 +1,16 @@ +export interface BackgroundTaskArgs { + description: string + prompt: string + agent: string +} + +export interface BackgroundOutputArgs { + task_id: string + block?: boolean + timeout?: number +} + +export interface BackgroundCancelArgs { + taskId?: string + all?: boolean +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 0688470d..b02117b2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -35,14 +35,25 @@ export { createSkillTool } from "./skill" export { getTmuxPath } from "./interactive-bash/utils" export { createSkillMcpTool } from "./skill-mcp" -import type { ToolDefinition } from "@opencode-ai/plugin" +import { + createBackgroundOutput, + createBackgroundCancel, +} from "./background-task" + +import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../features/background-agent" + +type OpencodeClient = PluginInput["client"] export { createCallOmoAgent } from "./call-omo-agent" export { createLookAt } from "./look-at" export { createSisyphusTask, type SisyphusTaskToolOptions, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./sisyphus-task" -export function createBackgroundTools(): Record { - return {} +export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record { + return { + background_output: createBackgroundOutput(manager, client), + background_cancel: createBackgroundCancel(manager, client), + } } export const builtinTools: Record = {