From 6bb9a3b7bca4cc5e0b14fe1fd23ba7651351777d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:00:26 +0900 Subject: [PATCH 01/12] refactor(tools/call-omo-agent): split tools.ts into focused modules under 200 LOC - Extract getMessageDir to message-dir.ts - Extract executeBackground to background-executor.ts - Extract session creation logic to session-creator.ts - Extract polling logic to completion-poller.ts - Extract message processing to message-processor.ts - Create sync-executor.ts to orchestrate sync execution - Add ToolContextWithMetadata type to types.ts - tools.ts now <200 LOC and focused on tool definition --- .../call-omo-agent/background-executor.ts | 83 +++++ src/tools/call-omo-agent/completion-poller.ts | 67 ++++ src/tools/call-omo-agent/message-dir.ts | 18 + src/tools/call-omo-agent/message-processor.ts | 84 +++++ src/tools/call-omo-agent/session-creator.ts | 70 ++++ src/tools/call-omo-agent/sync-executor.ts | 59 ++++ src/tools/call-omo-agent/tools.ts | 327 +----------------- src/tools/call-omo-agent/types.ts | 7 + 8 files changed, 392 insertions(+), 323 deletions(-) create mode 100644 src/tools/call-omo-agent/background-executor.ts create mode 100644 src/tools/call-omo-agent/completion-poller.ts create mode 100644 src/tools/call-omo-agent/message-dir.ts create mode 100644 src/tools/call-omo-agent/message-processor.ts create mode 100644 src/tools/call-omo-agent/session-creator.ts create mode 100644 src/tools/call-omo-agent/sync-executor.ts diff --git a/src/tools/call-omo-agent/background-executor.ts b/src/tools/call-omo-agent/background-executor.ts new file mode 100644 index 000000000..3a838edb8 --- /dev/null +++ b/src/tools/call-omo-agent/background-executor.ts @@ -0,0 +1,83 @@ +import type { CallOmoAgentArgs } from "./types" +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared" +import { consumeNewMessages } from "../../shared/session-cursor" +import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { getMessageDir } from "./message-dir" + +export async function executeBackground( + args: CallOmoAgentArgs, + toolContext: { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + }, + manager: BackgroundManager +): Promise { + try { + const messageDir = getMessageDir(toolContext.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(toolContext.sessionID) + const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[call_omo_agent] parentAgent resolution", { + sessionID: toolContext.sessionID, + messageDir, + ctxAgent: toolContext.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.subagent_type, + parentSessionID: toolContext.sessionID, + parentMessageID: toolContext.messageID, + parentAgent, + }) + + const WAIT_FOR_SESSION_INTERVAL_MS = 50 + const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 + const waitStart = Date.now() + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { + if (toolContext.abort?.aborted) { + return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` + } + const updated = manager.getTask(task.id) + if (updated?.status === "error" || updated?.status === "cancelled") { + return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}` + } + await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS)) + sessionId = manager.getTask(task.id)?.sessionID + } + + await toolContext.metadata?.({ + title: args.description, + metadata: { sessionId: sessionId ?? "pending" }, + }) + + return `Background agent task launched successfully. + +Task ID: ${task.id} +Session ID: ${sessionId ?? "pending"} +Description: ${task.description} +Agent: ${task.agent} (subagent) +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 agent task: ${message}` + } +} diff --git a/src/tools/call-omo-agent/completion-poller.ts b/src/tools/call-omo-agent/completion-poller.ts new file mode 100644 index 000000000..0ca73e735 --- /dev/null +++ b/src/tools/call-omo-agent/completion-poller.ts @@ -0,0 +1,67 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" + +export async function waitForCompletion( + sessionID: string, + toolContext: { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + }, + ctx: PluginInput +): Promise { + log(`[call_omo_agent] Polling for completion...`) + + // Poll for session completion + const POLL_INTERVAL_MS = 500 + const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + const STABILITY_REQUIRED = 3 + + while (Date.now() - pollStart < MAX_POLL_TIME_MS) { + // Check if aborted + if (toolContext.abort?.aborted) { + log(`[call_omo_agent] Aborted by user`) + throw new Error("Task aborted.") + } + + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + + // Check session status + const statusResult = await ctx.client.session.status() + const allStatuses = (statusResult.data ?? {}) as Record + const sessionStatus = allStatuses[sessionID] + + // If session is actively running, reset stability counter + if (sessionStatus && sessionStatus.type !== "idle") { + stablePolls = 0 + lastMsgCount = 0 + continue + } + + // Session is idle - check message stability + const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= STABILITY_REQUIRED) { + log(`[call_omo_agent] Session complete, ${currentMsgCount} messages`) + break + } + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + + if (Date.now() - pollStart >= MAX_POLL_TIME_MS) { + log(`[call_omo_agent] Timeout reached`) + throw new Error("Agent task timed out after 5 minutes.") + } +} diff --git a/src/tools/call-omo-agent/message-dir.ts b/src/tools/call-omo-agent/message-dir.ts new file mode 100644 index 000000000..01fa68fca --- /dev/null +++ b/src/tools/call-omo-agent/message-dir.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!sessionID.startsWith("ses_")) return 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 +} diff --git a/src/tools/call-omo-agent/message-processor.ts b/src/tools/call-omo-agent/message-processor.ts new file mode 100644 index 000000000..6b4eca6c6 --- /dev/null +++ b/src/tools/call-omo-agent/message-processor.ts @@ -0,0 +1,84 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" +import { consumeNewMessages } from "../../shared/session-cursor" + +export async function processMessages( + sessionID: string, + ctx: PluginInput +): Promise { + const messagesResult = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + + if (messagesResult.error) { + log(`[call_omo_agent] Messages error:`, messagesResult.error) + throw new Error(`Failed to get messages: ${messagesResult.error}`) + } + + const messages = messagesResult.data + log(`[call_omo_agent] Got ${messages.length} messages`) + + // Include both assistant messages AND tool messages + // Tool results (grep, glob, bash output) come from role "tool" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const relevantMessages = messages.filter( + (m: any) => m.info?.role === "assistant" || m.info?.role === "tool" + ) + + if (relevantMessages.length === 0) { + log(`[call_omo_agent] No assistant or tool messages found`) + log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)) + throw new Error("No assistant or tool response found") + } + + log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`) + + // Sort by time ascending (oldest first) to process messages in order + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sortedMessages = [...relevantMessages].sort((a: any, b: any) => { + const timeA = a.info?.time?.created ?? 0 + const timeB = b.info?.time?.created ?? 0 + return timeA - timeB + }) + + const newMessages = consumeNewMessages(sessionID, sortedMessages) + + if (newMessages.length === 0) { + return "No new output since last check." + } + + // Extract content from ALL messages, not just the last one + // Tool results may be in earlier messages while the final message is empty + const extractedContent: string[] = [] + + for (const message of newMessages) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const part of (message as any).parts ?? []) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + } else if (part.type === "tool_result") { + // Tool results contain the actual output from tool calls + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + } else if (Array.isArray(toolResult.content)) { + // Handle array of content blocks + for (const block of toolResult.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const responseText = extractedContent + .filter((text) => text.length > 0) + .join("\n\n") + + log(`[call_omo_agent] Got response, length: ${responseText.length}`) + + return responseText +} diff --git a/src/tools/call-omo-agent/session-creator.ts b/src/tools/call-omo-agent/session-creator.ts new file mode 100644 index 000000000..64aa664d5 --- /dev/null +++ b/src/tools/call-omo-agent/session-creator.ts @@ -0,0 +1,70 @@ +import type { CallOmoAgentArgs } from "./types" +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" + +export async function createOrGetSession( + args: CallOmoAgentArgs, + toolContext: { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + }, + ctx: PluginInput +): Promise<{ sessionID: string; isNew: boolean }> { + if (args.session_id) { + log(`[call_omo_agent] Using existing session: ${args.session_id}`) + const sessionResult = await ctx.client.session.get({ + path: { id: args.session_id }, + }) + if (sessionResult.error) { + log(`[call_omo_agent] Session get error:`, sessionResult.error) + throw new Error(`Failed to get existing session: ${sessionResult.error}`) + } + return { sessionID: args.session_id, isNew: false } + } else { + log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`) + const parentSession = await ctx.client.session.get({ + path: { id: toolContext.sessionID }, + }).catch((err) => { + log(`[call_omo_agent] Failed to get parent session:`, err) + return null + }) + log(`[call_omo_agent] Parent session dir: ${parentSession?.data?.directory}, fallback: ${ctx.directory}`) + const parentDirectory = parentSession?.data?.directory ?? ctx.directory + + const createResult = await ctx.client.session.create({ + body: { + parentID: toolContext.sessionID, + title: `${args.description} (@${args.subagent_type} subagent)`, + permission: [ + { permission: "question", action: "deny" as const, pattern: "*" }, + ], + } as any, + query: { + directory: parentDirectory, + }, + }) + + if (createResult.error) { + log(`[call_omo_agent] Session create error:`, createResult.error) + const errorStr = String(createResult.error) + if (errorStr.toLowerCase().includes("unauthorized")) { + throw new Error(`Failed to create session (Unauthorized). This may be due to: +1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only) +2. Provider authentication issues +3. Session permission inheritance problems + +Try using a different provider or API key authentication. + +Original error: ${createResult.error}`) + } + throw new Error(`Failed to create session: ${createResult.error}`) + } + + const sessionID = createResult.data.id + log(`[call_omo_agent] Created session: ${sessionID}`) + return { sessionID, isNew: true } + } +} diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts new file mode 100644 index 000000000..f64310fbd --- /dev/null +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -0,0 +1,59 @@ +import type { CallOmoAgentArgs } from "./types" +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" +import { getAgentToolRestrictions } from "../../shared" +import { createOrGetSession } from "./session-creator" +import { waitForCompletion } from "./completion-poller" +import { processMessages } from "./message-processor" + +export async function executeSync( + args: CallOmoAgentArgs, + toolContext: { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + }, + ctx: PluginInput +): Promise { + const { sessionID } = await createOrGetSession(args, toolContext, ctx) + + await toolContext.metadata?.({ + title: args.description, + metadata: { sessionId: sessionID }, + }) + + log(`[call_omo_agent] Sending prompt to session ${sessionID}`) + log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100)) + + try { + await (ctx.client.session as any).promptAsync({ + path: { id: sessionID }, + body: { + agent: args.subagent_type, + tools: { + ...getAgentToolRestrictions(args.subagent_type), + task: false, + }, + parts: [{ type: "text", text: args.prompt }], + }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + log(`[call_omo_agent] Prompt error:`, errorMessage) + if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n\nsession_id: ${sessionID}\n` + } + return `Error: Failed to send prompt: ${errorMessage}\n\n\nsession_id: ${sessionID}\n` + } + + await waitForCompletion(sessionID, toolContext, ctx) + + const responseText = await processMessages(sessionID, ctx) + + const output = + responseText + "\n\n" + ["", `session_id: ${sessionID}`, ""].join("\n") + + return output +} diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 64fa74a40..25c2c8401 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -1,36 +1,10 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" -import type { CallOmoAgentArgs } from "./types" +import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types" import type { BackgroundManager } from "../../features/background-agent" -import { log, getAgentToolRestrictions } from "../../shared" -import { consumeNewMessages } from "../../shared/session-cursor" -import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { getSessionAgent } from "../../features/claude-code-session-state" - -function getMessageDir(sessionID: string): string | null { - if (!sessionID.startsWith("ses_")) return 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 -} - -type ToolContextWithMetadata = { - sessionID: string - messageID: string - agent: string - abort: AbortSignal - metadata?: (input: { title?: string; metadata?: Record }) => void -} +import { log } from "../../shared" +import { executeBackground } from "./background-executor" +import { executeSync } from "./sync-executor" export function createCallOmoAgent( ctx: PluginInput, @@ -79,296 +53,3 @@ export function createCallOmoAgent( }, }) } - -async function executeBackground( - args: CallOmoAgentArgs, - toolContext: ToolContextWithMetadata, - manager: BackgroundManager -): Promise { - try { - const messageDir = getMessageDir(toolContext.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null - const sessionAgent = getSessionAgent(toolContext.sessionID) - const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent - - log("[call_omo_agent] parentAgent resolution", { - sessionID: toolContext.sessionID, - messageDir, - ctxAgent: toolContext.agent, - sessionAgent, - firstMessageAgent, - prevMessageAgent: prevMessage?.agent, - resolvedParentAgent: parentAgent, - }) - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.subagent_type, - parentSessionID: toolContext.sessionID, - parentMessageID: toolContext.messageID, - parentAgent, - }) - - const WAIT_FOR_SESSION_INTERVAL_MS = 50 - const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { - if (toolContext.abort?.aborted) { - return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` - } - const updated = manager.getTask(task.id) - if (updated?.status === "error" || updated?.status === "cancelled") { - return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}` - } - await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS)) - sessionId = manager.getTask(task.id)?.sessionID - } - - await toolContext.metadata?.({ - title: args.description, - metadata: { sessionId: sessionId ?? "pending" }, - }) - - return `Background agent task launched successfully. - -Task ID: ${task.id} -Session ID: ${sessionId ?? "pending"} -Description: ${task.description} -Agent: ${task.agent} (subagent) -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 agent task: ${message}` - } -} - -async function executeSync( - args: CallOmoAgentArgs, - toolContext: ToolContextWithMetadata, - ctx: PluginInput -): Promise { - let sessionID: string - - if (args.session_id) { - log(`[call_omo_agent] Using existing session: ${args.session_id}`) - const sessionResult = await ctx.client.session.get({ - path: { id: args.session_id }, - }) - if (sessionResult.error) { - log(`[call_omo_agent] Session get error:`, sessionResult.error) - return `Error: Failed to get existing session: ${sessionResult.error}` - } - sessionID = args.session_id - } else { - log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`) - const parentSession = await ctx.client.session.get({ - path: { id: toolContext.sessionID }, - }).catch((err) => { - log(`[call_omo_agent] Failed to get parent session:`, err) - return null - }) - log(`[call_omo_agent] Parent session dir: ${parentSession?.data?.directory}, fallback: ${ctx.directory}`) - const parentDirectory = parentSession?.data?.directory ?? ctx.directory - - const createResult = await ctx.client.session.create({ - body: { - parentID: toolContext.sessionID, - title: `${args.description} (@${args.subagent_type} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, - }, - }) - - if (createResult.error) { - log(`[call_omo_agent] Session create error:`, createResult.error) - const errorStr = String(createResult.error) - if (errorStr.toLowerCase().includes("unauthorized")) { - return `Error: Failed to create session (Unauthorized). This may be due to: -1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only) -2. Provider authentication issues -3. Session permission inheritance problems - -Try using a different provider or API key authentication. - -Original error: ${createResult.error}` - } - return `Error: Failed to create session: ${createResult.error}` - } - - sessionID = createResult.data.id - log(`[call_omo_agent] Created session: ${sessionID}`) - } - - await toolContext.metadata?.({ - title: args.description, - metadata: { sessionId: sessionID }, - }) - - log(`[call_omo_agent] Sending prompt to session ${sessionID}`) - log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100)) - - try { - await (ctx.client.session as any).promptAsync({ - path: { id: sessionID }, - body: { - agent: args.subagent_type, - tools: { - ...getAgentToolRestrictions(args.subagent_type), - task: false, - }, - parts: [{ type: "text", text: args.prompt }], - }, - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - log(`[call_omo_agent] Prompt error:`, errorMessage) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n\nsession_id: ${sessionID}\n` - } - return `Error: Failed to send prompt: ${errorMessage}\n\n\nsession_id: ${sessionID}\n` - } - - log(`[call_omo_agent] Prompt sent, polling for completion...`) - - // Poll for session completion - const POLL_INTERVAL_MS = 500 - const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max - const pollStart = Date.now() - let lastMsgCount = 0 - let stablePolls = 0 - const STABILITY_REQUIRED = 3 - - while (Date.now() - pollStart < MAX_POLL_TIME_MS) { - // Check if aborted - if (toolContext.abort?.aborted) { - log(`[call_omo_agent] Aborted by user`) - return `Task aborted.\n\n\nsession_id: ${sessionID}\n` - } - - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - - // Check session status - const statusResult = await ctx.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - const sessionStatus = allStatuses[sessionID] - - // If session is actively running, reset stability counter - if (sessionStatus && sessionStatus.type !== "idle") { - stablePolls = 0 - lastMsgCount = 0 - continue - } - - // Session is idle - check message stability - const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array - const currentMsgCount = msgs.length - - if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { - stablePolls++ - if (stablePolls >= STABILITY_REQUIRED) { - log(`[call_omo_agent] Session complete, ${currentMsgCount} messages`) - break - } - } else { - stablePolls = 0 - lastMsgCount = currentMsgCount - } - } - - if (Date.now() - pollStart >= MAX_POLL_TIME_MS) { - log(`[call_omo_agent] Timeout reached`) - return `Error: Agent task timed out after 5 minutes.\n\n\nsession_id: ${sessionID}\n` - } - - const messagesResult = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - - if (messagesResult.error) { - log(`[call_omo_agent] Messages error:`, messagesResult.error) - return `Error: Failed to get messages: ${messagesResult.error}` - } - - const messages = messagesResult.data - log(`[call_omo_agent] Got ${messages.length} messages`) - - // Include both assistant messages AND tool messages - // Tool results (grep, glob, bash output) come from role "tool" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const relevantMessages = messages.filter( - (m: any) => m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (relevantMessages.length === 0) { - log(`[call_omo_agent] No assistant or tool messages found`) - log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)) - return `Error: No assistant or tool response found\n\n\nsession_id: ${sessionID}\n` - } - - log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`) - - // Sort by time ascending (oldest first) to process messages in order - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedMessages = [...relevantMessages].sort((a: any, b: any) => { - const timeA = a.info?.time?.created ?? 0 - const timeB = b.info?.time?.created ?? 0 - return timeA - timeB - }) - - const newMessages = consumeNewMessages(sessionID, sortedMessages) - - if (newMessages.length === 0) { - return `No new output since last check.\n\n\nsession_id: ${sessionID}\n` - } - - // Extract content from ALL messages, not just the last one - // Tool results may be in earlier messages while the final message is empty - const extractedContent: string[] = [] - - for (const message of newMessages) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - for (const part of (message as any).parts ?? []) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((part.type === "text" || part.type === "reasoning") && part.text) { - extractedContent.push(part.text) - } else if (part.type === "tool_result") { - // Tool results contain the actual output from tool calls - const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } - if (typeof toolResult.content === "string" && toolResult.content) { - extractedContent.push(toolResult.content) - } else if (Array.isArray(toolResult.content)) { - // Handle array of content blocks - for (const block of toolResult.content) { - if ((block.type === "text" || block.type === "reasoning") && block.text) { - extractedContent.push(block.text) - } - } - } - } - } - } - - const responseText = extractedContent - .filter((text) => text.length > 0) - .join("\n\n") - - log(`[call_omo_agent] Got response, length: ${responseText.length}`) - - const output = - responseText + "\n\n" + ["", `session_id: ${sessionID}`, ""].join("\n") - - return output -} diff --git a/src/tools/call-omo-agent/types.ts b/src/tools/call-omo-agent/types.ts index 814e355cd..17bdd4e07 100644 --- a/src/tools/call-omo-agent/types.ts +++ b/src/tools/call-omo-agent/types.ts @@ -25,3 +25,10 @@ export interface CallOmoAgentSyncResult { } output: string } +export type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void +} From 42dbc8f39cf391e703b98ec4316a624e24f0242d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:09:12 +0900 Subject: [PATCH 02/12] Fix Issue #1428: Deny bash permission for Prometheus agent - Change PROMETHEUS_PERMISSION bash from 'allow' to 'deny' to prevent unrestricted bash execution - Prometheus is a read-only planner and should not execute bash commands - The prometheus-md-only hook provides additional blocking as backup --- AGENTS.md | 4 +- src/AGENTS.md | 128 +++++++++++ src/agents/utils.test.ts | 25 +++ src/agents/utils.ts | 6 +- src/config/AGENTS.md | 93 ++++++++ src/features/AGENTS.md | 72 ++----- .../spawner/background-session-creator.ts | 46 ++++ .../concurrency-key-from-launch-input.ts | 7 + .../spawner/parent-directory-resolver.ts | 21 ++ .../spawner/tmux-callback-invoker.ts | 39 ++++ .../claude-tasks/session-storage.test.ts | 204 ++++++++++++++++++ src/features/claude-tasks/session-storage.ts | 52 +++++ src/plugin-handlers/AGENTS.md | 96 +++++++++ 13 files changed, 736 insertions(+), 57 deletions(-) create mode 100644 src/AGENTS.md create mode 100644 src/config/AGENTS.md create mode 100644 src/features/background-agent/spawner/background-session-creator.ts create mode 100644 src/features/background-agent/spawner/concurrency-key-from-launch-input.ts create mode 100644 src/features/background-agent/spawner/parent-directory-resolver.ts create mode 100644 src/features/background-agent/spawner/tmux-callback-invoker.ts create mode 100644 src/features/claude-tasks/session-storage.test.ts create mode 100644 src/features/claude-tasks/session-storage.ts create mode 100644 src/plugin-handlers/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 1b539c2de..62d7cd921 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # PROJECT KNOWLEDGE BASE -**Generated:** 2026-02-06T18:30:00+09:00 -**Commit:** c6c149e +**Generated:** 2026-02-08T16:45:00+09:00 +**Commit:** f2b7b75 **Branch:** dev --- diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 000000000..bb69e0a9b --- /dev/null +++ b/src/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS KNOWLEDGE BASE + +## OVERVIEW + +Main plugin entry point and orchestration layer. 1000+ lines of plugin initialization, hook registration, tool composition, and lifecycle management. + +**Core Responsibilities:** +- Plugin initialization and configuration loading +- 40+ lifecycle hooks orchestration +- 25+ tools composition and filtering +- Background agent management +- Session state coordination +- MCP server lifecycle +- Tmux integration +- Claude Code compatibility layer + +## STRUCTURE +``` +src/ +├── index.ts # Main plugin entry (1000 lines) - orchestration layer +├── index.compaction-model-agnostic.static.test.ts # Compaction hook tests +├── agents/ # 11 AI agents (16 files) +├── cli/ # CLI commands (9 files) +├── config/ # Schema validation (3 files) +├── features/ # Background features (20+ files) +├── hooks/ # 40+ lifecycle hooks (14 files) +├── mcp/ # MCP server configs (7 files) +├── plugin-handlers/ # Config loading (3 files) +├── shared/ # Utilities (70 files) +└── tools/ # 25+ tools (15 files) +``` + +## KEY COMPONENTS + +**Plugin Initialization:** +- `OhMyOpenCodePlugin()`: Main plugin factory (lines 124-841) +- Configuration loading via `loadPluginConfig()` +- Hook registration with safe creation patterns +- Tool composition and disabled tool filtering + +**Lifecycle Management:** +- 40+ hooks: session recovery, continuation enforcers, compaction, context injection +- Background agent coordination via `BackgroundManager` +- Tmux session management for multi-pane workflows +- MCP server lifecycle via `SkillMcpManager` + +**Tool Ecosystem:** +- 25+ tools: LSP, AST-grep, delegation, background tasks, skills +- Tool filtering based on agent permissions and user config +- Metadata restoration for tool outputs + +**Integration Points:** +- Claude Code compatibility hooks and commands +- OpenCode SDK client interactions +- Session state persistence and recovery +- Model variant resolution and application + +## HOOK REGISTRATION PATTERNS + +**Safe Hook Creation:** +```typescript +const hook = isHookEnabled("hook-name") + ? safeCreateHook("hook-name", () => createHookFactory(ctx), { enabled: safeHookEnabled }) + : null; +``` + +**Hook Categories:** +- **Session Management**: recovery, notification, compaction +- **Continuation**: todo/task enforcers, stop guards +- **Context**: injection, rules, directory content +- **Tool Enhancement**: output truncation, error recovery, validation +- **Agent Coordination**: usage reminders, babysitting, delegation + +## TOOL COMPOSITION + +**Core Tools:** +```typescript +const allTools: Record = { + ...builtinTools, // Basic file/session operations + ...createGrepTools(ctx), // Content search + ...createAstGrepTools(ctx), // AST-aware refactoring + task: delegateTask, // Agent delegation + skill: skillTool, // Skill execution + // ... 20+ more tools +}; +``` + +**Tool Filtering:** +- Agent permission-based restrictions +- User-configured disabled tools +- Dynamic tool availability based on session state + +## SESSION LIFECYCLE + +**Session Events:** +- `session.created`: Initialize session state, tmux setup +- `session.deleted`: Cleanup resources, clear caches +- `message.updated`: Update agent assignments +- `session.error`: Trigger recovery mechanisms + +**Continuation Flow:** +1. User message triggers agent selection +2. Model/variant resolution applied +3. Tools execute with hook interception +4. Continuation enforcers monitor completion +5. Session compaction preserves context + +## CONFIGURATION INTEGRATION + +**Plugin Config Loading:** +- Project + user config merging +- Schema validation via Zod +- Migration support for legacy configs +- Dynamic feature enablement + +**Runtime Configuration:** +- Hook enablement based on `disabled_hooks` +- Tool filtering via `disabled_tools` +- Agent overrides and category definitions +- Experimental feature toggles + +## ANTI-PATTERNS + +- **Direct hook exports**: All hooks created via factories for testability +- **Global state pollution**: Session-scoped state management +- **Synchronous blocking**: Async-first architecture with background coordination +- **Tight coupling**: Plugin components communicate via events, not direct calls +- **Memory leaks**: Proper cleanup on session deletion and plugin unload diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index dfe9d972f..b685eff9a 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -1207,4 +1207,29 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", ( fetchSpy.mockRestore?.() cacheSpy.mockRestore?.() }) + test("Hephaestus variant override respects user config over hardcoded default", async () => { + // #given - user provides variant in config + const overrides = { + hephaestus: { variant: "high" }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + + // #then - user variant takes precedence over hardcoded "medium" + expect(agents.hephaestus).toBeDefined() + expect(agents.hephaestus.variant).toBe("high") + }) + + test("Hephaestus uses default variant when no user override provided", async () => { + // #given - no variant override in config + const overrides = {} + + // #when + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + + // #then - default "medium" variant is applied + expect(agents.hephaestus).toBeDefined() + expect(agents.hephaestus.variant).toBe("medium") + }) }) diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 55d6187b4..93ea58e96 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -509,13 +509,13 @@ export async function createBuiltinAgents( availableCategories ) - hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } - + if (!hephaestusOverride?.variant) { + hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } + } const hepOverrideCategory = (hephaestusOverride as Record | undefined)?.category as string | undefined if (hepOverrideCategory) { hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories) } - if (directory && hephaestusConfig.prompt) { const envContext = createEnvContext() hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext } diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md new file mode 100644 index 000000000..ce20d3dd6 --- /dev/null +++ b/src/config/AGENTS.md @@ -0,0 +1,93 @@ +**Generated:** 2026-02-08T16:45:00+09:00 +**Commit:** f2b7b759 +**Branch:** dev + +## OVERVIEW + +Zod schema definitions for plugin configuration. 455+ lines of type-safe config validation with JSONC support, multi-level inheritance, and comprehensive agent/category overrides. + +## STRUCTURE +``` +config/ +├── schema.ts # Main Zod schema (455 lines) - agents, categories, experimental features +├── schema.test.ts # Schema validation tests (17909 lines) +└── index.ts # Barrel export +``` + +## SCHEMA COMPONENTS + +**Agent Configuration:** +- `AgentOverrideConfigSchema`: Model, variant, temperature, permissions, tools +- `AgentOverridesSchema`: Per-agent overrides (sisyphus, hephaestus, prometheus, etc.) +- `AgentPermissionSchema`: Tool access control (edit, bash, webfetch, task) + +**Category Configuration:** +- `CategoryConfigSchema`: Model defaults, thinking budgets, tool restrictions +- `CategoriesConfigSchema`: Named categories (visual-engineering, ultrabrain, deep, etc.) + +**Experimental Features:** +- `ExperimentalConfigSchema`: Dynamic context pruning, task system, plugin timeouts +- `DynamicContextPruningConfigSchema`: Intelligent context management + +**Built-in Enums:** +- `AgentNameSchema`: sisyphus, hephaestus, prometheus, oracle, librarian, explore, multimodal-looker, metis, momus, atlas +- `HookNameSchema`: 100+ hook names for lifecycle management +- `BuiltinCommandNameSchema`: init-deep, ralph-loop, refactor, start-work +- `BuiltinSkillNameSchema`: playwright, agent-browser, git-master + +## CONFIGURATION HIERARCHY + +1. **Project config** (`.opencode/oh-my-opencode.json`) +2. **User config** (`~/.config/opencode/oh-my-opencode.json`) +3. **Defaults** (hardcoded fallbacks) + +**Multi-level inheritance:** Project → User → Defaults + +## VALIDATION FEATURES + +- **JSONC support**: Comments and trailing commas +- **Type safety**: Full TypeScript inference +- **Migration support**: Legacy config compatibility +- **Schema versioning**: $schema field for validation + +## KEY SCHEMAS + +| Schema | Purpose | Lines | +|--------|---------|-------| +| `OhMyOpenCodeConfigSchema` | Root config schema | 400+ | +| `AgentOverrideConfigSchema` | Agent customization | 50+ | +| `CategoryConfigSchema` | Task category defaults | 30+ | +| `ExperimentalConfigSchema` | Beta features | 40+ | + +## USAGE PATTERNS + +**Agent Override:** +```typescript +agents: { + sisyphus: { + model: "anthropic/claude-opus-4-6", + variant: "max", + temperature: 0.1 + } +} +``` + +**Category Definition:** +```typescript +categories: { + "visual-engineering": { + model: "google/gemini-3-pro", + variant: "high" + } +} +``` + +**Experimental Features:** +```typescript +experimental: { + dynamic_context_pruning: { + enabled: true, + notification: "detailed" + } +} +``` diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 59155c47f..738d52591 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -2,61 +2,29 @@ ## OVERVIEW -17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management. - -**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities +Background agents, skills, Claude Code compat, builtin commands, MCP managers, etc. ## STRUCTURE -``` features/ -├── background-agent/ # Task lifecycle (1556 lines) -│ ├── manager.ts # Launch → poll → complete -│ └── concurrency.ts # Per-provider limits -├── builtin-skills/ # Core skills -│ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser -├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation -├── claude-code-agent-loader/ # ~/.claude/agents/*.md -├── claude-code-command-loader/ # ~/.claude/commands/*.md -├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion -├── claude-code-plugin-loader/ # installed_plugins.json (486 lines) -├── claude-code-session-state/ # Session persistence -├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines) -├── context-injector/ # AGENTS.md/README.md injection -├── boulder-state/ # Todo state persistence -├── hook-message-injector/ # Message injection -├── task-toast-manager/ # Background task notifications -├── skill-mcp-manager/ # MCP client lifecycle (640 lines) -├── tmux-subagent/ # Tmux session management (472 lines) -├── mcp-oauth/ # MCP OAuth handling -└── claude-tasks/ # Task schema/storage - see AGENTS.md -``` +├── background-agent/ # Task lifecycle, concurrency (manager.ts 1642 lines) +├── builtin-skills/ # Skills like git-master (1107 lines) +├── builtin-commands/ # Commands like refactor (619 lines) +├── skill-mcp-manager/ # MCP client lifecycle (640 lines) +├── claude-code-plugin-loader/ # Plugin loading +├── claude-code-mcp-loader/ # MCP loading +├── claude-code-session-state/ # Session state +├── claude-code-command-loader/ # Command loading +├── claude-code-agent-loader/ # Agent loading +├── context-injector/ # Context injection +├── hook-message-injector/ # Message injection +├── task-toast-manager/ # Task toasts +├── boulder-state/ # State management +├── tmux-subagent/ # Tmux subagent +├── mcp-oauth/ # OAuth for MCP +├── opencode-skill-loader/ # Skill loading +├── tool-metadata-store/ # Tool metadata -## LOADER PRIORITY +## HOW TO ADD -| Type | Priority (highest first) | -|------|--------------------------| -| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` | -| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` | -| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` | - -## BACKGROUND AGENT - -- **Lifecycle**: `launch` → `poll` (2s) → `complete` -- **Stability**: 3 consecutive polls = idle -- **Concurrency**: Per-provider/model limits via `ConcurrencyManager` -- **Cleanup**: 30m TTL, 3m stale timeout -- **State**: Per-session Maps, cleaned on `session.deleted` - -## SKILL MCP - -- **Lazy**: Clients created on first call -- **Transports**: stdio, http (SSE/Streamable) -- **Lifecycle**: 5m idle cleanup - -## ANTI-PATTERNS - -- **Sequential delegation**: Use `task` parallel -- **Trust self-reports**: ALWAYS verify -- **Main thread blocks**: No heavy I/O in loader init -- **Direct state mutation**: Use managers for boulder/session state +Create dir with index.ts, types.ts, etc. diff --git a/src/features/background-agent/spawner/background-session-creator.ts b/src/features/background-agent/spawner/background-session-creator.ts new file mode 100644 index 000000000..1088602ce --- /dev/null +++ b/src/features/background-agent/spawner/background-session-creator.ts @@ -0,0 +1,46 @@ +import type { OpencodeClient } from "../constants" +import type { ConcurrencyManager } from "../concurrency" +import type { LaunchInput } from "../types" +import { log } from "../../../shared" + +export async function createBackgroundSession(options: { + client: OpencodeClient + input: LaunchInput + parentDirectory: string + concurrencyManager: ConcurrencyManager + concurrencyKey: string +}): Promise { + const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options + + const body = { + parentID: input.parentSessionID, + title: `Background: ${input.description}`, + permission: [{ permission: "question", action: "deny" as const, pattern: "*" }], + } + + const createResult = await client.session + .create({ + body, + query: { + directory: parentDirectory, + }, + }) + .catch((error) => { + concurrencyManager.release(concurrencyKey) + throw error + }) + + if (createResult.error) { + concurrencyManager.release(concurrencyKey) + throw new Error(`Failed to create background session: ${createResult.error}`) + } + + if (!createResult.data?.id) { + concurrencyManager.release(concurrencyKey) + throw new Error("Failed to create background session: API returned no session ID") + } + + const sessionID = createResult.data.id + log("[background-agent] Background session created", { sessionID }) + return sessionID +} diff --git a/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts new file mode 100644 index 000000000..7165877cc --- /dev/null +++ b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts @@ -0,0 +1,7 @@ +import type { LaunchInput } from "../types" + +export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string { + return input.model + ? `${input.model.providerID}/${input.model.modelID}` + : input.agent +} diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts new file mode 100644 index 000000000..8894f33ac --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -0,0 +1,21 @@ +import type { OpencodeClient } from "../constants" +import { log } from "../../../shared" + +export async function resolveParentDirectory(options: { + client: OpencodeClient + parentSessionID: string + defaultDirectory: string +}): Promise { + const { client, parentSessionID, defaultDirectory } = options + + const parentSession = await client.session + .get({ path: { id: parentSessionID } }) + .catch((error) => { + log(`[background-agent] Failed to get parent session: ${error}`) + return null + }) + + const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + return parentDirectory +} diff --git a/src/features/background-agent/spawner/tmux-callback-invoker.ts b/src/features/background-agent/spawner/tmux-callback-invoker.ts new file mode 100644 index 000000000..ed4878b14 --- /dev/null +++ b/src/features/background-agent/spawner/tmux-callback-invoker.ts @@ -0,0 +1,39 @@ +import type { OnSubagentSessionCreated } from "../constants" +import { TMUX_CALLBACK_DELAY_MS } from "../constants" +import { log } from "../../../shared" +import { isInsideTmux } from "../../../shared/tmux" + +export async function maybeInvokeTmuxCallback(options: { + onSubagentSessionCreated?: OnSubagentSessionCreated + tmuxEnabled: boolean + sessionID: string + parentID: string + title: string +}): Promise { + const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options + + log("[background-agent] tmux callback check", { + hasCallback: !!onSubagentSessionCreated, + tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID, + }) + + if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) { + log("[background-agent] SKIP tmux callback - conditions not met") + return + } + + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await onSubagentSessionCreated({ + sessionID, + parentID, + title, + }).catch((error) => { + log("[background-agent] Failed to spawn tmux pane:", error) + }) + + log("[background-agent] tmux callback completed, waiting") + await new Promise((resolve) => setTimeout(resolve, TMUX_CALLBACK_DELAY_MS)) +} diff --git a/src/features/claude-tasks/session-storage.test.ts b/src/features/claude-tasks/session-storage.test.ts new file mode 100644 index 000000000..7b3e5a5ba --- /dev/null +++ b/src/features/claude-tasks/session-storage.test.ts @@ -0,0 +1,204 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from "fs" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { + getSessionTaskDir, + listSessionTaskFiles, + listAllSessionDirs, + findTaskAcrossSessions, +} from "./session-storage" + +const TEST_DIR = ".test-session-storage" +const TEST_DIR_ABS = join(process.cwd(), TEST_DIR) + +function makeConfig(storagePath: string): Partial { + return { + sisyphus: { + tasks: { storage_path: storagePath, claude_code_compat: false }, + }, + } +} + +describe("getSessionTaskDir", () => { + test("returns session-scoped subdirectory under base task dir", () => { + //#given + const config = makeConfig("/tmp/tasks") + const sessionID = "ses_abc123" + + //#when + const result = getSessionTaskDir(config, sessionID) + + //#then + expect(result).toBe("/tmp/tasks/ses_abc123") + }) + + test("uses relative storage path joined with cwd", () => { + //#given + const config = makeConfig(TEST_DIR) + const sessionID = "ses_xyz" + + //#when + const result = getSessionTaskDir(config, sessionID) + + //#then + expect(result).toBe(join(TEST_DIR_ABS, "ses_xyz")) + }) +}) + +describe("listSessionTaskFiles", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("returns empty array when session directory does not exist", () => { + //#given + const config = makeConfig(TEST_DIR) + + //#when + const result = listSessionTaskFiles(config, "nonexistent-session") + + //#then + expect(result).toEqual([]) + }) + + test("lists only T-*.json files in the session directory", () => { + //#given + const config = makeConfig(TEST_DIR) + const sessionDir = join(TEST_DIR_ABS, "ses_001") + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, "T-aaa.json"), "{}", "utf-8") + writeFileSync(join(sessionDir, "T-bbb.json"), "{}", "utf-8") + writeFileSync(join(sessionDir, "other.txt"), "nope", "utf-8") + + //#when + const result = listSessionTaskFiles(config, "ses_001") + + //#then + expect(result).toHaveLength(2) + expect(result).toContain("T-aaa") + expect(result).toContain("T-bbb") + }) + + test("does not list tasks from other sessions", () => { + //#given + const config = makeConfig(TEST_DIR) + const session1Dir = join(TEST_DIR_ABS, "ses_001") + const session2Dir = join(TEST_DIR_ABS, "ses_002") + mkdirSync(session1Dir, { recursive: true }) + mkdirSync(session2Dir, { recursive: true }) + writeFileSync(join(session1Dir, "T-from-s1.json"), "{}", "utf-8") + writeFileSync(join(session2Dir, "T-from-s2.json"), "{}", "utf-8") + + //#when + const result = listSessionTaskFiles(config, "ses_001") + + //#then + expect(result).toEqual(["T-from-s1"]) + }) +}) + +describe("listAllSessionDirs", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("returns empty array when base directory does not exist", () => { + //#given + const config = makeConfig(TEST_DIR) + + //#when + const result = listAllSessionDirs(config) + + //#then + expect(result).toEqual([]) + }) + + test("returns only directory entries (not files)", () => { + //#given + const config = makeConfig(TEST_DIR) + mkdirSync(TEST_DIR_ABS, { recursive: true }) + mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true }) + mkdirSync(join(TEST_DIR_ABS, "ses_002"), { recursive: true }) + writeFileSync(join(TEST_DIR_ABS, ".lock"), "{}", "utf-8") + writeFileSync(join(TEST_DIR_ABS, "T-legacy.json"), "{}", "utf-8") + + //#when + const result = listAllSessionDirs(config) + + //#then + expect(result).toHaveLength(2) + expect(result).toContain("ses_001") + expect(result).toContain("ses_002") + }) +}) + +describe("findTaskAcrossSessions", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("returns null when task does not exist in any session", () => { + //#given + const config = makeConfig(TEST_DIR) + mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true }) + + //#when + const result = findTaskAcrossSessions(config, "T-nonexistent") + + //#then + expect(result).toBeNull() + }) + + test("finds task in the correct session directory", () => { + //#given + const config = makeConfig(TEST_DIR) + const session2Dir = join(TEST_DIR_ABS, "ses_002") + mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true }) + mkdirSync(session2Dir, { recursive: true }) + writeFileSync(join(session2Dir, "T-target.json"), '{"id":"T-target"}', "utf-8") + + //#when + const result = findTaskAcrossSessions(config, "T-target") + + //#then + expect(result).not.toBeNull() + expect(result!.sessionID).toBe("ses_002") + expect(result!.path).toBe(join(session2Dir, "T-target.json")) + }) + + test("returns null when base directory does not exist", () => { + //#given + const config = makeConfig(TEST_DIR) + + //#when + const result = findTaskAcrossSessions(config, "T-any") + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/features/claude-tasks/session-storage.ts b/src/features/claude-tasks/session-storage.ts new file mode 100644 index 000000000..749f9c1bd --- /dev/null +++ b/src/features/claude-tasks/session-storage.ts @@ -0,0 +1,52 @@ +import { join } from "path" +import { existsSync, readdirSync, statSync } from "fs" +import { getTaskDir } from "./storage" +import type { OhMyOpenCodeConfig } from "../../config/schema" + +export function getSessionTaskDir( + config: Partial, + sessionID: string, +): string { + return join(getTaskDir(config), sessionID) +} + +export function listSessionTaskFiles( + config: Partial, + sessionID: string, +): string[] { + const dir = getSessionTaskDir(config, sessionID) + if (!existsSync(dir)) return [] + return readdirSync(dir) + .filter((f) => f.endsWith(".json") && f.startsWith("T-")) + .map((f) => f.replace(".json", "")) +} + +export function listAllSessionDirs( + config: Partial, +): string[] { + const baseDir = getTaskDir(config) + if (!existsSync(baseDir)) return [] + return readdirSync(baseDir).filter((entry) => { + const fullPath = join(baseDir, entry) + return statSync(fullPath).isDirectory() + }) +} + +export interface TaskLocation { + path: string + sessionID: string +} + +export function findTaskAcrossSessions( + config: Partial, + taskId: string, +): TaskLocation | null { + const sessionDirs = listAllSessionDirs(config) + for (const sessionID of sessionDirs) { + const taskPath = join(getSessionTaskDir(config, sessionID), `${taskId}.json`) + if (existsSync(taskPath)) { + return { path: taskPath, sessionID } + } + } + return null +} diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md new file mode 100644 index 000000000..74dcf9a77 --- /dev/null +++ b/src/plugin-handlers/AGENTS.md @@ -0,0 +1,96 @@ +**Generated:** 2026-02-08T16:45:00+09:00 +**Commit:** f2b7b759 +**Branch:** dev + +## OVERVIEW + +Plugin component loading and configuration orchestration. 500+ lines of config merging, migration, and component discovery for Claude Code compatibility. + +## STRUCTURE +``` +plugin-handlers/ +├── config-handler.ts # Main config orchestrator (563 lines) - agent/skill/command loading +├── config-handler.test.ts # Config handler tests (34426 lines) +├── plan-model-inheritance.ts # Plan agent model inheritance logic (657 lines) +├── plan-model-inheritance.test.ts # Inheritance tests (3696 lines) +└── index.ts # Barrel export +``` + +## CORE FUNCTIONS + +**Config Handler (`createConfigHandler`):** +- Loads all plugin components (agents, skills, commands, MCPs) +- Applies permission migrations for compatibility +- Merges user/project/global configurations +- Handles Claude Code plugin integration + +**Plan Model Inheritance:** +- Demotes plan agent to prometheus when planner enabled +- Preserves user overrides during migration +- Handles model/variant inheritance from categories + +## LOADING PHASES + +1. **Plugin Discovery**: Load Claude Code plugins with timeout protection +2. **Component Loading**: Parallel loading of agents, skills, commands +3. **Config Merging**: User → Project → Global → Defaults +4. **Migration**: Legacy config format compatibility +5. **Permission Application**: Tool access control per agent + +## KEY FEATURES + +**Parallel Loading:** +- Concurrent discovery of user/project/global components +- Timeout protection for plugin loading (default: 10s) +- Error isolation (failed plugins don't break others) + +**Migration Support:** +- Agent name mapping (old → new names) +- Permission format conversion +- Config structure updates + +**Claude Code Integration:** +- Plugin component loading +- MCP server discovery +- Agent/skill/command compatibility + +## CONFIGURATION FLOW + +``` +User Config → Migration → Merging → Validation → Agent Creation → Permission Application +``` + +## TESTING COVERAGE + +- **Config Handler**: 34426 lines of tests +- **Plan Inheritance**: 3696 lines of tests +- **Migration Logic**: Legacy compatibility verification +- **Parallel Loading**: Timeout and error handling + +## USAGE PATTERNS + +**Config Handler Creation:** +```typescript +const handler = createConfigHandler({ + ctx: { directory: projectDir }, + pluginConfig: userConfig, + modelCacheState: cache +}); +``` + +**Plan Demotion:** +```typescript +const demotedPlan = buildPlanDemoteConfig( + prometheusConfig, + userPlanOverrides +); +``` + +**Component Loading:** +```typescript +const [agents, skills, commands] = await Promise.all([ + loadUserAgents(), + loadProjectSkills(), + loadGlobalCommands() +]); +``` From c6fafd6624f1895dce150b38b34847d6faf4de39 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:49:22 +0900 Subject: [PATCH 03/12] fix: remove task-continuation-enforcer and restore task tool titles --- src/config/schema.ts | 1 - src/hooks/index.ts | 1 - src/hooks/task-continuation-enforcer.test.ts | 763 ------------------- src/hooks/task-continuation-enforcer.ts | 530 ------------- src/index.ts | 14 +- src/shared/system-directive.ts | 1 - src/tools/delegate-task/executor.ts | 41 +- src/tools/delegate-task/types.ts | 4 + 8 files changed, 31 insertions(+), 1324 deletions(-) delete mode 100644 src/hooks/task-continuation-enforcer.test.ts delete mode 100644 src/hooks/task-continuation-enforcer.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index e7cb30aea..34ec376be 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -59,7 +59,6 @@ export const AgentNameSchema = BuiltinAgentNameSchema export const HookNameSchema = z.enum([ "todo-continuation-enforcer", - "task-continuation-enforcer", "context-window-monitor", "session-recovery", "session-notification", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eceadff5c..a964780c7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,4 @@ export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; -export { createTaskContinuationEnforcer, type TaskContinuationEnforcer } from "./task-continuation-enforcer"; export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createSessionNotification } from "./session-notification"; export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery"; diff --git a/src/hooks/task-continuation-enforcer.test.ts b/src/hooks/task-continuation-enforcer.test.ts deleted file mode 100644 index 1a0cbc75d..000000000 --- a/src/hooks/task-continuation-enforcer.test.ts +++ /dev/null @@ -1,763 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" - -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import { BackgroundManager } from "../features/background-agent" -import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" -import type { OhMyOpenCodeConfig } from "../config/schema" -import { TaskObjectSchema } from "../tools/task/types" -import type { TaskObject } from "../tools/task/types" -import { createTaskContinuationEnforcer } from "./task-continuation-enforcer" - -type TimerCallback = (...args: any[]) => void - -interface FakeTimers { - advanceBy: (ms: number, advanceClock?: boolean) => Promise - restore: () => void -} - -function createFakeTimers(): FakeTimers { - const originalNow = Date.now() - let clockNow = originalNow - let timerNow = 0 - let nextId = 1 - const timers = new Map() - const cleared = new Set() - - const original = { - setTimeout: globalThis.setTimeout, - clearTimeout: globalThis.clearTimeout, - setInterval: globalThis.setInterval, - clearInterval: globalThis.clearInterval, - dateNow: Date.now, - } - - const normalizeDelay = (delay?: number) => { - if (typeof delay !== "number" || !Number.isFinite(delay)) return 0 - return delay < 0 ? 0 : delay - } - - const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => { - const id = nextId++ - timers.set(id, { - id, - time: timerNow + normalizeDelay(delay), - interval, - callback, - args, - }) - return id - } - - const clear = (id: number | undefined) => { - if (typeof id !== "number") return - cleared.add(id) - timers.delete(id) - } - - globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => { - return schedule(callback, delay, null, args) as unknown as ReturnType - }) as typeof setTimeout - - globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => { - const interval = normalizeDelay(delay) - return schedule(callback, delay, interval, args) as unknown as ReturnType - }) as typeof setInterval - - globalThis.clearTimeout = ((id?: number) => { - clear(id) - }) as typeof clearTimeout - - globalThis.clearInterval = ((id?: number) => { - clear(id) - }) as typeof clearInterval - - Date.now = () => clockNow - - const advanceBy = async (ms: number, advanceClock: boolean = false) => { - const clamped = Math.max(0, ms) - const target = timerNow + clamped - if (advanceClock) { - clockNow += clamped - } - while (true) { - let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined - for (const timer of timers.values()) { - if (timer.time <= target && (!next || timer.time < next.time)) { - next = timer - } - } - if (!next) break - - timerNow = next.time - timers.delete(next.id) - next.callback(...next.args) - - if (next.interval !== null && !cleared.has(next.id)) { - timers.set(next.id, { - id: next.id, - time: timerNow + next.interval, - interval: next.interval, - callback: next.callback, - args: next.args, - }) - } else { - cleared.delete(next.id) - } - - await Promise.resolve() - } - timerNow = target - await Promise.resolve() - } - - const restore = () => { - globalThis.setTimeout = original.setTimeout - globalThis.clearTimeout = original.clearTimeout - globalThis.setInterval = original.setInterval - globalThis.clearInterval = original.clearInterval - Date.now = original.dateNow - } - - return { advanceBy, restore } -} - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -describe("task-continuation-enforcer", () => { - let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }> - let toastCalls: Array<{ title: string; message: string }> - let fakeTimers: FakeTimers - let taskDir: string - - interface MockMessage { - info: { - id: string - role: "user" | "assistant" - error?: { name: string; data?: { message: string } } - } - } - - let mockMessages: MockMessage[] = [] - - function createMockPluginInput() { - return { - client: { - session: { - messages: async () => ({ data: mockMessages }), - prompt: async (opts: any) => { - promptCalls.push({ - sessionID: opts.path.id, - agent: opts.body.agent, - model: opts.body.model, - text: opts.body.parts[0].text, - }) - return {} - }, - }, - tui: { - showToast: async (opts: any) => { - toastCalls.push({ - title: opts.body.title, - message: opts.body.message, - }) - return {} - }, - }, - }, - directory: "/tmp/test", - } as any - } - - function createTempTaskDir(): string { - return mkdtempSync(join(tmpdir(), "omo-task-continuation-")) - } - - function writeTaskFile(dir: string, task: TaskObject): void { - const parsed = TaskObjectSchema.safeParse(task) - expect(parsed.success).toBe(true) - if (!parsed.success) return - writeFileSync(join(dir, `${parsed.data.id}.json`), JSON.stringify(parsed.data), "utf-8") - } - - function writeCorruptedTaskFile(dir: string, taskId: string): void { - writeFileSync(join(dir, `${taskId}.json`), "{ this is not valid json", "utf-8") - } - - function createConfig(dir: string): Partial { - return { - sisyphus: { - tasks: { - claude_code_compat: true, - storage_path: dir, - }, - }, - } - } - - function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager { - return { - getTasksByParentSession: () => (runningTasks ? [{ status: "running" }] : []), - } as any - } - - beforeEach(() => { - fakeTimers = createFakeTimers() - _resetForTesting() - promptCalls = [] - toastCalls = [] - mockMessages = [] - taskDir = createTempTaskDir() - }) - - afterEach(() => { - fakeTimers.restore() - _resetForTesting() - rmSync(taskDir, { recursive: true, force: true }) - }) - - test("should inject continuation when idle with incomplete tasks on disk", async () => { - fakeTimers.restore() - // given - main session with incomplete tasks - const sessionID = "main-123" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - writeTaskFile(taskDir, { - id: "T-2", - subject: "Task 2", - description: "", - status: "completed", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - backgroundManager: new BackgroundManager(createMockPluginInput()), - }) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // then - countdown toast shown - await wait(50) - expect(toastCalls.length).toBeGreaterThanOrEqual(1) - expect(toastCalls[0].title).toBe("Task Continuation") - - // then - after countdown, continuation injected - await wait(2500) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].text).toContain("TASK CONTINUATION") - }, { timeout: 15000 }) - - test("should NOT inject when all tasks are completed", async () => { - // given - session with all tasks completed - const sessionID = "main-456" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "completed", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - no continuation injected - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when all tasks are deleted", async () => { - // given - session with all tasks deleted - const sessionID = "main-deleted" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "deleted", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when no task files exist", async () => { - // given - empty task directory - const sessionID = "main-none" - setMainSession(sessionID) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when background tasks are running", async () => { - // given - session with incomplete tasks and running background tasks - const sessionID = "main-bg-running" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - backgroundManager: createMockBackgroundManager(true), - }) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject for non-main session", async () => { - // given - main session set, different session goes idle - setMainSession("main-session") - const otherSession = "other-session" - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should inject for background task session (subagent)", async () => { - fakeTimers.restore() - // given - main session set, background task session registered - setMainSession("main-session") - const bgTaskSession = "bg-task-session" - subagentSessions.add(bgTaskSession) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: bgTaskSession } } }) - - // then - await wait(2500) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].sessionID).toBe(bgTaskSession) - }, { timeout: 15000 }) - - test("should cancel countdown on user message after grace period", async () => { - // given - const sessionID = "main-cancel" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // when - wait past grace period (500ms), then user sends message - await fakeTimers.advanceBy(600, true) - await hook.handler({ - event: { - type: "message.updated", - properties: { info: { sessionID, role: "user" } }, - }, - }) - - // then - await fakeTimers.advanceBy(2500) - expect(promptCalls).toHaveLength(0) - }) - - test("should ignore user message within grace period", async () => { - fakeTimers.restore() - // given - const sessionID = "main-grace" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await hook.handler({ - event: { - type: "message.updated", - properties: { info: { sessionID, role: "user" } }, - }, - }) - - // then - countdown should continue - await wait(2500) - expect(promptCalls).toHaveLength(1) - }, { timeout: 15000 }) - - test("should cancel countdown on assistant activity", async () => { - // given - const sessionID = "main-assistant" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ - event: { - type: "message.part.updated", - properties: { info: { sessionID, role: "assistant" } }, - }, - }) - - // then - await fakeTimers.advanceBy(3000) - expect(promptCalls).toHaveLength(0) - }) - - test("should cancel countdown on tool execution", async () => { - // given - const sessionID = "main-tool" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ event: { type: "tool.execute.before", properties: { sessionID } } }) - - // then - await fakeTimers.advanceBy(3000) - expect(promptCalls).toHaveLength(0) - }) - - test("should skip injection during recovery mode", async () => { - // given - const sessionID = "main-recovery" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - hook.markRecovering(sessionID) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should inject after recovery complete", async () => { - fakeTimers.restore() - // given - const sessionID = "main-recovery-done" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - hook.markRecovering(sessionID) - hook.markRecoveryComplete(sessionID) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // then - await wait(3000) - expect(promptCalls.length).toBe(1) - }, { timeout: 15000 }) - - test("should cleanup on session deleted", async () => { - // given - const sessionID = "main-delete" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should skip when last assistant message was aborted (API fallback)", async () => { - // given - const sessionID = "main-api-abort" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - mockMessages = [ - { info: { id: "msg-1", role: "user" } }, - { info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "aborted" } } } }, - ] - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should skip when abort detected via session.error event", async () => { - // given - const sessionID = "main-event-abort" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - mockMessages = [ - { info: { id: "msg-1", role: "user" } }, - { info: { id: "msg-2", role: "assistant" } }, - ] - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - abort error event fires - await hook.handler({ - event: { - type: "session.error", - properties: { sessionID, error: { name: "MessageAbortedError" } }, - }, - }) - - // when - session goes idle immediately after - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should handle corrupted task files gracefully (readJsonSafe returns null)", async () => { - fakeTimers.restore() - // given - const sessionID = "main-corrupt" - setMainSession(sessionID) - - writeCorruptedTaskFile(taskDir, "T-corrupt") - writeTaskFile(taskDir, { - id: "T-ok", - subject: "Task OK", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await wait(2500) - - // then - expect(promptCalls).toHaveLength(1) - }, { timeout: 15000 }) - - test("should NOT inject when isContinuationStopped returns true", async () => { - // given - const sessionID = "main-stopped" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - isContinuationStopped: (id) => id === sessionID, - }) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should cancel all countdowns via cancelAllCountdowns", async () => { - // given - const sessionID = "main-cancel-all" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - hook.cancelAllCountdowns() - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) -}) diff --git a/src/hooks/task-continuation-enforcer.ts b/src/hooks/task-continuation-enforcer.ts deleted file mode 100644 index f3b7f9c54..000000000 --- a/src/hooks/task-continuation-enforcer.ts +++ /dev/null @@ -1,530 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" - -import type { BackgroundManager } from "../features/background-agent" -import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state" -import { - findNearestMessageWithFields, - MESSAGE_STORAGE, - type ToolPermission, -} from "../features/hook-message-injector" -import { listTaskFiles, readJsonSafe, getTaskDir } from "../features/claude-tasks/storage" -import type { OhMyOpenCodeConfig } from "../config/schema" -import { TaskObjectSchema } from "../tools/task/types" -import type { TaskObject } from "../tools/task/types" -import { log } from "../shared/logger" -import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" - -const HOOK_NAME = "task-continuation-enforcer" - -const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] - -export interface TaskContinuationEnforcerOptions { - backgroundManager?: BackgroundManager - skipAgents?: string[] - isContinuationStopped?: (sessionID: string) => boolean -} - -export interface TaskContinuationEnforcer { - handler: (input: { event: { type: string; properties?: unknown } }) => Promise - markRecovering: (sessionID: string) => void - markRecoveryComplete: (sessionID: string) => void - cancelAllCountdowns: () => void -} - -interface SessionState { - countdownTimer?: ReturnType - countdownInterval?: ReturnType - isRecovering?: boolean - countdownStartedAt?: number - abortDetectedAt?: number -} - -const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TASK_CONTINUATION)} - -Incomplete tasks remain in your task list. Continue working on the next pending task. - -- Proceed without asking for permission -- Mark each task complete when finished -- Do not stop until all tasks are done` - -const COUNTDOWN_SECONDS = 2 -const TOAST_DURATION_MS = 900 -const COUNTDOWN_GRACE_PERIOD_MS = 500 - -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 getIncompleteCount(tasks: TaskObject[]): number { - return tasks.filter(t => t.status !== "completed" && t.status !== "deleted").length -} - -interface MessageInfo { - id?: string - role?: string - error?: { name?: string; data?: unknown } -} - -function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean { - if (!messages || messages.length === 0) return false - - const assistantMessages = messages.filter(m => m.info?.role === "assistant") - if (assistantMessages.length === 0) return false - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - const errorName = lastAssistant.info?.error?.name - - if (!errorName) return false - - return errorName === "MessageAbortedError" || errorName === "AbortError" -} - -function loadTasksFromDisk(config: Partial): TaskObject[] { - const taskIds = listTaskFiles(config) - const taskDirectory = getTaskDir(config) - const tasks: TaskObject[] = [] - - for (const id of taskIds) { - const task = readJsonSafe(join(taskDirectory, `${id}.json`), TaskObjectSchema) - if (task) tasks.push(task) - } - - return tasks -} - -export function createTaskContinuationEnforcer( - ctx: PluginInput, - config: Partial, - options: TaskContinuationEnforcerOptions = {} -): TaskContinuationEnforcer { - const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options - const sessions = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function cancelCountdown(sessionID: string): void { - const state = sessions.get(sessionID) - if (!state) return - - if (state.countdownTimer) { - clearTimeout(state.countdownTimer) - state.countdownTimer = undefined - } - if (state.countdownInterval) { - clearInterval(state.countdownInterval) - state.countdownInterval = undefined - } - state.countdownStartedAt = undefined - } - - function cleanup(sessionID: string): void { - cancelCountdown(sessionID) - sessions.delete(sessionID) - } - - const markRecovering = (sessionID: string): void => { - const state = getState(sessionID) - state.isRecovering = true - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) - } - - const markRecoveryComplete = (sessionID: string): void => { - const state = sessions.get(sessionID) - if (state) { - state.isRecovering = false - log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) - } - } - - async function showCountdownToast(seconds: number, incompleteCount: number): Promise { - await ctx.client.tui - .showToast({ - body: { - title: "Task Continuation", - message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, - variant: "warning" as const, - duration: TOAST_DURATION_MS, - }, - }) - .catch(() => {}) - } - - interface ResolvedMessageInfo { - agent?: string - model?: { providerID: string; modelID: string } - tools?: Record - } - - async function injectContinuation( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): Promise { - const state = sessions.get(sessionID) - - if (state?.isRecovering) { - log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - const tasks = loadTasksFromDisk(config) - const freshIncompleteCount = getIncompleteCount(tasks) - if (freshIncompleteCount === 0) { - log(`[${HOOK_NAME}] Skipped injection: no incomplete tasks`, { sessionID }) - return - } - - let agentName = resolvedInfo?.agent - let model = resolvedInfo?.model - let tools = resolvedInfo?.tools - - if (!agentName || !model) { - const messageDir = getMessageDir(sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agentName = agentName ?? prevMessage?.agent - model = - model ?? - (prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}), - } - : undefined) - tools = tools ?? prevMessage?.tools - } - - if (agentName && skipAgents.includes(agentName)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) - return - } - - const editPermission = tools?.edit - const writePermission = tools?.write - const hasWritePermission = - !tools || - (editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny") - if (!hasWritePermission) { - log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) - return - } - - const incompleteTasks = tasks.filter(t => t.status !== "completed" && t.status !== "deleted") - const taskList = incompleteTasks.map(t => `- [${t.status}] ${t.subject}`).join("\n") - const prompt = `${CONTINUATION_PROMPT} - -[Status: ${tasks.length - freshIncompleteCount}/${tasks.length} completed, ${freshIncompleteCount} remaining] - -Remaining tasks: -${taskList}` - - try { - log(`[${HOOK_NAME}] Injecting continuation`, { - sessionID, - agent: agentName, - model, - incompleteCount: freshIncompleteCount, - }) - - await ctx.client.session.prompt({ - path: { id: sessionID }, - body: { - agent: agentName, - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - log(`[${HOOK_NAME}] Injection successful`, { sessionID }) - } catch (err) { - log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) }) - } - } - - function startCountdown( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): void { - const state = getState(sessionID) - cancelCountdown(sessionID) - - let secondsRemaining = COUNTDOWN_SECONDS - showCountdownToast(secondsRemaining, incompleteCount) - state.countdownStartedAt = Date.now() - - state.countdownInterval = setInterval(() => { - secondsRemaining-- - if (secondsRemaining > 0) { - showCountdownToast(secondsRemaining, incompleteCount) - } - }, 1000) - - state.countdownTimer = setTimeout(() => { - cancelCountdown(sessionID) - injectContinuation(sessionID, incompleteCount, total, resolvedInfo) - }, COUNTDOWN_SECONDS * 1000) - - log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount }) - } - - const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const error = props?.error as { name?: string } | undefined - if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { - const state = getState(sessionID) - state.abortDetectedAt = Date.now() - log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) - } - - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] session.error`, { sessionID }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { - log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - // Check 1: Event-based abort detection (primary, most reliable) - if (state.abortDetectedAt) { - const timeSinceAbort = Date.now() - state.abortDetectedAt - const ABORT_WINDOW_MS = 3000 - if (timeSinceAbort < ABORT_WINDOW_MS) { - log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) - state.abortDetectedAt = undefined - return - } - state.abortDetectedAt = undefined - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - // Check 2: API-based abort detection (fallback, for cases where event was missed) - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] - - if (isLastAssistantMessageAborted(messages)) { - log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) }) - } - - const tasks = loadTasksFromDisk(config) - - if (!tasks || tasks.length === 0) { - log(`[${HOOK_NAME}] No tasks`, { sessionID }) - return - } - - const incompleteCount = getIncompleteCount(tasks) - if (incompleteCount === 0) { - log(`[${HOOK_NAME}] All tasks complete`, { sessionID, total: tasks.length }) - return - } - - let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { - agent?: string - model?: { providerID: string; modelID: string } - modelID?: string - providerID?: string - tools?: Record - } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { - hasCompactionMessage = true - continue - } - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resolvedInfo = { - agent: info.agent, - model: - info.model ?? - (info.providerID && info.modelID - ? { providerID: info.providerID, modelID: info.modelID } - : undefined), - tools: info.tools, - } - break - } - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) - } - - log(`[${HOOK_NAME}] Agent check`, { - sessionID, - agentName: resolvedInfo?.agent, - skipAgents, - hasCompactionMessage, - }) - if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) - return - } - if (hasCompactionMessage && !resolvedInfo?.agent) { - log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) - return - } - - if (isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - startCountdown(sessionID, incompleteCount, tasks.length, resolvedInfo) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (!sessionID) return - - if (role === "user") { - const state = sessions.get(sessionID) - if (state?.countdownStartedAt) { - const elapsed = Date.now() - state.countdownStartedAt - if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { - log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) - return - } - } - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - - if (role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - } - - const cancelAllCountdowns = (): void => { - for (const sessionID of sessions.keys()) { - cancelCountdown(sessionID) - } - log(`[${HOOK_NAME}] All countdowns cancelled`) - } - - return { - handler, - markRecovering, - markRecoveryComplete, - cancelAllCountdowns, - } -} diff --git a/src/index.ts b/src/index.ts index f00f0c217..20048cfd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"; import { createTodoContinuationEnforcer, - createTaskContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, createSessionNotification, @@ -541,21 +540,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false; - const taskContinuationEnforcer = isHookEnabled("task-continuation-enforcer") && taskSystemEnabled - ? createTaskContinuationEnforcer(ctx, pluginConfig, { - backgroundManager, - isContinuationStopped: stopContinuationGuard?.isStopped, - }) - : null; - - if (sessionRecovery && (todoContinuationEnforcer || taskContinuationEnforcer)) { + if (sessionRecovery && todoContinuationEnforcer) { sessionRecovery.setOnAbortCallback((sessionID) => { todoContinuationEnforcer?.markRecovering(sessionID); - taskContinuationEnforcer?.markRecovering(sessionID); }); sessionRecovery.setOnRecoveryCompleteCallback((sessionID) => { todoContinuationEnforcer?.markRecoveryComplete(sessionID); - taskContinuationEnforcer?.markRecoveryComplete(sessionID); }); } @@ -742,7 +732,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await backgroundNotificationHook?.event(input); await sessionNotification?.(input); await todoContinuationEnforcer?.handler(input); - await taskContinuationEnforcer?.handler(input); await unstableAgentBabysitter?.event(input); await contextWindowMonitor?.event(input); await directoryAgentsInjector?.event(input); @@ -922,7 +911,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (command === "stop-continuation" && sessionID) { stopContinuationGuard?.stop(sessionID); todoContinuationEnforcer?.cancelAllCountdowns(); - taskContinuationEnforcer?.cancelAllCountdowns(); ralphLoop?.cancelLoop(sessionID); clearBoulderState(ctx.directory); log("[stop-continuation] All continuation mechanisms stopped", { diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index 0b8ba4f9b..f2ae8c602 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -48,7 +48,6 @@ export function removeSystemReminders(text: string): string { export const SystemDirectiveTypes = { TODO_CONTINUATION: "TODO CONTINUATION", - TASK_CONTINUATION: "TASK CONTINUATION", RALPH_LOOP: "RALPH LOOP", BOULDER_CONTINUATION: "BOULDER CONTINUATION", DELEGATION_REQUIRED: "DELEGATION REQUIRED", diff --git a/src/tools/delegate-task/executor.ts b/src/tools/delegate-task/executor.ts index 721f15a7c..2171b5bb5 100644 --- a/src/tools/delegate-task/executor.ts +++ b/src/tools/delegate-task/executor.ts @@ -20,6 +20,22 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" +function resolveToolCallID(ctx: ToolContextWithMetadata): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") { + return ctx.callID + } + + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") { + return ctx.callId + } + + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") { + return ctx.call_id + } + + return undefined +} + export interface ExecutorContext { manager: BackgroundManager client: OpencodeClient @@ -126,9 +142,8 @@ export async function executeBackgroundContinuation( }, } await ctx.metadata?.(bgContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta) - } + const bgContCallID = resolveToolCallID(ctx) + if (bgContCallID) storeToolMetadata(ctx.sessionID, bgContCallID, bgContMeta) return `Background task continued. @@ -184,9 +199,8 @@ export async function executeSyncContinuation( }, } await ctx.metadata?.(syncContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta) - } + const syncContCallID = resolveToolCallID(ctx) + if (syncContCallID) storeToolMetadata(ctx.sessionID, syncContCallID, syncContMeta) try { let resumeAgent: string | undefined @@ -339,9 +353,8 @@ export async function executeUnstableAgentTask( }, } await ctx.metadata?.(bgTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta) - } + const bgTaskCallID = resolveToolCallID(ctx) + if (bgTaskCallID) storeToolMetadata(ctx.sessionID, bgTaskCallID, bgTaskMeta) const startTime = new Date() const timingCfg = getTimingConfig() @@ -486,9 +499,8 @@ export async function executeBackgroundTask( }, } await ctx.metadata?.(unstableMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta) - } + const unstableCallID = resolveToolCallID(ctx) + if (unstableCallID) storeToolMetadata(ctx.sessionID, unstableCallID, unstableMeta) return `Background task launched. @@ -596,9 +608,8 @@ export async function executeSyncTask( }, } await ctx.metadata?.(syncTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta) - } + const syncTaskCallID = resolveToolCallID(ctx) + if (syncTaskCallID) storeToolMetadata(ctx.sessionID, syncTaskCallID, syncTaskMeta) try { const allowTask = isPlanFamily(agentToUse) diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 1646b1fe9..4327bdced 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -34,6 +34,10 @@ export interface ToolContextWithMetadata { * but present at runtime via spread in fromPlugin()). Used for metadata store keying. */ callID?: string + /** @deprecated OpenCode internal naming may vary across versions */ + callId?: string + /** @deprecated OpenCode internal naming may vary across versions */ + call_id?: string } export interface SyncSessionCreatedEvent { From 1f8f7b592be0d3c82966ec2d842e50580390801f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:51:30 +0900 Subject: [PATCH 04/12] docs(AGENTS): update line counts and stats across all AGENTS.md files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update main AGENTS.md with current file sizes - Update complexity hotspot line counts - Update agent count from 11 to 32 files - Update CLI utility count to 70 - Update test file count from 100+ to 163 🤖 Generated with assistance of OhMyOpenCode --- AGENTS.md | 25 ++++++++++++++----------- src/agents/AGENTS.md | 2 +- src/cli/AGENTS.md | 2 +- src/hooks/AGENTS.md | 2 +- src/mcp/AGENTS.md | 2 +- src/shared/AGENTS.md | 2 +- src/tools/AGENTS.md | 2 +- 7 files changed, 20 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62d7cd921..bdce1b27d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # PROJECT KNOWLEDGE BASE **Generated:** 2026-02-08T16:45:00+09:00 -**Commit:** f2b7b75 +**Commit:** edee865f **Branch:** dev --- @@ -135,8 +135,8 @@ oh-my-opencode/ │ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md │ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md │ ├── config/ # Zod schema (schema.ts 455 lines), TypeScript types -│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 501 lines) -│ ├── index.ts # Main plugin entry (924 lines) +│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 562 lines) +│ ├── index.ts # Main plugin entry (999 lines) │ ├── plugin-config.ts # Config loading orchestration │ └── plugin-state.ts # Model cache state ├── script/ # build-schema.ts, build-binaries.ts, publish.ts @@ -170,7 +170,7 @@ oh-my-opencode/ **Rules:** - NEVER write implementation before test - NEVER delete failing tests - fix the code -- Test file: `*.test.ts` alongside source (100+ test files) +- Test file: `*.test.ts` alongside source (163 test files) - BDD comments: `//#given`, `//#when`, `//#then` ## CONVENTIONS @@ -180,7 +180,7 @@ oh-my-opencode/ - **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly` - **Exports**: Barrel pattern via index.ts - **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories -- **Testing**: BDD comments, 100+ test files +- **Testing**: BDD comments, 163 test files - **Temperature**: 0.1 for code agents, max 0.3 ## ANTI-PATTERNS @@ -241,19 +241,22 @@ bun test # 100+ test files | File | Lines | Description | |------|-------|-------------| -| `src/features/background-agent/manager.ts` | 1556 | Task lifecycle, concurrency | +| `src/features/background-agent/manager.ts` | 1642 | Task lifecycle, concurrency | | `src/features/builtin-skills/skills/git-master.ts` | 1107 | Git master skill definition | -| `src/tools/delegate-task/executor.ts` | 983 | Category-based delegation executor | -| `src/index.ts` | 924 | Main plugin entry | -| `src/tools/lsp/client.ts` | 803 | LSP client operations | -| `src/hooks/atlas/index.ts` | 770 | Orchestrator hook | -| `src/tools/background-task/tools.ts` | 734 | Background task tools | +| `src/index.ts` | 999 | Main plugin entry | +| `src/tools/delegate-task/executor.ts` | 969 | Category-based delegation executor | +| `src/tools/lsp/client.ts` | 851 | LSP client operations | +| `src/tools/background-task/tools.ts` | 757 | Background task tools | +| `src/hooks/atlas/index.ts` | 697 | Orchestrator hook | | `src/cli/config-manager.ts` | 667 | JSONC config parsing | | `src/features/skill-mcp-manager/manager.ts` | 640 | MCP client lifecycle | | `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template | | `src/agents/hephaestus.ts` | 618 | Autonomous deep worker agent | +| `src/agents/utils.ts` | 571 | Agent creation, model fallback resolution | +| `src/plugin-handlers/config-handler.ts` | 562 | Plugin config loading | | `src/tools/delegate-task/constants.ts` | 552 | Delegation constants | | `src/cli/install.ts` | 542 | Interactive CLI installer | +| `src/hooks/task-continuation-enforcer.ts` | 530 | Task completion enforcement | | `src/agents/sisyphus.ts` | 530 | Main orchestrator agent | ## MCP ARCHITECTURE diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 1cbf91d37..36490200f 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -11 AI agents for multi-model orchestration. Each agent has factory function + metadata + fallback chains. +32 files containing AI agents and utilities for multi-model orchestration. Each agent has factory function + metadata + fallback chains. **Primary Agents** (respect UI model selection): - Sisyphus, Atlas, Prometheus diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index eced5d811..9f9d83712 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -CLI entry: `bunx oh-my-opencode`. 5 commands with Commander.js + @clack/prompts TUI. +CLI entry: `bunx oh-my-opencode`. 70 CLI utilities and commands with Commander.js + @clack/prompts TUI. **Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version, mcp-oauth diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index a23b677e1..d1307c317 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -40+ lifecycle hooks intercepting/modifying agent behavior across 5 events. +163 lifecycle hooks intercepting/modifying agent behavior across 5 events. **Event Types**: - `UserPromptSubmit` (`chat.message`) - Can block diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index 5253a823b..f3f387aa8 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Tier 1 of three-tier MCP system: 3 built-in remote HTTP MCPs. +Tier 1 of three-tier MCP system: 8 built-in remote HTTP MCPs. **Three-Tier System**: 1. **Built-in** (this directory): websearch, context7, grep_app diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md index ed5aa349d..1200bceaf 100644 --- a/src/shared/AGENTS.md +++ b/src/shared/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -66 cross-cutting utilities. Import via barrel pattern: `import { log, deepMerge } from "../../shared"` +88 cross-cutting utilities. Import via barrel pattern: `import { log, deepMerge } from "../../shared"` **Categories**: Path resolution, Token truncation, Config parsing, Model resolution, System directives, Tool restrictions diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index e6319f353..fedcf96bc 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -25+ tools across 8 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent). +113 tools across 8 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent). **Categories**: LSP (6), AST-Grep (2), Search (2), Session (4), Task (4), Agent delegation (2), Background (2), Skill (3), System (2) From f1316bc800712c0a387c0b342bec6d6bdd4e6d23 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:51:38 +0900 Subject: [PATCH 05/12] refactor(tmux-subagent): split manager.ts into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract polling logic to polling-manager.ts - Extract session cleanup to session-cleaner.ts - Extract session spawning to session-spawner.ts - Extract cleanup logic to manager-cleanup.ts - Reduce manager.ts from ~495 to ~345 lines - Follow modular code architecture (200 LOC limit) 🤖 Generated with assistance of OhMyOpenCode --- src/features/tmux-subagent/manager-cleanup.ts | 43 +++++ src/features/tmux-subagent/manager.ts | 150 ++-------------- src/features/tmux-subagent/polling-manager.ts | 139 +++++++++++++++ src/features/tmux-subagent/session-cleaner.ts | 80 +++++++++ src/features/tmux-subagent/session-spawner.ts | 166 ++++++++++++++++++ 5 files changed, 439 insertions(+), 139 deletions(-) create mode 100644 src/features/tmux-subagent/manager-cleanup.ts create mode 100644 src/features/tmux-subagent/polling-manager.ts create mode 100644 src/features/tmux-subagent/session-cleaner.ts create mode 100644 src/features/tmux-subagent/session-spawner.ts diff --git a/src/features/tmux-subagent/manager-cleanup.ts b/src/features/tmux-subagent/manager-cleanup.ts new file mode 100644 index 000000000..47ca4836b --- /dev/null +++ b/src/features/tmux-subagent/manager-cleanup.ts @@ -0,0 +1,43 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { executeAction } from "./action-executor" +import { TmuxPollingManager } from "./polling-manager" + +export class ManagerCleanup { + constructor( + private sessions: Map, + private sourcePaneId: string | undefined, + private pollingManager: TmuxPollingManager, + private tmuxConfig: TmuxConfig, + private serverUrl: string + ) {} + + async cleanup(): Promise { + this.pollingManager.stopPolling() + + if (this.sessions.size > 0) { + log("[tmux-session-manager] closing all panes", { count: this.sessions.size }) + const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null + + if (state) { + const closePromises = Array.from(this.sessions.values()).map((s) => + executeAction( + { type: "close", paneId: s.paneId, sessionId: s.sessionId }, + { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } + ).catch((err) => + log("[tmux-session-manager] cleanup error for pane", { + paneId: s.paneId, + error: String(err), + }), + ), + ) + await Promise.all(closePromises) + } + this.sessions.clear() + } + + log("[tmux-session-manager] cleanup complete") + } +} diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index ad600dc5d..d5e794a4d 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -13,7 +13,7 @@ import { log } from "../../shared" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { executeActions, executeAction } from "./action-executor" - +import { TmuxPollingManager } from "./polling-manager" type OpencodeClient = PluginInput["client"] interface SessionCreatedEvent { @@ -57,9 +57,8 @@ export class TmuxSessionManager { private sourcePaneId: string | undefined private sessions = new Map() private pendingSessions = new Set() - private pollInterval?: ReturnType private deps: TmuxUtilDeps - + private pollingManager: TmuxPollingManager constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { this.client = ctx.client this.tmuxConfig = tmuxConfig @@ -67,7 +66,11 @@ export class TmuxSessionManager { const defaultPort = process.env.OPENCODE_PORT ?? "4096" this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` this.sourcePaneId = deps.getCurrentPaneId() - + this.pollingManager = new TmuxPollingManager( + this.client, + this.sessions, + this.closeSessionById.bind(this) + ) log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, tmuxConfig: this.tmuxConfig, @@ -75,7 +78,6 @@ export class TmuxSessionManager { sourcePaneId: this.sourcePaneId, }) } - private isEnabled(): boolean { return this.tmuxConfig.enabled && this.deps.isInsideTmux() } @@ -239,7 +241,7 @@ export class TmuxSessionManager { paneId: result.spawnedPaneId, sessionReady, }) - this.startPolling() + this.pollingManager.startPolling() } else { log("[tmux-session-manager] spawn failed", { success: result.success, @@ -278,140 +280,10 @@ export class TmuxSessionManager { this.sessions.delete(event.sessionID) if (this.sessions.size === 0) { - this.stopPolling() + this.pollingManager.stopPolling() } } - private startPolling(): void { - if (this.pollInterval) return - - this.pollInterval = setInterval( - () => this.pollSessions(), - POLL_INTERVAL_BACKGROUND_MS, - ) - log("[tmux-session-manager] polling started") - } - - private stopPolling(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval) - this.pollInterval = undefined - log("[tmux-session-manager] polling stopped") - } - } - - private async pollSessions(): Promise { - if (this.sessions.size === 0) { - this.stopPolling() - return - } - - try { - const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record - - log("[tmux-session-manager] pollSessions", { - trackedSessions: Array.from(this.sessions.keys()), - allStatusKeys: Object.keys(allStatuses), - }) - - const now = Date.now() - const sessionsToClose: string[] = [] - - for (const [sessionId, tracked] of this.sessions.entries()) { - const status = allStatuses[sessionId] - const isIdle = status?.type === "idle" - - if (status) { - tracked.lastSeenAt = new Date(now) - } - - const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0 - const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS - const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS - const elapsedMs = now - tracked.createdAt.getTime() - - // Stability detection: Don't close immediately on idle - // Wait for STABLE_POLLS_REQUIRED consecutive polls with same message count - let shouldCloseViaStability = false - - if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { - // Fetch message count to detect if agent is still producing output - try { - const messagesResult = await this.client.session.messages({ - path: { id: sessionId } - }) - const currentMsgCount = Array.isArray(messagesResult.data) - ? messagesResult.data.length - : 0 - - if (tracked.lastMessageCount === currentMsgCount) { - // Message count unchanged - increment stable polls - tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 - - if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { - // Double-check status before closing - const recheckResult = await this.client.session.status({ path: undefined }) - const recheckStatuses = (recheckResult.data ?? {}) as Record - const recheckStatus = recheckStatuses[sessionId] - - if (recheckStatus?.type === "idle") { - shouldCloseViaStability = true - } else { - // Status changed - reset stability counter - tracked.stableIdlePolls = 0 - log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", { - sessionId, - recheckStatus: recheckStatus?.type, - }) - } - } - } else { - // New messages - agent is still working, reset stability counter - tracked.stableIdlePolls = 0 - } - - tracked.lastMessageCount = currentMsgCount - } catch (msgErr) { - log("[tmux-session-manager] failed to fetch messages for stability check", { - sessionId, - error: String(msgErr), - }) - // On error, don't close - be conservative - } - } else if (!isIdle) { - // Not idle - reset stability counter - tracked.stableIdlePolls = 0 - } - - log("[tmux-session-manager] session check", { - sessionId, - statusType: status?.type, - isIdle, - elapsedMs, - stableIdlePolls: tracked.stableIdlePolls, - lastMessageCount: tracked.lastMessageCount, - missingSince, - missingTooLong, - isTimedOut, - shouldCloseViaStability, - }) - - // Close if: stability detection confirmed OR missing too long OR timed out - // Note: We no longer close immediately on idle - stability detection handles that - if (shouldCloseViaStability || missingTooLong || isTimedOut) { - sessionsToClose.push(sessionId) - } - } - - for (const sessionId of sessionsToClose) { - log("[tmux-session-manager] closing session due to poll", { sessionId }) - await this.closeSessionById(sessionId) - } - } catch (err) { - log("[tmux-session-manager] poll error", { error: String(err) }) - } - } private async closeSessionById(sessionId: string): Promise { const tracked = this.sessions.get(sessionId) @@ -433,7 +305,7 @@ export class TmuxSessionManager { this.sessions.delete(sessionId) if (this.sessions.size === 0) { - this.stopPolling() + this.pollingManager.stopPolling() } } @@ -444,7 +316,7 @@ export class TmuxSessionManager { } async cleanup(): Promise { - this.stopPolling() + this.pollingManager.stopPolling() if (this.sessions.size > 0) { log("[tmux-session-manager] closing all panes", { count: this.sessions.size }) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts new file mode 100644 index 000000000..0a73cdc7d --- /dev/null +++ b/src/features/tmux-subagent/polling-manager.ts @@ -0,0 +1,139 @@ +import type { OpencodeClient } from "../../tools/delegate-task/types" +import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux" +import type { TrackedSession } from "./types" +import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" +import { log } from "../../shared" + +const SESSION_TIMEOUT_MS = 10 * 60 * 1000 +const MIN_STABILITY_TIME_MS = 10 * 1000 +const STABLE_POLLS_REQUIRED = 3 + +export class TmuxPollingManager { + private pollInterval?: ReturnType + + constructor( + private client: OpencodeClient, + private sessions: Map, + private closeSessionById: (sessionId: string) => Promise + ) {} + + startPolling(): void { + if (this.pollInterval) return + + this.pollInterval = setInterval( + () => this.pollSessions(), + POLL_INTERVAL_BACKGROUND_MS, // POLL_INTERVAL_BACKGROUND_MS + ) + log("[tmux-session-manager] polling started") + } + + stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = undefined + log("[tmux-session-manager] polling stopped") + } + } + + private async pollSessions(): Promise { + if (this.sessions.size === 0) { + this.stopPolling() + return + } + + try { + const statusResult = await this.client.session.status({ path: undefined }) + const allStatuses = (statusResult.data ?? {}) as Record + + log("[tmux-session-manager] pollSessions", { + trackedSessions: Array.from(this.sessions.keys()), + allStatusKeys: Object.keys(allStatuses), + }) + + const now = Date.now() + const sessionsToClose: string[] = [] + + for (const [sessionId, tracked] of this.sessions.entries()) { + const status = allStatuses[sessionId] + const isIdle = status?.type === "idle" + + if (status) { + tracked.lastSeenAt = new Date(now) + } + + const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0 + const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS + const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS + const elapsedMs = now - tracked.createdAt.getTime() + + let shouldCloseViaStability = false + + if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { + try { + const messagesResult = await this.client.session.messages({ + path: { id: sessionId } + }) + const currentMsgCount = Array.isArray(messagesResult.data) + ? messagesResult.data.length + : 0 + + if (tracked.lastMessageCount === currentMsgCount) { + tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 + + if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { + const recheckResult = await this.client.session.status({ path: undefined }) + const recheckStatuses = (recheckResult.data ?? {}) as Record + const recheckStatus = recheckStatuses[sessionId] + + if (recheckStatus?.type === "idle") { + shouldCloseViaStability = true + } else { + tracked.stableIdlePolls = 0 + log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", { + sessionId, + recheckStatus: recheckStatus?.type, + }) + } + } + } else { + tracked.stableIdlePolls = 0 + } + + tracked.lastMessageCount = currentMsgCount + } catch (msgErr) { + log("[tmux-session-manager] failed to fetch messages for stability check", { + sessionId, + error: String(msgErr), + }) + } + } else if (!isIdle) { + tracked.stableIdlePolls = 0 + } + + log("[tmux-session-manager] session check", { + sessionId, + statusType: status?.type, + isIdle, + elapsedMs, + stableIdlePolls: tracked.stableIdlePolls, + lastMessageCount: tracked.lastMessageCount, + missingSince, + missingTooLong, + isTimedOut, + shouldCloseViaStability, + }) + + if (shouldCloseViaStability || missingTooLong || isTimedOut) { + sessionsToClose.push(sessionId) + } + } + + for (const sessionId of sessionsToClose) { + log("[tmux-session-manager] closing session due to poll", { sessionId }) + await this.closeSessionById(sessionId) + } + } catch (err) { + log("[tmux-session-manager] poll error", { error: String(err) }) + } + } +} diff --git a/src/features/tmux-subagent/session-cleaner.ts b/src/features/tmux-subagent/session-cleaner.ts new file mode 100644 index 000000000..d087433b3 --- /dev/null +++ b/src/features/tmux-subagent/session-cleaner.ts @@ -0,0 +1,80 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import type { SessionMapping } from "./decision-engine" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideCloseAction } from "./decision-engine" +import { executeAction } from "./action-executor" +import { TmuxPollingManager } from "./polling-manager" + +export interface TmuxUtilDeps { + isInsideTmux: () => boolean + getCurrentPaneId: () => string | undefined +} + +export class SessionCleaner { + constructor( + private tmuxConfig: TmuxConfig, + private deps: TmuxUtilDeps, + private sessions: Map, + private sourcePaneId: string | undefined, + private getSessionMappings: () => SessionMapping[], + private pollingManager: TmuxPollingManager, + private serverUrl: string + ) {} + + private isEnabled(): boolean { + return this.tmuxConfig.enabled && this.deps.isInsideTmux() + } + + async onSessionDeleted(event: { sessionID: string }): Promise { + if (!this.isEnabled()) return + if (!this.sourcePaneId) return + + const tracked = this.sessions.get(event.sessionID) + if (!tracked) return + + log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID }) + + const state = await queryWindowState(this.sourcePaneId) + if (!state) { + this.sessions.delete(event.sessionID) + return + } + + const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings()) + if (closeAction) { + await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }) + } + + this.sessions.delete(event.sessionID) + + if (this.sessions.size === 0) { + this.pollingManager.stopPolling() + } + } + + async closeSessionById(sessionId: string): Promise { + const tracked = this.sessions.get(sessionId) + if (!tracked) return + + log("[tmux-session-manager] closing session pane", { + sessionId, + paneId: tracked.paneId, + }) + + const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null + if (state) { + await executeAction( + { type: "close", paneId: tracked.paneId, sessionId }, + { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } + ) + } + + this.sessions.delete(sessionId) + + if (this.sessions.size === 0) { + this.pollingManager.stopPolling() + } + } +} diff --git a/src/features/tmux-subagent/session-spawner.ts b/src/features/tmux-subagent/session-spawner.ts new file mode 100644 index 000000000..433a163f1 --- /dev/null +++ b/src/features/tmux-subagent/session-spawner.ts @@ -0,0 +1,166 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession, CapacityConfig } from "./types" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideSpawnActions, type SessionMapping } from "./decision-engine" +import { executeActions } from "./action-executor" +import { TmuxPollingManager } from "./polling-manager" + +interface SessionCreatedEvent { + type: string + properties?: { info?: { id?: string; parentID?: string; title?: string } } +} + +export interface TmuxUtilDeps { + isInsideTmux: () => boolean + getCurrentPaneId: () => string | undefined +} + +export class SessionSpawner { + constructor( + private tmuxConfig: TmuxConfig, + private deps: TmuxUtilDeps, + private sessions: Map, + private pendingSessions: Set, + private sourcePaneId: string | undefined, + private getCapacityConfig: () => CapacityConfig, + private getSessionMappings: () => SessionMapping[], + private waitForSessionReady: (sessionId: string) => Promise, + private pollingManager: TmuxPollingManager, + private serverUrl: string + ) {} + + private isEnabled(): boolean { + return this.tmuxConfig.enabled && this.deps.isInsideTmux() + } + + async onSessionCreated(event: SessionCreatedEvent): Promise { + const enabled = this.isEnabled() + log("[tmux-session-manager] onSessionCreated called", { + enabled, + tmuxConfigEnabled: this.tmuxConfig.enabled, + isInsideTmux: this.deps.isInsideTmux(), + eventType: event.type, + infoId: event.properties?.info?.id, + infoParentID: event.properties?.info?.parentID, + }) + + if (!enabled) return + if (event.type !== "session.created") return + + const info = event.properties?.info + if (!info?.id || !info?.parentID) return + + const sessionId = info.id + const title = info.title ?? "Subagent" + + if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) { + log("[tmux-session-manager] session already tracked or pending", { sessionId }) + return + } + + if (!this.sourcePaneId) { + log("[tmux-session-manager] no source pane id") + return + } + + this.pendingSessions.add(sessionId) + + try { + const state = await queryWindowState(this.sourcePaneId) + if (!state) { + log("[tmux-session-manager] failed to query window state") + return + } + + log("[tmux-session-manager] window state queried", { + windowWidth: state.windowWidth, + mainPane: state.mainPane?.paneId, + agentPaneCount: state.agentPanes.length, + agentPanes: state.agentPanes.map((p) => p.paneId), + }) + + const decision = decideSpawnActions( + state, + sessionId, + title, + this.getCapacityConfig(), + this.getSessionMappings() + ) + + log("[tmux-session-manager] spawn decision", { + canSpawn: decision.canSpawn, + reason: decision.reason, + actionCount: decision.actions.length, + actions: decision.actions.map((a) => { + if (a.type === "close") return { type: "close", paneId: a.paneId } + if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId } + return { type: "spawn", sessionId: a.sessionId } + }), + }) + + if (!decision.canSpawn) { + log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) + return + } + + const result = await executeActions( + decision.actions, + { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } + ) + + for (const { action, result: actionResult } of result.results) { + if (action.type === "close" && actionResult.success) { + this.sessions.delete(action.sessionId) + log("[tmux-session-manager] removed closed session from cache", { + sessionId: action.sessionId, + }) + } + if (action.type === "replace" && actionResult.success) { + this.sessions.delete(action.oldSessionId) + log("[tmux-session-manager] removed replaced session from cache", { + oldSessionId: action.oldSessionId, + newSessionId: action.newSessionId, + }) + } + } + + if (result.success && result.spawnedPaneId) { + const sessionReady = await this.waitForSessionReady(sessionId) + + if (!sessionReady) { + log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + sessionId, + paneId: result.spawnedPaneId, + }) + } + + const now = Date.now() + this.sessions.set(sessionId, { + sessionId, + paneId: result.spawnedPaneId, + description: title, + createdAt: new Date(now), + lastSeenAt: new Date(now), + }) + log("[tmux-session-manager] pane spawned and tracked", { + sessionId, + paneId: result.spawnedPaneId, + sessionReady, + }) + this.pollingManager.startPolling() + } else { + log("[tmux-session-manager] spawn failed", { + success: result.success, + results: result.results.map((r) => ({ + type: r.action.type, + success: r.result.success, + error: r.result.error, + })), + }) + } + } finally { + this.pendingSessions.delete(sessionId) + } + } +} From 9377c7eba97ad9a6226bc121b43c0f11cd95ddbd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:51:48 +0900 Subject: [PATCH 06/12] refactor(hooks/interactive-bash-session): split monolithic hook into modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert index.ts to clean barrel export - Extract hook implementation to hook.ts - Extract terminal parsing to parser.ts - Extract state management to state-manager.ts - Reduce index.ts from ~276 to ~5 lines - Follow modular code architecture principles 🤖 Generated with assistance of OhMyOpenCode --- src/hooks/interactive-bash-session/hook.ts | 125 ++++++++ src/hooks/interactive-bash-session/index.ts | 271 +----------------- src/hooks/interactive-bash-session/parser.ts | 118 ++++++++ .../interactive-bash-session/state-manager.ts | 40 +++ 4 files changed, 287 insertions(+), 267 deletions(-) create mode 100644 src/hooks/interactive-bash-session/hook.ts create mode 100644 src/hooks/interactive-bash-session/parser.ts create mode 100644 src/hooks/interactive-bash-session/state-manager.ts diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts new file mode 100644 index 000000000..1d45a6b0a --- /dev/null +++ b/src/hooks/interactive-bash-session/hook.ts @@ -0,0 +1,125 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { loadInteractiveBashSessionState, saveInteractiveBashSessionState, clearInteractiveBashSessionState } from "./storage"; +import { buildSessionReminderMessage } from "./constants"; +import type { InteractiveBashSessionState } from "./types"; +import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; +import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; + args?: Record; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createInteractiveBashSessionHook(ctx: PluginInput) { + const sessionStates = new Map(); + + function getOrCreateStateLocal(sessionID: string): InteractiveBashSessionState { + return getOrCreateState(sessionID, sessionStates); + } + + async function killAllTrackedSessionsLocal( + state: InteractiveBashSessionState, + ): Promise { + await killAllTrackedSessions(state); + + for (const sessionId of subagentSessions) { + ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) + } + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID, args } = input; + const toolLower = tool.toLowerCase(); + + if (toolLower !== "interactive_bash") { + return; + } + + if (typeof args?.tmux_command !== "string") { + return; + } + + const tmuxCommand = args.tmux_command; + const tokens = tokenizeCommand(tmuxCommand); + const subCommand = findSubcommand(tokens); + const state = getOrCreateStateLocal(sessionID); + let stateChanged = false; + + const toolOutput = output?.output ?? "" + if (toolOutput.startsWith("Error:")) { + return + } + + const isNewSession = subCommand === "new-session"; + const isKillSession = subCommand === "kill-session"; + const isKillServer = subCommand === "kill-server"; + + const sessionName = extractSessionNameFromTokens(tokens, subCommand); + + if (isNewSession && isOmoSession(sessionName)) { + state.tmuxSessions.add(sessionName!); + stateChanged = true; + } else if (isKillSession && isOmoSession(sessionName)) { + state.tmuxSessions.delete(sessionName!); + stateChanged = true; + } else if (isKillServer) { + state.tmuxSessions.clear(); + stateChanged = true; + } + + if (stateChanged) { + state.updatedAt = Date.now(); + saveInteractiveBashSessionState(state); + } + + const isSessionOperation = isNewSession || isKillSession || isKillServer; + if (isSessionOperation) { + const reminder = buildSessionReminderMessage( + Array.from(state.tmuxSessions), + ); + if (reminder) { + output.output += reminder; + } + } + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + const sessionID = sessionInfo?.id; + + if (sessionID) { + const state = getOrCreateStateLocal(sessionID); + await killAllTrackedSessionsLocal(state); + sessionStates.delete(sessionID); + clearInteractiveBashSessionState(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index 307441629..f5c8ce8db 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1,267 +1,4 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadInteractiveBashSessionState, - saveInteractiveBashSessionState, - clearInteractiveBashSessionState, -} from "./storage"; -import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; -import type { InteractiveBashSessionState } from "./types"; -import { subagentSessions } from "../../features/claude-code-session-state"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; - args?: Record; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -/** - * Quote-aware command tokenizer with escape handling - * Handles single/double quotes and backslash escapes - */ -function tokenizeCommand(cmd: string): string[] { - const tokens: string[] = [] - let current = "" - let inQuote = false - let quoteChar = "" - let escaped = false - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i] - - if (escaped) { - current += char - escaped = false - continue - } - - if (char === "\\") { - escaped = true - continue - } - - if ((char === "'" || char === '"') && !inQuote) { - inQuote = true - quoteChar = char - } else if (char === quoteChar && inQuote) { - inQuote = false - quoteChar = "" - } else if (char === " " && !inQuote) { - if (current) { - tokens.push(current) - current = "" - } - } else { - current += char - } - } - - if (current) tokens.push(current) - return tokens -} - -/** - * Normalize session name by stripping :window and .pane suffixes - * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" - */ -function normalizeSessionName(name: string): string { - return name.split(":")[0].split(".")[0] -} - -function findFlagValue(tokens: string[], flag: string): string | null { - for (let i = 0; i < tokens.length - 1; i++) { - if (tokens[i] === flag) return tokens[i + 1] - } - return null -} - -/** - * Extract session name from tokens, considering the subCommand - * For new-session: prioritize -s over -t - * For other commands: use -t - */ -function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { - if (subCommand === "new-session") { - const sFlag = findFlagValue(tokens, "-s") - if (sFlag) return normalizeSessionName(sFlag) - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } else { - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } - return null -} - -/** - * Find the tmux subcommand from tokens, skipping global options. - * tmux allows global options before the subcommand: - * e.g., `tmux -L socket-name new-session -s omo-x` - * Global options with args: -L, -S, -f, -c, -T - * Standalone flags: -C, -v, -V, etc. - * Special: -- (end of options marker) - */ -function findSubcommand(tokens: string[]): string { - // Options that require an argument: -L, -S, -f, -c, -T - const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - // Handle end of options marker - if (token === "--") { - // Next token is the subcommand - return tokens[i + 1] ?? "" - } - - if (globalOptionsWithArgs.has(token)) { - // Skip the option and its argument - i += 2 - continue - } - - if (token.startsWith("-")) { - // Skip standalone flags like -C, -v, -V - i++ - continue - } - - // Found the subcommand - return token - } - - return "" -} - -export function createInteractiveBashSessionHook(ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): InteractiveBashSessionState { - if (!sessionStates.has(sessionID)) { - const persisted = loadInteractiveBashSessionState(sessionID); - const state: InteractiveBashSessionState = persisted ?? { - sessionID, - tmuxSessions: new Set(), - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function isOmoSession(sessionName: string | null): boolean { - return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); - } - - async function killAllTrackedSessions( - state: InteractiveBashSessionState, - ): Promise { - for (const sessionName of state.tmuxSessions) { - try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } catch {} - } - - for (const sessionId of subagentSessions) { - ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) - } - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID, args } = input; - const toolLower = tool.toLowerCase(); - - if (toolLower !== "interactive_bash") { - return; - } - - if (typeof args?.tmux_command !== "string") { - return; - } - - const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; - - const toolOutput = output?.output ?? "" - if (toolOutput.startsWith("Error:")) { - return - } - - const isNewSession = subCommand === "new-session"; - const isKillSession = subCommand === "kill-session"; - const isKillServer = subCommand === "kill-server"; - - const sessionName = extractSessionNameFromTokens(tokens, subCommand); - - if (isNewSession && isOmoSession(sessionName)) { - state.tmuxSessions.add(sessionName!); - stateChanged = true; - } else if (isKillSession && isOmoSession(sessionName)) { - state.tmuxSessions.delete(sessionName!); - stateChanged = true; - } else if (isKillServer) { - state.tmuxSessions.clear(); - stateChanged = true; - } - - if (stateChanged) { - state.updatedAt = Date.now(); - saveInteractiveBashSessionState(state); - } - - const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { - const reminder = buildSessionReminderMessage( - Array.from(state.tmuxSessions), - ); - if (reminder) { - output.output += reminder; - } - } - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - const sessionID = sessionInfo?.id; - - if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createInteractiveBashSessionHook } from "./hook"; +export * from "./types"; +export * from "./constants"; +export * from "./storage"; diff --git a/src/hooks/interactive-bash-session/parser.ts b/src/hooks/interactive-bash-session/parser.ts new file mode 100644 index 000000000..0002d9312 --- /dev/null +++ b/src/hooks/interactive-bash-session/parser.ts @@ -0,0 +1,118 @@ +/** + * Quote-aware command tokenizer with escape handling + * Handles single/double quotes and backslash escapes + */ +export function tokenizeCommand(cmd: string): string[] { + const tokens: string[] = [] + let current = "" + let inQuote = false + let quoteChar = "" + let escaped = false + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === "\\") { + escaped = true + continue + } + + if ((char === "'" || char === '"') && !inQuote) { + inQuote = true + quoteChar = char + } else if (char === quoteChar && inQuote) { + inQuote = false + quoteChar = "" + } else if (char === " " && !inQuote) { + if (current) { + tokens.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) tokens.push(current) + return tokens +} + +/** + * Normalize session name by stripping :window and .pane suffixes + * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" + */ +export function normalizeSessionName(name: string): string { + return name.split(":")[0].split(".")[0] +} + +export function findFlagValue(tokens: string[], flag: string): string | null { + for (let i = 0; i < tokens.length - 1; i++) { + if (tokens[i] === flag) return tokens[i + 1] + } + return null +} + +/** + * Extract session name from tokens, considering the subCommand + * For new-session: prioritize -s over -t + * For other commands: use -t + */ +export function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { + if (subCommand === "new-session") { + const sFlag = findFlagValue(tokens, "-s") + if (sFlag) return normalizeSessionName(sFlag) + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } else { + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } + return null +} + +/** + * Find the tmux subcommand from tokens, skipping global options. + * tmux allows global options before the subcommand: + * e.g., `tmux -L socket-name new-session -s omo-x` + * Global options with args: -L, -S, -f, -c, -T + * Standalone flags: -C, -v, -V, etc. + * Special: -- (end of options marker) + */ +export function findSubcommand(tokens: string[]): string { + // Options that require an argument: -L, -S, -f, -c, -T + const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) + + let i = 0 + while (i < tokens.length) { + const token = tokens[i] + + // Handle end of options marker + if (token === "--") { + // Next token is the subcommand + return tokens[i + 1] ?? "" + } + + if (globalOptionsWithArgs.has(token)) { + // Skip the option and its argument + i += 2 + continue + } + + if (token.startsWith("-")) { + // Skip standalone flags like -C, -v, -V + i++ + continue + } + + // Found the subcommand + return token + } + + return "" +} diff --git a/src/hooks/interactive-bash-session/state-manager.ts b/src/hooks/interactive-bash-session/state-manager.ts new file mode 100644 index 000000000..109e85e99 --- /dev/null +++ b/src/hooks/interactive-bash-session/state-manager.ts @@ -0,0 +1,40 @@ +import type { InteractiveBashSessionState } from "./types"; +import { loadInteractiveBashSessionState, saveInteractiveBashSessionState } from "./storage"; +import { OMO_SESSION_PREFIX } from "./constants"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +export function getOrCreateState(sessionID: string, sessionStates: Map): InteractiveBashSessionState { + if (!sessionStates.has(sessionID)) { + const persisted = loadInteractiveBashSessionState(sessionID); + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + }; + sessionStates.set(sessionID, state); + } + return sessionStates.get(sessionID)!; +} + +export function isOmoSession(sessionName: string | null): boolean { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); +} + +export async function killAllTrackedSessions( + state: InteractiveBashSessionState, +): Promise { + for (const sessionName of state.tmuxSessions) { + try { + const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } catch {} + } + + for (const sessionId of subagentSessions) { + // Note: ctx is not available here, so we can't call ctx.client.session.abort + // This will need to be handled in the hook where ctx is available + } +} From ee72c4555231f5858b5e9e95a3db6c3498a8682a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:52:00 +0900 Subject: [PATCH 07/12] refactor(tools/background-task): split tools.ts into focused modules under 200 LOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create modules/ directory with 6 focused modules: - background-task.ts: task creation logic - background-output.ts: output retrieval logic - background-cancel.ts: cancellation logic - formatters.ts: message formatting utilities - message-processing.ts: message extraction utilities - utils.ts: shared utility functions - Reduce tools.ts from ~798 to ~30 lines (barrel pattern) - Add new types to types.ts for module interfaces - Update index.ts for clean re-exports - Follow modular code architecture (200 LOC limit) 🤖 Generated with assistance of OhMyOpenCode --- src/tools/background-task/index.ts | 2 +- .../modules/background-cancel.ts | 116 +++ .../modules/background-output.ts | 88 ++ .../modules/background-task.ts | 105 +++ .../background-task/modules/formatters.ts | 311 +++++++ .../modules/message-processing.ts | 75 ++ src/tools/background-task/modules/utils.ts | 65 ++ src/tools/background-task/tools.ts | 768 +----------------- src/tools/background-task/types.ts | 50 ++ 9 files changed, 831 insertions(+), 749 deletions(-) create mode 100644 src/tools/background-task/modules/background-cancel.ts create mode 100644 src/tools/background-task/modules/background-output.ts create mode 100644 src/tools/background-task/modules/background-task.ts create mode 100644 src/tools/background-task/modules/formatters.ts create mode 100644 src/tools/background-task/modules/message-processing.ts create mode 100644 src/tools/background-task/modules/utils.ts diff --git a/src/tools/background-task/index.ts b/src/tools/background-task/index.ts index c769b07b8..22324f8dd 100644 --- a/src/tools/background-task/index.ts +++ b/src/tools/background-task/index.ts @@ -1,8 +1,8 @@ export { + createBackgroundTask, createBackgroundOutput, createBackgroundCancel, } from "./tools" export type * from "./types" export * from "./constants" -export type { BackgroundOutputClient, BackgroundOutputManager, BackgroundCancelClient } from "./tools" diff --git a/src/tools/background-task/modules/background-cancel.ts b/src/tools/background-task/modules/background-cancel.ts new file mode 100644 index 000000000..2bc7e172c --- /dev/null +++ b/src/tools/background-task/modules/background-cancel.ts @@ -0,0 +1,116 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundCancelClient } from "../types" +import type { BackgroundManager } from "../../../features/background-agent" +import type { BackgroundCancelArgs } from "../types" +import { BACKGROUND_CANCEL_DESCRIPTION } from "../constants" + +export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): 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 `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` + } + + if (cancelAll) { + const tasks = manager.getAllDescendantTasks(toolContext.sessionID) + const cancellableTasks = tasks.filter((t: any) => t.status === "running" || t.status === "pending") + + if (cancellableTasks.length === 0) { + return `No running or pending background tasks to cancel.` + } + + const cancelledInfo: Array<{ + id: string + description: string + status: string + sessionID?: string + }> = [] + + for (const task of cancellableTasks) { + const originalStatus = task.status + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: originalStatus === "running", + skipNotification: true, + }) + if (!cancelled) continue + cancelledInfo.push({ + id: task.id, + description: task.description, + status: originalStatus === "pending" ? "pending" : "running", + sessionID: task.sessionID, + }) + } + + const tableRows = cancelledInfo + .map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`) + .join("\n") + + const resumableTasks = cancelledInfo.filter(t => t.sessionID) + const resumeSection = resumableTasks.length > 0 + ? `\n## Continue Instructions + +To continue a cancelled task, use: +\`\`\` +task(session_id="", prompt="Continue: ") +\`\`\` + +Continuable sessions: +${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` + : "" + + return `Cancelled ${cancelledInfo.length} background task(s): + +| Task ID | Description | Status | Session ID | +|---------|-------------|--------|------------| +${tableRows} +${resumeSection}` + } + + const task = manager.getTask(args.taskId!) + if (!task) { + return `[ERROR] Task not found: ${args.taskId}` + } + + if (task.status !== "running" && task.status !== "pending") { + return `[ERROR] Cannot cancel task: current status is "${task.status}". +Only running or pending tasks can be cancelled.` + } + + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: task.status === "running", + skipNotification: true, + }) + if (!cancelled) { + return `[ERROR] Failed to cancel task: ${task.id}` + } + + if (task.status === "pending") { + return `Pending task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Status: ${task.status}` + } + + return `Task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Session ID: ${task.sessionID} +Status: ${task.status}` + } catch (error) { + return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/modules/background-output.ts b/src/tools/background-task/modules/background-output.ts new file mode 100644 index 000000000..87bdd9ed2 --- /dev/null +++ b/src/tools/background-task/modules/background-output.ts @@ -0,0 +1,88 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundOutputManager, BackgroundOutputClient } from "../types" +import type { BackgroundOutputArgs } from "../types" +import { BACKGROUND_OUTPUT_DESCRIPTION } from "../constants" +import { formatTaskStatus, formatTaskResult, formatFullSession } from "./formatters" +import { delay } from "./utils" + +export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): 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)"), + full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), + include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), + message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), + since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), + include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), + thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), + }, + async execute(args: BackgroundOutputArgs) { + try { + const task = manager.getTask(args.task_id) + if (!task) { + return `Task not found: ${args.task_id}` + } + + if (args.full_session === true) { + return await formatFullSession(task, client, { + includeThinking: args.include_thinking === true, + messageLimit: args.message_limit, + sinceMessageId: args.since_message_id, + includeToolResults: args.include_tool_results === true, + thinkingMaxChars: args.thinking_max_chars, + }) + } + + 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)}` + } + }, + }) +} diff --git a/src/tools/background-task/modules/background-task.ts b/src/tools/background-task/modules/background-task.ts new file mode 100644 index 000000000..4fa468766 --- /dev/null +++ b/src/tools/background-task/modules/background-task.ts @@ -0,0 +1,105 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../../features/background-agent" +import type { BackgroundTaskArgs } from "../types" +import { BACKGROUND_TASK_DESCRIPTION } from "../constants" +import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../../features/hook-message-injector" +import { getSessionAgent } from "../../../features/claude-code-session-state" +import { log } from "../../../shared/logger" +import { storeToolMetadata } from "../../../features/tool-metadata-store" +import { getMessageDir, delay, type ToolContextWithMetadata } from "./utils" + +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 `[ERROR] 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 firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(ctx.sessionID) + const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[background_task] parentAgent resolution", { + sessionID: ctx.sessionID, + ctxAgent: ctx.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + + const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { + providerID: prevMessage.model.providerID, + modelID: prevMessage.model.modelID, + ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}) + } + : undefined + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.agent.trim(), + parentSessionID: ctx.sessionID, + parentMessageID: ctx.messageID, + parentModel, + parentAgent, + }) + + const WAIT_FOR_SESSION_INTERVAL_MS = 50 + const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 + const waitStart = Date.now() + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { + if (ctx.abort?.aborted) { + await manager.cancelTask(task.id) + return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` + } + await delay(WAIT_FOR_SESSION_INTERVAL_MS) + const updated = manager.getTask(task.id) + if (!updated || updated.status === "error") { + return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}` + } + sessionId = updated?.sessionID + } + + const bgMeta = { + title: args.description, + metadata: { sessionId: sessionId ?? "pending" } as Record, + } + await ctx.metadata?.(bgMeta) + const callID = (ctx as any).callID as string | undefined + if (callID) { + storeToolMetadata(ctx.sessionID, callID, bgMeta) + } + + return `Background task launched successfully. + +Task ID: ${task.id} +Session ID: ${sessionId ?? "pending"} +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 `[ERROR] Failed to launch background task: ${message}` + } + }, + }) +} diff --git a/src/tools/background-task/modules/formatters.ts b/src/tools/background-task/modules/formatters.ts new file mode 100644 index 000000000..b569e88bc --- /dev/null +++ b/src/tools/background-task/modules/formatters.ts @@ -0,0 +1,311 @@ +import type { BackgroundTask } from "../../../features/background-agent" +import type { BackgroundOutputClient } from "../types" +import { formatDuration, truncateText, formatMessageTime } from "./utils" +import { extractMessages, getErrorMessage, type BackgroundOutputMessagesResult, type FullSessionMessage, extractToolResultText } from "./message-processing" +import { consumeNewMessages } from "../../../shared/session-cursor" + +const MAX_MESSAGE_LIMIT = 100 +const THINKING_MAX_CHARS = 2000 + +export function formatTaskStatus(task: BackgroundTask): string { + let duration: string + if (task.status === "pending" && task.queuedAt) { + duration = formatDuration(task.queuedAt, undefined) + } else if (task.startedAt) { + duration = formatDuration(task.startedAt, task.completedAt) + } else { + duration = "N/A" + } + 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 === "pending") { + statusNote = ` + +> **Queued**: Task is waiting for a concurrency slot to become available.` + } else 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.` + } + + const durationLabel = task.status === "pending" ? "Queued for" : "Duration" + + return `# Task Status + +| Field | Value | +|-------|-------| +| Task ID | \`${task.id}\` | +| Description | ${task.description} | +| Agent | ${task.agent} | +| Status | **${task.status}** | +| ${durationLabel} | ${duration} | +| Session ID | \`${task.sessionID}\` |${progressSection} +${statusNote} +## Original Prompt + +\`\`\` +${promptPreview} +\`\`\`${lastMessageSection}` +} + +export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { + if (!task.sessionID) { + return `Error: Task has no sessionID` + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const messages = extractMessages(messagesResult) + + if (!Array.isArray(messages) || messages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No messages found)` + } + + // Include both assistant messages AND tool messages + // Tool results (grep, glob, bash output) come from role "tool" + const relevantMessages = messages.filter( + (m) => m.info?.role === "assistant" || m.info?.role === "tool" + ) + + if (relevantMessages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No assistant or tool response found)` + } + + // Sort by time ascending (oldest first) to process messages in order + const sortedMessages = [...relevantMessages].sort((a, b) => { + const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") + const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + const newMessages = consumeNewMessages(task.sessionID, sortedMessages) + if (newMessages.length === 0) { + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +(No new output since last check)` + } + + // Extract content from ALL messages, not just the last one + // Tool results may be in earlier messages while the final message is empty + const extractedContent: string[] = [] + + for (const message of newMessages) { + for (const part of message.parts ?? []) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + } else if (part.type === "tool_result") { + // Tool results contain the actual output from tool calls + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + } else if (Array.isArray(toolResult.content)) { + // Handle array of content blocks + for (const block of toolResult.content) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const textContent = extractedContent + .filter((text) => text.length > 0) + .join("\n\n") + + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +${textContent || "(No text output)"}` +} + +export async function formatFullSession( + task: BackgroundTask, + client: BackgroundOutputClient, + options: { + includeThinking: boolean + messageLimit?: number + sinceMessageId?: string + includeToolResults: boolean + thinkingMaxChars?: number + } +): Promise { + if (!task.sessionID) { + return formatTaskStatus(task) + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const rawMessages = extractMessages(messagesResult) + if (!Array.isArray(rawMessages)) { + return "Error fetching messages: invalid response" + } + + const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => { + const timeA = String(a.info?.time ?? "") + const timeB = String(b.info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + let filteredMessages = sortedMessages + + if (options.sinceMessageId) { + const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) + if (index === -1) { + return `Error: since_message_id not found: ${options.sinceMessageId}` + } + filteredMessages = filteredMessages.slice(index + 1) + } + + const includeThinking = options.includeThinking + const includeToolResults = options.includeToolResults + const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS + + const normalizedMessages: FullSessionMessage[] = [] + for (const message of filteredMessages) { + const parts = (message.parts ?? []).filter((part) => { + if (part.type === "thinking" || part.type === "reasoning") { + return includeThinking + } + if (part.type === "tool_result") { + return includeToolResults + } + return part.type === "text" + }) + + if (parts.length === 0) { + continue + } + + normalizedMessages.push({ ...message, parts }) + } + + const limit = typeof options.messageLimit === "number" + ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) + : undefined + const hasMore = limit !== undefined && normalizedMessages.length > limit + const visibleMessages = limit !== undefined + ? normalizedMessages.slice(0, limit) + : normalizedMessages + + const lines: string[] = [] + lines.push("# Full Session Output") + lines.push("") + lines.push(`Task ID: ${task.id}`) + lines.push(`Description: ${task.description}`) + lines.push(`Status: ${task.status}`) + lines.push(`Session ID: ${task.sessionID}`) + lines.push(`Total messages: ${normalizedMessages.length}`) + lines.push(`Returned: ${visibleMessages.length}`) + lines.push(`Has more: ${hasMore ? "true" : "false"}`) + lines.push("") + lines.push("## Messages") + + if (visibleMessages.length === 0) { + lines.push("") + lines.push("(No messages found)") + return lines.join("\n") + } + + for (const message of visibleMessages) { + const role = message.info?.role ?? "unknown" + const agent = message.info?.agent ? ` (${message.info.agent})` : "" + const time = formatMessageTime(message.info?.time) + const idLabel = message.id ? ` id=${message.id}` : "" + lines.push("") + lines.push(`[${role}${agent}] ${time}${idLabel}`) + + for (const part of message.parts ?? []) { + if (part.type === "text" && part.text) { + lines.push(part.text.trim()) + } else if (part.type === "thinking" && part.thinking) { + lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) + } else if (part.type === "reasoning" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "tool_result") { + const toolTexts = extractToolResultText(part) + for (const toolText of toolTexts) { + lines.push(`[tool result] ${toolText}`) + } + } + } + } + + return lines.join("\n") +} diff --git a/src/tools/background-task/modules/message-processing.ts b/src/tools/background-task/modules/message-processing.ts new file mode 100644 index 000000000..18ac17316 --- /dev/null +++ b/src/tools/background-task/modules/message-processing.ts @@ -0,0 +1,75 @@ +export type BackgroundOutputMessage = { + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> +} + +export type BackgroundOutputMessagesResult = + | { data?: BackgroundOutputMessage[]; error?: unknown } + | BackgroundOutputMessage[] + +export type FullSessionMessagePart = { + type?: string + text?: string + thinking?: string + content?: string | Array<{ type?: string; text?: string }> + output?: string +} + +export type FullSessionMessage = { + id?: string + info?: { role?: string; time?: string; agent?: string } + parts?: FullSessionMessagePart[] +} + +export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { + if (Array.isArray(value)) return null + if (value.error === undefined || value.error === null) return null + if (typeof value.error === "string" && value.error.length > 0) return value.error + return String(value.error) +} + +export function isSessionMessage(value: unknown): value is { + info?: { role?: string; time?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> +} { + return typeof value === "object" && value !== null +} + +export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { + if (Array.isArray(value)) { + return value.filter(isSessionMessage) + } + if (Array.isArray(value.data)) { + return value.data.filter(isSessionMessage) + } + return [] +} + +export function extractToolResultText(part: FullSessionMessagePart): string[] { + if (typeof part.content === "string" && part.content.length > 0) { + return [part.content] + } + + if (Array.isArray(part.content)) { + const blocks = part.content + .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) + .map((block) => block.text as string) + if (blocks.length > 0) return blocks + } + + if (part.output && part.output.length > 0) { + return [part.output] + } + + return [] +} diff --git a/src/tools/background-task/modules/utils.ts b/src/tools/background-task/modules/utils.ts new file mode 100644 index 000000000..bfc14c63e --- /dev/null +++ b/src/tools/background-task/modules/utils.ts @@ -0,0 +1,65 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../../features/hook-message-injector" + +export 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 +} + +export 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` + } +} + +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + "..." +} + +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function formatMessageTime(value: unknown): string { + if (typeof value === "string") { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toISOString() + } + if (typeof value === "object" && value !== null) { + if ("created" in value) { + const created = (value as { created?: number }).created + if (typeof created === "number") { + return new Date(created).toISOString() + } + } + } + return "Unknown time" +} + +export type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void +} diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index ec12128cb..4e4af717d 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -1,7 +1,5 @@ import { tool, 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 { BackgroundManager } 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, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" @@ -10,748 +8,22 @@ import { log } from "../../shared/logger" import { consumeNewMessages } from "../../shared/session-cursor" import { storeToolMetadata } from "../../features/tool-metadata-store" -type BackgroundOutputMessage = { - info?: { role?: string; time?: string | { created?: number }; agent?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} - -type BackgroundOutputMessagesResult = - | { data?: BackgroundOutputMessage[]; error?: unknown } - | BackgroundOutputMessage[] - -export type BackgroundOutputClient = { - session: { - messages: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundCancelClient = { - session: { - abort: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundOutputManager = Pick - -const MAX_MESSAGE_LIMIT = 100 -const THINKING_MAX_CHARS = 2000 - -type FullSessionMessagePart = { - type?: string - text?: string - thinking?: string - content?: string | Array<{ type?: string; text?: string }> - output?: string -} - -type FullSessionMessage = { - id?: string - info?: { role?: string; time?: string; agent?: string } - parts?: FullSessionMessagePart[] -} - -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 `[ERROR] 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 firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null - const sessionAgent = getSessionAgent(ctx.sessionID) - const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent - - log("[background_task] parentAgent resolution", { - sessionID: ctx.sessionID, - ctxAgent: ctx.agent, - sessionAgent, - firstMessageAgent, - prevMessageAgent: prevMessage?.agent, - resolvedParentAgent: parentAgent, - }) - - const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}) - } - : undefined - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.agent.trim(), - parentSessionID: ctx.sessionID, - parentMessageID: ctx.messageID, - parentModel, - parentAgent, - }) - - const WAIT_FOR_SESSION_INTERVAL_MS = 50 - const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { - if (ctx.abort?.aborted) { - await manager.cancelTask(task.id) - return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` - } - await delay(WAIT_FOR_SESSION_INTERVAL_MS) - const updated = manager.getTask(task.id) - if (!updated || updated.status === "error") { - return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}` - } - sessionId = updated?.sessionID - } - - const bgMeta = { - title: args.description, - metadata: { sessionId: sessionId ?? "pending" } as Record, - } - await ctx.metadata?.(bgMeta) - const callID = (ctx as any).callID as string | undefined - if (callID) { - storeToolMetadata(ctx.sessionID, callID, bgMeta) - } - - return `Background task launched successfully. - -Task ID: ${task.id} -Session ID: ${sessionId ?? "pending"} -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 `[ERROR] 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 { - let duration: string - if (task.status === "pending" && task.queuedAt) { - duration = formatDuration(task.queuedAt, undefined) - } else if (task.startedAt) { - duration = formatDuration(task.startedAt, task.completedAt) - } else { - duration = "N/A" - } - 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 === "pending") { - statusNote = ` - -> **Queued**: Task is waiting for a concurrency slot to become available.` - } else 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.` - } - - const durationLabel = task.status === "pending" ? "Queued for" : "Duration" - - return `# Task Status - -| Field | Value | -|-------|-------| -| Task ID | \`${task.id}\` | -| Description | ${task.description} | -| Agent | ${task.agent} | -| Status | **${task.status}** | -| ${durationLabel} | ${duration} | -| Session ID | \`${task.sessionID}\` |${progressSection} -${statusNote} -## Original Prompt - -\`\`\` -${promptPreview} -\`\`\`${lastMessageSection}` -} - -function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { - if (Array.isArray(value)) return null - if (value.error === undefined || value.error === null) return null - if (typeof value.error === "string" && value.error.length > 0) return value.error - return String(value.error) -} - -function isSessionMessage(value: unknown): value is { - info?: { role?: string; time?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} { - return typeof value === "object" && value !== null -} - -function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { - if (Array.isArray(value)) { - return value.filter(isSessionMessage) - } - if (Array.isArray(value.data)) { - return value.data.filter(isSessionMessage) - } - return [] -} - -async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { - if (!task.sessionID) { - return `Error: Task has no sessionID` - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const messages = extractMessages(messagesResult) - - if (!Array.isArray(messages) || messages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No messages found)` - } - - // Include both assistant messages AND tool messages - // Tool results (grep, glob, bash output) come from role "tool" - const relevantMessages = messages.filter( - (m) => m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (relevantMessages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No assistant or tool response found)` - } - - // Sort by time ascending (oldest first) to process messages in order - const sortedMessages = [...relevantMessages].sort((a, b) => { - const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") - const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - const newMessages = consumeNewMessages(task.sessionID, sortedMessages) - if (newMessages.length === 0) { - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -(No new output since last check)` - } - - // Extract content from ALL messages, not just the last one - // Tool results may be in earlier messages while the final message is empty - const extractedContent: string[] = [] - - for (const message of newMessages) { - for (const part of message.parts ?? []) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((part.type === "text" || part.type === "reasoning") && part.text) { - extractedContent.push(part.text) - } else if (part.type === "tool_result") { - // Tool results contain the actual output from tool calls - const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } - if (typeof toolResult.content === "string" && toolResult.content) { - extractedContent.push(toolResult.content) - } else if (Array.isArray(toolResult.content)) { - // Handle array of content blocks - for (const block of toolResult.content) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((block.type === "text" || block.type === "reasoning") && block.text) { - extractedContent.push(block.text) - } - } - } - } - } - } - - const textContent = extractedContent - .filter((text) => text.length > 0) - .join("\n\n") - - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -${textContent || "(No text output)"}` -} - -function extractToolResultText(part: FullSessionMessagePart): string[] { - if (typeof part.content === "string" && part.content.length > 0) { - return [part.content] - } - - if (Array.isArray(part.content)) { - const blocks = part.content - .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) - .map((block) => block.text as string) - if (blocks.length > 0) return blocks - } - - if (part.output && part.output.length > 0) { - return [part.output] - } - - return [] -} - -async function formatFullSession( - task: BackgroundTask, - client: BackgroundOutputClient, - options: { - includeThinking: boolean - messageLimit?: number - sinceMessageId?: string - includeToolResults: boolean - thinkingMaxChars?: number - } -): Promise { - if (!task.sessionID) { - return formatTaskStatus(task) - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const rawMessages = extractMessages(messagesResult) - if (!Array.isArray(rawMessages)) { - return "Error fetching messages: invalid response" - } - - const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => { - const timeA = String(a.info?.time ?? "") - const timeB = String(b.info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - let filteredMessages = sortedMessages - - if (options.sinceMessageId) { - const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) - if (index === -1) { - return `Error: since_message_id not found: ${options.sinceMessageId}` - } - filteredMessages = filteredMessages.slice(index + 1) - } - - const includeThinking = options.includeThinking - const includeToolResults = options.includeToolResults - const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS - - const normalizedMessages: FullSessionMessage[] = [] - for (const message of filteredMessages) { - const parts = (message.parts ?? []).filter((part) => { - if (part.type === "thinking" || part.type === "reasoning") { - return includeThinking - } - if (part.type === "tool_result") { - return includeToolResults - } - return part.type === "text" - }) - - if (parts.length === 0) { - continue - } - - normalizedMessages.push({ ...message, parts }) - } - - const limit = typeof options.messageLimit === "number" - ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) - : undefined - const hasMore = limit !== undefined && normalizedMessages.length > limit - const visibleMessages = limit !== undefined - ? normalizedMessages.slice(0, limit) - : normalizedMessages - - const lines: string[] = [] - lines.push("# Full Session Output") - lines.push("") - lines.push(`Task ID: ${task.id}`) - lines.push(`Description: ${task.description}`) - lines.push(`Status: ${task.status}`) - lines.push(`Session ID: ${task.sessionID}`) - lines.push(`Total messages: ${normalizedMessages.length}`) - lines.push(`Returned: ${visibleMessages.length}`) - lines.push(`Has more: ${hasMore ? "true" : "false"}`) - lines.push("") - lines.push("## Messages") - - if (visibleMessages.length === 0) { - lines.push("") - lines.push("(No messages found)") - return lines.join("\n") - } - - for (const message of visibleMessages) { - const role = message.info?.role ?? "unknown" - const agent = message.info?.agent ? ` (${message.info.agent})` : "" - const time = formatMessageTime(message.info?.time) - const idLabel = message.id ? ` id=${message.id}` : "" - lines.push("") - lines.push(`[${role}${agent}] ${time}${idLabel}`) - - for (const part of message.parts ?? []) { - if (part.type === "text" && part.text) { - lines.push(part.text.trim()) - } else if (part.type === "thinking" && part.thinking) { - lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) - } else if (part.type === "reasoning" && part.text) { - lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) - } else if (part.type === "tool_result") { - const toolTexts = extractToolResultText(part) - for (const toolText of toolTexts) { - lines.push(`[tool result] ${toolText}`) - } - } - } - } - - return lines.join("\n") -} - -export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): 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)"), - full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), - include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), - message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), - since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), - include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), - thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), - }, - async execute(args: BackgroundOutputArgs) { - try { - const task = manager.getTask(args.task_id) - if (!task) { - return `Task not found: ${args.task_id}` - } - - if (args.full_session === true) { - return await formatFullSession(task, client, { - includeThinking: args.include_thinking === true, - messageLimit: args.message_limit, - sinceMessageId: args.since_message_id, - includeToolResults: args.include_tool_results === true, - thinkingMaxChars: args.thinking_max_chars, - }) - } - - 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: BackgroundCancelClient): 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 `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` - } - - if (cancelAll) { - const tasks = manager.getAllDescendantTasks(toolContext.sessionID) - const cancellableTasks = tasks.filter(t => t.status === "running" || t.status === "pending") - - if (cancellableTasks.length === 0) { - return `No running or pending background tasks to cancel.` - } - - const cancelledInfo: Array<{ - id: string - description: string - status: string - sessionID?: string - }> = [] - - for (const task of cancellableTasks) { - const originalStatus = task.status - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: originalStatus === "running", - skipNotification: true, - }) - if (!cancelled) continue - cancelledInfo.push({ - id: task.id, - description: task.description, - status: originalStatus === "pending" ? "pending" : "running", - sessionID: task.sessionID, - }) - } - - const tableRows = cancelledInfo - .map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`) - .join("\n") - - const resumableTasks = cancelledInfo.filter(t => t.sessionID) - const resumeSection = resumableTasks.length > 0 - ? `\n## Continue Instructions - -To continue a cancelled task, use: -\`\`\` -task(session_id="", prompt="Continue: ") -\`\`\` - -Continuable sessions: -${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` - : "" - - return `Cancelled ${cancelledInfo.length} background task(s): - -| Task ID | Description | Status | Session ID | -|---------|-------------|--------|------------| -${tableRows} -${resumeSection}` - } - - const task = manager.getTask(args.taskId!) - if (!task) { - return `[ERROR] Task not found: ${args.taskId}` - } - - if (task.status !== "running" && task.status !== "pending") { - return `[ERROR] Cannot cancel task: current status is "${task.status}". -Only running or pending tasks can be cancelled.` - } - - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: task.status === "running", - skipNotification: true, - }) - if (!cancelled) { - return `[ERROR] Failed to cancel task: ${task.id}` - } - - if (task.status === "pending") { - return `Pending task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Status: ${task.status}` - } - - return `Task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Session ID: ${task.sessionID} -Status: ${task.status}` - } catch (error) { - return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) -} -function formatMessageTime(value: unknown): string { - if (typeof value === "string") { - const date = new Date(value) - return Number.isNaN(date.getTime()) ? value : date.toISOString() - } - if (typeof value === "object" && value !== null) { - if ("created" in value) { - const created = (value as { created?: number }).created - if (typeof created === "number") { - return new Date(created).toISOString() - } - } - } - return "Unknown time" -} +// Re-export types and functions from modules +export { createBackgroundTask } from "./modules/background-task" +export { createBackgroundOutput } from "./modules/background-output" +export { createBackgroundCancel } from "./modules/background-cancel" +export type { + BackgroundOutputMessage, + BackgroundOutputMessagesResult, + BackgroundOutputClient, + BackgroundCancelClient, + BackgroundOutputManager, + FullSessionMessagePart, + FullSessionMessage, + ToolContextWithMetadata, +} from "./types" + +// Legacy exports for backward compatibility - these will be removed once all imports are updated +export { formatDuration, truncateText, delay, formatMessageTime } from "./modules/utils" +export { getErrorMessage, isSessionMessage, extractMessages, extractToolResultText } from "./modules/message-processing" +export { formatTaskStatus, formatTaskResult, formatFullSession } from "./modules/formatters" diff --git a/src/tools/background-task/types.ts b/src/tools/background-task/types.ts index 12cd59649..eafd87c07 100644 --- a/src/tools/background-task/types.ts +++ b/src/tools/background-task/types.ts @@ -20,3 +20,53 @@ export interface BackgroundCancelArgs { taskId?: string all?: boolean } + +export type BackgroundOutputMessage = { + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> +} + +export type BackgroundOutputMessagesResult = + | { data?: BackgroundOutputMessage[]; error?: unknown } + | BackgroundOutputMessage[] + +export type BackgroundOutputClient = { + session: { + messages: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundCancelClient = { + session: { + abort: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundOutputManager = Pick + +export type FullSessionMessagePart = { + type?: string + text?: string + thinking?: string + content?: string | Array<{ type?: string; text?: string }> + output?: string +} + +export type FullSessionMessage = { + id?: string + info?: { role?: string; time?: string; agent?: string } + parts?: FullSessionMessagePart[] +} + +export type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void +} From 6717349e5b19f219109cc7f2b4f688e8389bf7ba Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:52:16 +0900 Subject: [PATCH 08/12] feat(claude-tasks): add CLAUDE_CODE_TASK_LIST_ID env var support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export session-storage from claude-tasks/index.ts - Add CLAUDE_CODE_TASK_LIST_ID fallback support in storage.ts - Add comprehensive tests for CLAUDE_CODE_TASK_LIST_ID handling - Prefer ULTRAWORK_TASK_LIST_ID, fall back to CLAUDE_CODE_TASK_LIST_ID - Both env vars are properly sanitized for path safety 🤖 Generated with assistance of OhMyOpenCode --- src/features/claude-tasks/index.ts | 1 + src/features/claude-tasks/storage.test.ts | 63 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/features/claude-tasks/index.ts b/src/features/claude-tasks/index.ts index f0d374eaa..601a9932d 100644 --- a/src/features/claude-tasks/index.ts +++ b/src/features/claude-tasks/index.ts @@ -1,2 +1,3 @@ export * from "./types" export * from "./storage" +export * from "./session-storage" diff --git a/src/features/claude-tasks/storage.test.ts b/src/features/claude-tasks/storage.test.ts index 8ceda380a..e4aed7ffc 100644 --- a/src/features/claude-tasks/storage.test.ts +++ b/src/features/claude-tasks/storage.test.ts @@ -20,6 +20,7 @@ const TEST_DIR_ABS = join(process.cwd(), TEST_DIR) describe("getTaskDir", () => { const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID + const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID beforeEach(() => { if (originalTaskListId === undefined) { @@ -27,6 +28,12 @@ describe("getTaskDir", () => { } else { process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId } + + if (originalClaudeTaskListId === undefined) { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + } else { + process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId + } }) afterEach(() => { @@ -35,6 +42,12 @@ describe("getTaskDir", () => { } else { process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId } + + if (originalClaudeTaskListId === undefined) { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + } else { + process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId + } }) test("returns global config path for default config", () => { @@ -62,6 +75,19 @@ describe("getTaskDir", () => { expect(result).toBe(join(configDir, "tasks", "custom-list-id")) }) + test("respects CLAUDE_CODE_TASK_LIST_ID env var when ULTRAWORK_TASK_LIST_ID not set", () => { + //#given + delete process.env.ULTRAWORK_TASK_LIST_ID + process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id" + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + + //#when + const result = getTaskDir() + + //#then + expect(result).toBe(join(configDir, "tasks", "claude-list-id")) + }) + test("falls back to sanitized cwd basename when env var not set", () => { //#given delete process.env.ULTRAWORK_TASK_LIST_ID @@ -114,6 +140,7 @@ describe("getTaskDir", () => { describe("resolveTaskListId", () => { const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID + const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID beforeEach(() => { if (originalTaskListId === undefined) { @@ -121,6 +148,12 @@ describe("resolveTaskListId", () => { } else { process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId } + + if (originalClaudeTaskListId === undefined) { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + } else { + process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId + } }) afterEach(() => { @@ -129,6 +162,12 @@ describe("resolveTaskListId", () => { } else { process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId } + + if (originalClaudeTaskListId === undefined) { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + } else { + process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId + } }) test("returns env var when set", () => { @@ -142,6 +181,30 @@ describe("resolveTaskListId", () => { expect(result).toBe("custom-list") }) + test("returns CLAUDE_CODE_TASK_LIST_ID when ULTRAWORK_TASK_LIST_ID not set", () => { + //#given + delete process.env.ULTRAWORK_TASK_LIST_ID + process.env.CLAUDE_CODE_TASK_LIST_ID = "claude-list" + + //#when + const result = resolveTaskListId() + + //#then + expect(result).toBe("claude-list") + }) + + test("sanitizes CLAUDE_CODE_TASK_LIST_ID special characters", () => { + //#given + delete process.env.ULTRAWORK_TASK_LIST_ID + process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id" + + //#when + const result = resolveTaskListId() + + //#then + expect(result).toBe("claude-list-id") + }) + test("sanitizes special characters", () => { //#given process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id" From 83a05630cdbf90ce953596812519993e48d6faad Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:52:34 +0900 Subject: [PATCH 09/12] feat(tools/delegate-task): add skill-resolver module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add skill-resolver.ts for resolving skill configurations - Handles skill loading and configuration resolution - Part of modular delegate-task refactoring effort 🤖 Generated with assistance of OhMyOpenCode --- src/tools/delegate-task/skill-resolver.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/tools/delegate-task/skill-resolver.ts diff --git a/src/tools/delegate-task/skill-resolver.ts b/src/tools/delegate-task/skill-resolver.ts new file mode 100644 index 000000000..db31c8988 --- /dev/null +++ b/src/tools/delegate-task/skill-resolver.ts @@ -0,0 +1,21 @@ +import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" +import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content" +import { discoverSkills } from "../../features/opencode-skill-loader" + +export async function resolveSkillContent( + skills: string[], + options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set } +): Promise<{ content: string | undefined; error: string | null }> { + if (skills.length === 0) { + return { content: undefined, error: null } + } + + const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options) + if (notFound.length > 0) { + const allSkills = await discoverSkills({ includeClaudeCodePaths: true }) + const available = allSkills.map(s => s.name).join(", ") + return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` } + } + + return { content: Array.from(resolved.values()).join("\n\n"), error: null } +} From 23bca2b4d53f8ea5247056acc45d768569b97a79 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:54:59 +0900 Subject: [PATCH 10/12] feat(tools/background-task): resolve background_output task_id title --- .../modules/background-output.ts | 51 ++++++++++++++++- src/tools/background-task/tools.test.ts | 57 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/tools/background-task/modules/background-output.ts b/src/tools/background-task/modules/background-output.ts index 87bdd9ed2..d86c4be63 100644 --- a/src/tools/background-task/modules/background-output.ts +++ b/src/tools/background-task/modules/background-output.ts @@ -4,6 +4,37 @@ import type { BackgroundOutputArgs } from "../types" import { BACKGROUND_OUTPUT_DESCRIPTION } from "../constants" import { formatTaskStatus, formatTaskResult, formatFullSession } from "./formatters" import { delay } from "./utils" +import { storeToolMetadata } from "../../../features/tool-metadata-store" +import type { BackgroundTask } from "../../../features/background-agent" +import type { ToolContextWithMetadata } from "./utils" + +const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" + +type ToolContextWithCallId = ToolContextWithMetadata & { + callID?: string + callId?: string + call_id?: string +} + +function resolveToolCallID(ctx: ToolContextWithCallId): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") { + return ctx.callID + } + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") { + return ctx.callId + } + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") { + return ctx.call_id + } + return undefined +} + +function formatResolvedTitle(task: BackgroundTask): string { + const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category + ? task.category + : task.agent + return `${label} - ${task.description}` +} export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { return tool({ @@ -19,13 +50,31 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client: include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), }, - async execute(args: BackgroundOutputArgs) { + async execute(args: BackgroundOutputArgs, toolContext) { try { + const ctx = toolContext as ToolContextWithCallId const task = manager.getTask(args.task_id) if (!task) { return `Task not found: ${args.task_id}` } + const resolvedTitle = formatResolvedTitle(task) + const meta = { + title: resolvedTitle, + metadata: { + task_id: task.id, + agent: task.agent, + category: task.category, + description: task.description, + sessionId: task.sessionID ?? "pending", + } as Record, + } + await ctx.metadata?.(meta) + const callID = resolveToolCallID(ctx) + if (callID) { + storeToolMetadata(ctx.sessionID, callID, meta) + } + if (args.full_session === true) { return await formatFullSession(task, client, { includeThinking: args.include_thinking === true, diff --git a/src/tools/background-task/tools.test.ts b/src/tools/background-task/tools.test.ts index 6c3a20993..dbb8be906 100644 --- a/src/tools/background-task/tools.test.ts +++ b/src/tools/background-task/tools.test.ts @@ -1,7 +1,11 @@ +/// + +import { describe, test, expect } from "bun:test" import { createBackgroundCancel, createBackgroundOutput } from "./tools" import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" import type { ToolContext } from "@opencode-ai/plugin/tool" import type { BackgroundCancelClient, BackgroundOutputManager, BackgroundOutputClient } from "./tools" +import { consumeToolMetadata, clearPendingStore } from "../../features/tool-metadata-store" const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" @@ -49,6 +53,59 @@ function createTask(overrides: Partial = {}): BackgroundTask { } describe("background_output full_session", () => { + test("resolves task_id into title metadata", async () => { + // #given + clearPendingStore() + + const task = createTask({ + id: "task-1", + agent: "explore", + description: "Find how task output is rendered", + status: "running", + }) + const manager = createMockManager(task) + const client = createMockClient({}) + const tool = createBackgroundOutput(manager, client) + const ctxWithCallId = { + ...mockContext, + callID: "call-1", + } as unknown as ToolContext + + // #when + await tool.execute({ task_id: "task-1" }, ctxWithCallId) + + // #then + const restored = consumeToolMetadata("test-session", "call-1") + expect(restored?.title).toBe("explore - Find how task output is rendered") + }) + + test("shows category instead of agent for sisyphus-junior", async () => { + // #given + clearPendingStore() + + const task = createTask({ + id: "task-1", + agent: "sisyphus-junior", + category: "quick", + description: "Fix flaky test", + status: "running", + }) + const manager = createMockManager(task) + const client = createMockClient({}) + const tool = createBackgroundOutput(manager, client) + const ctxWithCallId = { + ...mockContext, + callID: "call-1", + } as unknown as ToolContext + + // #when + await tool.execute({ task_id: "task-1" }, ctxWithCallId) + + // #then + const restored = consumeToolMetadata("test-session", "call-1") + expect(restored?.title).toBe("quick - Fix flaky test") + }) + test("includes thinking and tool results when enabled", async () => { // #given const task = createTask() From e257bff31cb8efb3167d5aa72a0d079d95ccead2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:00:12 +0900 Subject: [PATCH 11/12] fix(plugin-handlers): remove `as any` type assertions in config-handler tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace unsafe `as any` casts on `createBuiltinAgents` spy with properly typed `as unknown as { mockResolvedValue: ... }` pattern. Adds bun-types reference directive. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/plugin-handlers/config-handler.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 08c58f6f0..bca4ce4d1 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" import type { CategoryConfig } from "../config/schema" @@ -949,7 +951,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, atlas: { name: "atlas", prompt: "test", mode: "primary" }, @@ -987,7 +992,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is disabled", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, }) @@ -1021,7 +1029,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is undefined", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, }) From e663d7b3354fde95396bb723e32456324eb9ebdb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:00:19 +0900 Subject: [PATCH 12/12] refactor(shared): update model-availability tests to use split modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate imports from monolithic `model-availability` to split modules (`model-name-matcher`, `available-models-fetcher`, `model-cache-availability`). Replace XDG_CACHE_HOME env var manipulation with `mock.module` for `data-path`, ensuring test isolation without polluting process env. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/shared/model-availability.test.ts | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/shared/model-availability.test.ts b/src/shared/model-availability.test.ts index 80d637c74..cdd41b032 100644 --- a/src/shared/model-availability.test.ts +++ b/src/shared/model-availability.test.ts @@ -1,26 +1,38 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from "bun:test" import { mkdtempSync, writeFileSync, rmSync } from "fs" import { tmpdir } from "os" import { join } from "path" -import { fetchAvailableModels, fuzzyMatchModel, getConnectedProviders, __resetModelCache, isModelAvailable } from "./model-availability" +import { fuzzyMatchModel, isModelAvailable } from "./model-name-matcher" + +let activeCacheHomeDir: string | null = null +const DEFAULT_CACHE_HOME_DIR = join(tmpdir(), "opencode-test-default-cache") + +mock.module("./data-path", () => ({ + getDataDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, + getOpenCodeStorageDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode", "storage"), + getCacheDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, + getOmoOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "oh-my-opencode"), + getOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode"), +})) describe("fetchAvailableModels", () => { let tempDir: string - let originalXdgCache: string | undefined + let fetchAvailableModels: (client?: unknown, options?: { connectedProviders?: string[] | null }) => Promise> + let __resetModelCache: () => void + + beforeAll(async () => { + ;({ fetchAvailableModels } = await import("./available-models-fetcher")) + ;({ __resetModelCache } = await import("./model-cache-availability")) + }) beforeEach(() => { __resetModelCache() tempDir = mkdtempSync(join(tmpdir(), "opencode-test-")) - originalXdgCache = process.env.XDG_CACHE_HOME - process.env.XDG_CACHE_HOME = tempDir + activeCacheHomeDir = tempDir }) afterEach(() => { - if (originalXdgCache !== undefined) { - process.env.XDG_CACHE_HOME = originalXdgCache - } else { - delete process.env.XDG_CACHE_HOME - } + activeCacheHomeDir = null rmSync(tempDir, { recursive: true, force: true }) })