diff --git a/src/features/background-agent/background-task-completer.ts b/src/features/background-agent/background-task-completer.ts deleted file mode 100644 index 4c105eb04..000000000 --- a/src/features/background-agent/background-task-completer.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { BackgroundTask } from "./types" -import type { ResultHandlerContext } from "./result-handler-context" -import { log } from "../../shared" -import { notifyParentSession } from "./parent-session-notifier" - -export async function tryCompleteTask( - task: BackgroundTask, - source: string, - ctx: ResultHandlerContext -): Promise { - const { concurrencyManager, state } = ctx - - if (task.status !== "running") { - log("[background-agent] Task already completed, skipping:", { - taskId: task.id, - status: task.status, - source, - }) - return false - } - - task.status = "completed" - task.completedAt = new Date() - - if (task.concurrencyKey) { - concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - state.markForNotification(task) - - try { - await notifyParentSession(task, ctx) - log(`[background-agent] Task completed via ${source}:`, task.id) - } catch (error) { - log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error }) - } - - return true -} diff --git a/src/features/background-agent/format-duration.ts b/src/features/background-agent/format-duration.ts deleted file mode 100644 index 65fd8adf2..000000000 --- a/src/features/background-agent/format-duration.ts +++ /dev/null @@ -1,14 +0,0 @@ -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` - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` -} diff --git a/src/features/background-agent/index.ts b/src/features/background-agent/index.ts index 5d3905d22..e1d1a9b73 100644 --- a/src/features/background-agent/index.ts +++ b/src/features/background-agent/index.ts @@ -1,5 +1,2 @@ export * from "./types" export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager" -export { TaskHistory, type TaskHistoryEntry } from "./task-history" -export { ConcurrencyManager } from "./concurrency" -export { TaskStateManager } from "./state" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index c9ce281ba..61e5d8434 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -268,7 +268,7 @@ export class BackgroundManager { body: { parentID: input.parentSessionID, title: `${input.description} (@${input.agent} subagent)`, - } as any, + } as Record, query: { directory: parentDirectory, }, diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts deleted file mode 100644 index cf8b56ed9..000000000 --- a/src/features/background-agent/message-dir.ts +++ /dev/null @@ -1 +0,0 @@ -export { getMessageDir } from "../../shared" diff --git a/src/features/background-agent/parent-session-context-resolver.ts b/src/features/background-agent/parent-session-context-resolver.ts deleted file mode 100644 index ac71b4b44..000000000 --- a/src/features/background-agent/parent-session-context-resolver.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { OpencodeClient } from "./constants" -import type { BackgroundTask } from "./types" -import { findNearestMessageWithFields } from "../hook-message-injector" -import { getMessageDir } from "../../shared" -import { normalizePromptTools, resolveInheritedPromptTools } from "../../shared" - -type AgentModel = { providerID: string; modelID: string } - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function extractAgentAndModelFromMessage(message: unknown): { - agent?: string - model?: AgentModel - tools?: Record -} { - if (!isObject(message)) return {} - const info = message["info"] - if (!isObject(info)) return {} - - const agent = typeof info["agent"] === "string" ? info["agent"] : undefined - const modelObj = info["model"] - const tools = normalizePromptTools(isObject(info["tools"]) ? info["tools"] as Record as Record : undefined) - if (isObject(modelObj)) { - const providerID = modelObj["providerID"] - const modelID = modelObj["modelID"] - if (typeof providerID === "string" && typeof modelID === "string") { - return { agent, model: { providerID, modelID }, tools } - } - } - - const providerID = info["providerID"] - const modelID = info["modelID"] - if (typeof providerID === "string" && typeof modelID === "string") { - return { agent, model: { providerID, modelID }, tools } - } - - return { agent, tools } -} - -export async function resolveParentSessionAgentAndModel(input: { - client: OpencodeClient - task: BackgroundTask -}): Promise<{ agent?: string; model?: AgentModel; tools?: Record }> { - const { client, task } = input - - let agent: string | undefined = task.parentAgent - let model: AgentModel | undefined - let tools: Record | undefined = task.parentTools - - try { - const messagesResp = await client.session.messages({ - path: { id: task.parentSessionID }, - }) - - const messagesRaw = "data" in messagesResp ? messagesResp.data : [] - const messages = Array.isArray(messagesRaw) ? messagesRaw : [] - - for (let i = messages.length - 1; i >= 0; i--) { - const extracted = extractAgentAndModelFromMessage(messages[i]) - if (extracted.agent || extracted.model || extracted.tools) { - agent = extracted.agent ?? task.parentAgent - model = extracted.model - tools = extracted.tools ?? tools - break - } - } - } catch { - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = - currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - tools = normalizePromptTools(currentMessage?.tools) ?? tools - } - - return { agent, model, tools: resolveInheritedPromptTools(task.parentSessionID, tools) } -} diff --git a/src/features/background-agent/parent-session-notifier.test.ts b/src/features/background-agent/parent-session-notifier.test.ts deleted file mode 100644 index 098e84bb7..000000000 --- a/src/features/background-agent/parent-session-notifier.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -declare const require: (name: string) => any -const { describe, test, expect } = require("bun:test") -import type { BackgroundTask } from "./types" -import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" - -describe("notifyParentSession", () => { - test("displays INTERRUPTED for interrupted tasks", () => { - // given - const task: BackgroundTask = { - id: "test-task", - parentSessionID: "parent-session", - parentMessageID: "parent-message", - description: "Test task", - prompt: "Test prompt", - agent: "test-agent", - status: "interrupt", - startedAt: new Date(), - completedAt: new Date(), - } - const duration = "1s" - const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED" - const allComplete = false - const remainingCount = 1 - const completedTasks: BackgroundTask[] = [] - - // when - const notification = buildBackgroundTaskNotificationText({ - task, - duration, - statusText, - allComplete, - remainingCount, - completedTasks, - }) - - // then - expect(notification).toContain("INTERRUPTED") - }) -}) \ No newline at end of file diff --git a/src/features/background-agent/parent-session-notifier.ts b/src/features/background-agent/parent-session-notifier.ts deleted file mode 100644 index 5116888da..000000000 --- a/src/features/background-agent/parent-session-notifier.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { BackgroundTask } from "./types" -import type { ResultHandlerContext } from "./result-handler-context" -import { TASK_CLEANUP_DELAY_MS } from "./constants" -import { createInternalAgentTextPart, log } from "../../shared" -import { getTaskToastManager } from "../task-toast-manager" -import { formatDuration } from "./duration-formatter" -import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" -import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver" - -export async function notifyParentSession( - task: BackgroundTask, - ctx: ResultHandlerContext -): Promise { - const { client, state } = ctx - - const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt) - log("[background-agent] notifyParentSession called for task:", task.id) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.showCompletionToast({ - id: task.id, - description: task.description, - duration, - }) - } - - const pendingSet = state.pendingByParent.get(task.parentSessionID) - if (pendingSet) { - pendingSet.delete(task.id) - if (pendingSet.size === 0) { - state.pendingByParent.delete(task.parentSessionID) - } - } - - const allComplete = !pendingSet || pendingSet.size === 0 - const remainingCount = pendingSet?.size ?? 0 - - const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED" - - const completedTasks = allComplete - ? Array.from(state.tasks.values()).filter( - (t) => - t.parentSessionID === task.parentSessionID && - t.status !== "running" && - t.status !== "pending" - ) - : [] - - const notification = buildBackgroundTaskNotificationText({ - task, - duration, - statusText, - allComplete, - remainingCount, - completedTasks, - }) - - const { agent, model, tools } = await resolveParentSessionAgentAndModel({ client, task }) - - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - ...(tools ? { tools } : {}), - parts: [createInternalAgentTextPart(notification)], - }, - }) - - log("[background-agent] Sent notification to parent session:", { - taskId: task.id, - allComplete, - noReply: !allComplete, - }) - } catch (error) { - log("[background-agent] Failed to send notification:", error) - } - - if (!allComplete) return - - for (const completedTask of completedTasks) { - const taskId = completedTask.id - state.clearCompletionTimer(taskId) - const timer = setTimeout(() => { - state.completionTimers.delete(taskId) - if (state.tasks.has(taskId)) { - state.clearNotificationsForTask(taskId) - state.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, TASK_CLEANUP_DELAY_MS) - state.setCompletionTimer(taskId, timer) - } -} diff --git a/src/features/background-agent/result-handler-context.ts b/src/features/background-agent/result-handler-context.ts deleted file mode 100644 index 7aa629542..000000000 --- a/src/features/background-agent/result-handler-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { OpencodeClient } from "./constants" -import type { ConcurrencyManager } from "./concurrency" -import type { TaskStateManager } from "./state" - -export interface ResultHandlerContext { - client: OpencodeClient - concurrencyManager: ConcurrencyManager - state: TaskStateManager -} diff --git a/src/features/background-agent/result-handler.ts b/src/features/background-agent/result-handler.ts deleted file mode 100644 index 3f9f9a7a2..000000000 --- a/src/features/background-agent/result-handler.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { ResultHandlerContext } from "./result-handler-context" -export { formatDuration } from "./duration-formatter" -export { getMessageDir } from "../../shared" -export { checkSessionTodos } from "./session-todo-checker" -export { validateSessionHasOutput } from "./session-output-validator" -export { tryCompleteTask } from "./background-task-completer" -export { notifyParentSession } from "./parent-session-notifier" diff --git a/src/features/background-agent/session-output-validator.ts b/src/features/background-agent/session-output-validator.ts deleted file mode 100644 index 8e14a21c8..000000000 --- a/src/features/background-agent/session-output-validator.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { OpencodeClient } from "./constants" -import { log } from "../../shared" - -type SessionMessagePart = { - type?: string - text?: string - content?: unknown -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function getMessageRole(message: unknown): string | undefined { - if (!isObject(message)) return undefined - const info = message["info"] - if (!isObject(info)) return undefined - const role = info["role"] - return typeof role === "string" ? role : undefined -} - -function getMessageParts(message: unknown): SessionMessagePart[] { - if (!isObject(message)) return [] - const parts = message["parts"] - if (!Array.isArray(parts)) return [] - - return parts - .filter((part): part is SessionMessagePart => isObject(part)) - .map((part) => ({ - type: typeof part["type"] === "string" ? part["type"] : undefined, - text: typeof part["text"] === "string" ? part["text"] : undefined, - content: part["content"], - })) -} - -function partHasContent(part: SessionMessagePart): boolean { - if (part.type === "text" || part.type === "reasoning") { - return Boolean(part.text && part.text.trim().length > 0) - } - if (part.type === "tool") return true - if (part.type === "tool_result") { - if (typeof part.content === "string") return part.content.trim().length > 0 - if (Array.isArray(part.content)) return part.content.length > 0 - return Boolean(part.content) - } - return false -} - -export async function validateSessionHasOutput( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.messages({ - path: { id: sessionID }, - }) - - const messagesRaw = - isObject(response) && "data" in response ? (response as { data?: unknown }).data : response - const messages = Array.isArray(messagesRaw) ? messagesRaw : [] - - const hasAssistantOrToolMessage = messages.some((message) => { - const role = getMessageRole(message) - return role === "assistant" || role === "tool" - }) - - if (!hasAssistantOrToolMessage) { - log("[background-agent] No assistant/tool messages found in session:", sessionID) - return false - } - - const hasContent = messages.some((message) => { - const role = getMessageRole(message) - if (role !== "assistant" && role !== "tool") return false - const parts = getMessageParts(message) - return parts.some(partHasContent) - }) - - if (!hasContent) { - log("[background-agent] Messages exist but no content found in session:", sessionID) - return false - } - - return true - } catch (error) { - log("[background-agent] Error validating session output:", error) - return true - } -} diff --git a/src/features/background-agent/session-task-cleanup.ts b/src/features/background-agent/session-task-cleanup.ts deleted file mode 100644 index 4130da4ed..000000000 --- a/src/features/background-agent/session-task-cleanup.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { subagentSessions } from "../claude-code-session-state" -import type { BackgroundTask } from "./types" - -export function cleanupTaskAfterSessionEnds(args: { - task: BackgroundTask - tasks: Map - idleDeferralTimers: Map> - completionTimers: Map> - cleanupPendingByParent: (task: BackgroundTask) => void - clearNotificationsForTask: (taskId: string) => void - releaseConcurrencyKey?: (key: string) => void -}): void { - const { - task, - tasks, - idleDeferralTimers, - completionTimers, - cleanupPendingByParent, - clearNotificationsForTask, - releaseConcurrencyKey, - } = args - - const completionTimer = completionTimers.get(task.id) - if (completionTimer) { - clearTimeout(completionTimer) - completionTimers.delete(task.id) - } - - const idleTimer = idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - idleDeferralTimers.delete(task.id) - } - - if (task.concurrencyKey && releaseConcurrencyKey) { - releaseConcurrencyKey(task.concurrencyKey) - task.concurrencyKey = undefined - } - - cleanupPendingByParent(task) - clearNotificationsForTask(task.id) - tasks.delete(task.id) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - } -} diff --git a/src/features/background-agent/session-todo-checker.ts b/src/features/background-agent/session-todo-checker.ts deleted file mode 100644 index c1bad3375..000000000 --- a/src/features/background-agent/session-todo-checker.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { OpencodeClient, Todo } from "./constants" - -function isTodo(value: unknown): value is Todo { - if (typeof value !== "object" || value === null) return false - const todo = value as Record - return ( - (typeof todo["id"] === "string" || todo["id"] === undefined) && - typeof todo["content"] === "string" && - typeof todo["status"] === "string" && - typeof todo["priority"] === "string" - ) -} - -export async function checkSessionTodos( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.todo({ - path: { id: sessionID }, - }) - - const todosRaw = "data" in response ? response.data : response - if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false - - const incomplete = todosRaw - .filter(isTodo) - .filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") - return incomplete.length > 0 - } catch { - return false - } -} diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index fb5f32960..56817c915 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -61,9 +61,7 @@ export async function startTask( const createResult = await client.session.create({ body: { parentID: input.parentSessionID, - title: `Background: ${input.description}`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, + } as Record, query: { directory: parentDirectory, }, diff --git a/src/features/background-agent/spawner/background-session-creator.ts b/src/features/background-agent/spawner/background-session-creator.ts deleted file mode 100644 index 9b27d869d..000000000 --- a/src/features/background-agent/spawner/background-session-creator.ts +++ /dev/null @@ -1,45 +0,0 @@ -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}`, - } - - const createResult = await client.session - .create({ - body, - query: { - directory: parentDirectory, - }, - }) - .catch((error: unknown) => { - 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 deleted file mode 100644 index 7165877cc..000000000 --- a/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/spawner-context.ts b/src/features/background-agent/spawner/spawner-context.ts deleted file mode 100644 index 3b6bb1484..000000000 --- a/src/features/background-agent/spawner/spawner-context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { BackgroundTask } from "../types" -import type { ConcurrencyManager } from "../concurrency" -import type { OpencodeClient, OnSubagentSessionCreated } from "../constants" - -export interface SpawnerContext { - client: OpencodeClient - directory: string - concurrencyManager: ConcurrencyManager - tmuxEnabled: boolean - onSubagentSessionCreated?: OnSubagentSessionCreated - onTaskError: (task: BackgroundTask, error: Error) => void -} diff --git a/src/features/background-agent/spawner/tmux-callback-invoker.ts b/src/features/background-agent/spawner/tmux-callback-invoker.ts deleted file mode 100644 index 139dd8b71..000000000 --- a/src/features/background-agent/spawner/tmux-callback-invoker.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { setTimeout } from "timers/promises" -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: unknown) => { - log("[background-agent] Failed to spawn tmux pane:", error) - }) - - log("[background-agent] tmux callback completed, waiting") - await setTimeout(TMUX_CALLBACK_DELAY_MS) -}